Skip to main content

krabka_remote_storage_topic/
kafka_log.rs

1//! [`KafkaMetadataEventLog`]: the production [`MetadataEventLog`]
2//! adapter that persists events in the internal `__remote_log_metadata`
3//! Kafka topic.
4//!
5//! Writes flow through a [`krabka_client_producer::Producer`] with
6//! explicit per-record partition pinning. Reads come back through one
7//! cancellable manual-`Fetch` task per assigned partition. Each task
8//! drives its own dedicated [`krabka_client_core::Connection`] and emits
9//! [`MetadataEventRecord`](crate::MetadataEventRecord)s into a shared
10//! mpsc. There is **no consumer group and no broker-side offset commit**.
11//! The RLMM owns the read position. The manager assigns all partitions from
12//! offset 0 today, then resumes from snapshot offsets and restricts the
13//! consumed set.
14//!
15//! A dedicated connection per partition is necessary because the broker is
16//! serial per-connection. A long-`max_wait_ms` fetch would
17//! head-of-line-block any other RPC that shares the socket.
18//!
19//! Topic provisioning runs once at [`KafkaMetadataEventLog::start`] through
20//! the [`krabka_client_admin::AdminClient`]. It reuses an existing topic, and
21//! the topic's actual partition count then overrides the configured
22//! `num_partitions`. It creates an absent topic with the configured cleanup
23//! policy and `retention.ms=-1`. The same admin round-trip surfaces the topic's
24//! `Uuid`, which the manual `Fetch` path needs, because Fetch v≥13 carries
25//! `topic_id` and not the name.
26//!
27//! One `ListOffsets(timestamp=-1)` over the raw
28//! [`krabka_client_core::Client`] pulls the high-water marks, rather than a
29//! consumer. [`MetadataEventLog::high_water_marks`] therefore does not need
30//! any fetch task to have made progress.
31//!
32//! [`KafkaMetadataEventLog::open_read_only`] opens the same log without a
33//! producer and without provisioning. It sends no `InitProducerId` and no
34//! `Produce`, so a principal that holds only `READ` and `DESCRIBE` on the topic
35//! can use it. [`MetadataEventLog::visit_range`] then reads a bounded offset
36//! range from the partition leader one page at a time, and follows the leader
37//! when it moves. The `range` module holds that loop.
38
39use {
40    async_trait::async_trait,
41    bytes::Bytes,
42    futures_util::stream::{StreamExt, unfold},
43    krabka_client_core::{
44        BrokerInfo, BrokerPool, Client, ClientError, ClientFrameMax, Connection,
45        ConnectionDispatchQueueCapacity, ConnectionOptions, FetchMinBytes, FetchPartitionResult,
46        IsolatedFetch, MetadataScope, connection_target_host,
47        fetch_partition_with_isolation_progress,
48    },
49    krabka_client_producer::{Acks, Producer, ProducerRecord},
50    krabka_protocol::{
51        owned::list_offsets_request::{ListOffsetsPartition, ListOffsetsRequest, ListOffsetsTopic},
52        primitives::uuid::Uuid as WireUuid,
53    },
54    krabka_units::prelude::{ByteSize, Time, TimeExt as _},
55    std::{
56        collections::{BTreeMap, HashMap},
57        sync::{Arc, Mutex as StdMutex},
58    },
59    tracing::{instrument, warn},
60};
61
62mod config;
63mod consumer;
64mod range;
65mod topic;
66
67pub use self::config::{
68    DEFAULT_METADATA_EVENT_QUEUE_CAPACITY, DEFAULT_METADATA_FETCH_MAX_BYTES,
69    DEFAULT_METADATA_FETCH_MAX_WAIT, DEFAULT_METADATA_FETCH_RETRY_BACKOFF,
70    DEFAULT_METADATA_TOPIC_CREATE_TIMEOUT, DEFAULT_MIN_ISR, DEFAULT_NUM_PARTITIONS,
71    DEFAULT_REPLICATION, KafkaMetadataLogConfig, METADATA_TOPIC, MetadataEventQueueCapacity,
72};
73
74fn connection_options(cfg: &KafkaMetadataLogConfig, purpose: &str) -> ConnectionOptions {
75    ConnectionOptions {
76        client_id: format!("{}-{purpose}", cfg.client_id),
77        dispatch_queue_capacity: cfg.dispatch_queue_capacity,
78        frame_max: cfg.frame_max,
79        security: cfg.security.clone().map(Box::new),
80        ..ConnectionOptions::default()
81    }
82}
83
84use {
85    self::{
86        consumer::{
87            ConsumerState, KafkaAssignmentHandle, MetadataEventChannel, metadata_event_channel,
88        },
89        range::{RangeFetcher, RangeReadFailure, RangeTopic, visit_range_pages},
90        topic::{TopicInfo, ensure_topic},
91    },
92    crate::{
93        error::MetadataLogError,
94        log::{
95            AssignmentHandle, KeyedEvent, MetadataEventLog, MetadataEventStream, PartitionStart,
96            RangeVisitor,
97        },
98    },
99    krabka_client_core::{security::ClientSecurity, transport::resolve},
100    krabka_protocol::owned::metadata_response::MetadataResponse,
101    std::net::SocketAddr,
102    tokio::{sync::Mutex, time},
103};
104
105/// Whether an opened log may write.
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107enum Access {
108    /// Provision the topic when configured to, and build the idempotent
109    /// producer.
110    ReadWrite,
111    /// Neither provision nor produce.
112    ReadOnly,
113}
114
115/// Production [`MetadataEventLog`] backed by an internal Kafka topic.
116pub struct KafkaMetadataEventLog {
117    /// `None` for a log from [`KafkaMetadataEventLog::open_read_only`].
118    producer: Option<Producer>,
119    client: Client,
120    /// Leader connections for [`MetadataEventLog::visit_range`].
121    readers: BrokerPool,
122    topic: String,
123    topic_id: WireUuid,
124    partition_count: i32,
125    bootstrap: String,
126    client_id: String,
127    security: Option<ClientSecurity>,
128    fetch_max_wait: Time,
129    fetch_max_bytes: ByteSize,
130    fetch_retry_backoff: Time,
131    event_queue_capacity: MetadataEventQueueCapacity,
132    dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
133    frame_max: ClientFrameMax,
134    subscriptions: Mutex<Vec<Arc<ConsumerState>>>,
135}
136
137impl KafkaMetadataEventLog {
138    /// Provision the topic if it is missing, connect the producer and the
139    /// raw client, learn the topic id, and return the log.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`MetadataLogError::Other`] on admin / producer /
144    /// client construction failures.
145    #[instrument(skip_all, fields(topic = %cfg.topic, bootstrap = %cfg.bootstrap), err)]
146    pub async fn start(cfg: KafkaMetadataLogConfig) -> Result<Arc<Self>, MetadataLogError> {
147        Box::pin(Self::open(cfg, Access::ReadWrite)).await
148    }
149
150    /// Open an existing topic for reads only, and return the log.
151    ///
152    /// The log neither provisions the topic nor changes its cleanup policy,
153    /// whatever `cfg.provision_topic` and `cfg.compacted` say, and it builds
154    /// no producer. Its reads therefore need only `DESCRIBE` and `READ` on the
155    /// topic. [`MetadataEventLog::publish`] and
156    /// [`MetadataEventLog::publish_keyed`] return
157    /// [`MetadataLogError::Publish`] without contacting the broker.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`MetadataLogError::Other`] when the topic does not exist, and
162    /// on admin or client construction failures.
163    #[instrument(skip_all, fields(topic = %cfg.topic, bootstrap = %cfg.bootstrap), err)]
164    pub async fn open_read_only(
165        mut cfg: KafkaMetadataLogConfig,
166    ) -> Result<Arc<Self>, MetadataLogError> {
167        cfg.provision_topic = false;
168        cfg.compacted = false;
169        Box::pin(Self::open(cfg, Access::ReadOnly)).await
170    }
171
172    async fn open(
173        cfg: KafkaMetadataLogConfig,
174        access: Access,
175    ) -> Result<Arc<Self>, MetadataLogError> {
176        cfg.validate()
177            .map_err(|error| MetadataLogError::Other(format!("invalid config: {error}")))?;
178
179        // 1. Provision the topic, learn its partition count and id. The
180        //    manual Fetch path needs the topic Uuid (Fetch v≥13 carries
181        //    topic_id, not the name).
182        let TopicInfo {
183            partition_count,
184            topic_id,
185        } = ensure_topic(&cfg).await?;
186
187        // 2. Producer with acks=All and idempotence on. Read-your-writes
188        //    depends on the broker durably acking the publish.
189        //    A read-only log builds none, so it never sends InitProducerId.
190        let producer = match access {
191            Access::ReadWrite => Some(
192                Producer::builder()
193                    .bootstrap(cfg.bootstrap.clone())
194                    .client_id(format!("{}-producer", cfg.client_id))
195                    .dispatch_queue_capacity(cfg.dispatch_queue_capacity.get())
196                    .frame_max(cfg.frame_max.size())
197                    .acks(Acks::All)
198                    .enable_idempotence(true)
199                    .maybe_security(cfg.security.clone())
200                    .build()
201                    .await
202                    .map_err(|e| MetadataLogError::Other(format!("producer build failed: {e}")))?,
203            ),
204            Access::ReadOnly => None,
205        };
206
207        // 3. Raw client for ListOffsets and any future low-level queries.
208        let client = Client::builder()
209            .bootstrap(cfg.bootstrap.clone())
210            .client_id(format!("{}-client", cfg.client_id))
211            .dispatch_queue_capacity(cfg.dispatch_queue_capacity.get())
212            .frame_max(cfg.frame_max.size())
213            .maybe_security(cfg.security.clone())
214            .metadata_scope(METADATA_TOPIC_SCOPE)
215            .build()
216            .await
217            .map_err(|e| MetadataLogError::Other(format!("client build failed: {e}")))?;
218        client.metadata_topics().set([cfg.topic.as_str()]);
219
220        // 4. Leader connections for bounded range reads.
221        let reader_options = connection_options(&cfg, "reader");
222        let readers = BrokerPool::new_with_server_names(
223            resolve_bootstrap(&cfg.bootstrap, &reader_options).await?,
224            reader_options,
225        );
226
227        Ok(Arc::new(Self {
228            producer,
229            client,
230            readers,
231            topic: cfg.topic,
232            topic_id,
233            partition_count,
234            bootstrap: cfg.bootstrap,
235            client_id: cfg.client_id,
236            security: cfg.security,
237            fetch_max_wait: cfg.fetch_max_wait,
238            fetch_max_bytes: cfg.fetch_max_bytes,
239            fetch_retry_backoff: cfg.fetch_retry_backoff,
240            event_queue_capacity: cfg.event_queue_capacity,
241            dispatch_queue_capacity: cfg.dispatch_queue_capacity,
242            frame_max: cfg.frame_max,
243            subscriptions: Mutex::new(Vec::new()),
244        }))
245    }
246
247    /// Cancel the fetch tasks of every active subscription. A drop also
248    /// cancels them.
249    pub async fn shutdown(&self) {
250        let mut subs = self.subscriptions.lock().await;
251        for state in subs.drain(..) {
252            state.cancel_all();
253        }
254    }
255
256    async fn list_offsets(&self, timestamp: i64) -> Result<Vec<i64>, MetadataLogError> {
257        let metadata = self
258            .client
259            .refresh_metadata()
260            .await
261            .map_err(|e| MetadataLogError::Other(format!("Metadata failed: {e}")))?;
262        let mut by_leader: BTreeMap<i32, Vec<i32>> = BTreeMap::new();
263        for partition in 0..self.partition_count {
264            let leader = partition_leader(&metadata, &self.topic, partition).ok_or_else(|| {
265                MetadataLogError::Other(format!(
266                    "{} partition {partition} has no available leader",
267                    self.topic
268                ))
269            })?;
270            by_leader.entry(leader).or_default().push(partition);
271        }
272        let mut offsets = vec![0i64; usize_count(self.partition_count)?];
273        for (leader, partitions) in by_leader {
274            let req = ListOffsetsRequest {
275                replica_id: -1,
276                isolation_level: 0,
277                topics: vec![ListOffsetsTopic {
278                    name: self.topic.clone(),
279                    partitions: partitions
280                        .into_iter()
281                        .map(|partition_index| ListOffsetsPartition {
282                            partition_index,
283                            current_leader_epoch: -1,
284                            timestamp,
285                            ..Default::default()
286                        })
287                        .collect(),
288                    ..Default::default()
289                }],
290                ..Default::default()
291            };
292            let resp = self.client.broker(leader).send(req).await.map_err(|e| {
293                MetadataLogError::Other(format!("ListOffsets from broker {leader} failed: {e}"))
294            })?;
295            for p in resp.topics.iter().flat_map(|topic| &topic.partitions) {
296                if p.error_code != 0 {
297                    return Err(MetadataLogError::Other(format!(
298                        "ListOffsets partition {} error {}",
299                        p.partition_index, p.error_code
300                    )));
301                }
302                if let Some(slot) = usize::try_from(p.partition_index)
303                    .ok()
304                    .and_then(|idx| offsets.get_mut(idx))
305                {
306                    *slot = p.offset;
307                }
308            }
309        }
310        Ok(offsets)
311    }
312}
313
314/// Resolve every `host:port` of a bootstrap list inside the DNS deadline,
315/// keeping each host name for TLS.
316async fn resolve_bootstrap(
317    bootstrap: &str,
318    options: &ConnectionOptions,
319) -> Result<Vec<(SocketAddr, String)>, MetadataLogError> {
320    let mut resolved = Vec::new();
321    for address in bootstrap.split(',').map(str::trim) {
322        if address.is_empty() {
323            continue;
324        }
325        let lookup = time::timeout(options.dns_timeout.time().to_std(), resolve(address))
326            .await
327            .map_err(|_elapsed| {
328                MetadataLogError::Other(format!("DNS lookup of {address} timed out"))
329            })?
330            .map_err(|error| {
331                MetadataLogError::Other(format!("DNS lookup of {address} failed: {error}"))
332            })?;
333        resolved.extend(
334            lookup
335                .into_iter()
336                .map(|socket| (socket, connection_target_host(address).to_owned())),
337        );
338    }
339    if resolved.is_empty() {
340        return Err(MetadataLogError::Other(format!(
341            "bootstrap {bootstrap:?} resolved to no address"
342        )));
343    }
344    Ok(resolved)
345}
346
347/// The brokers a `Metadata` response names, in the pool's shape.
348fn brokers_of(metadata: &MetadataResponse) -> Vec<BrokerInfo> {
349    metadata
350        .brokers
351        .iter()
352        .map(|broker| BrokerInfo {
353            id: broker.node_id,
354            host: broker.host.clone(),
355            port: broker.port,
356            rack: broker.rack.clone(),
357        })
358        .collect()
359}
360
361/// The metadata scope of every RLMM client: only the metadata topic.
362///
363/// Kafka's `TopicBasedRemoteLogMetadataManager` reads through a consumer that
364/// `assign`s the metadata topic partitions, and its `ConsumerMetadata` names
365/// exactly the assigned topics. The RLMM creates the topic itself, so no
366/// request may auto-create it with broker defaults.
367pub(super) const METADATA_TOPIC_SCOPE: MetadataScope = MetadataScope::Topics {
368    allow_auto_topic_creation: false,
369};
370
371pub(super) fn partition_leader(
372    metadata: &MetadataResponse,
373    topic: &str,
374    partition: i32,
375) -> Option<i32> {
376    metadata
377        .topics
378        .iter()
379        .find(|entry| entry.name.as_deref() == Some(topic) && entry.error_code == 0)?
380        .partitions
381        .iter()
382        .find(|entry| entry.partition_index == partition && entry.error_code == 0)
383        .map(|entry| entry.leader_id)
384        .filter(|leader| *leader >= 0)
385}
386
387impl Drop for KafkaMetadataEventLog {
388    fn drop(&mut self) {
389        if let Ok(mut subs) = self.subscriptions.try_lock() {
390            for state in subs.drain(..) {
391                state.cancel_all();
392            }
393        }
394    }
395}
396
397#[async_trait]
398impl MetadataEventLog for KafkaMetadataEventLog {
399    fn partition_count(&self) -> i32 { self.partition_count }
400
401    #[instrument(
402        level = "debug",
403        skip_all,
404        fields(topic = %self.topic, partition, len = event.len()),
405        err
406    )]
407    async fn publish(&self, partition: i32, event: Bytes) -> Result<i64, MetadataLogError> {
408        self.publish_record(partition, None, Some(event)).await
409    }
410
411    async fn publish_keyed(
412        &self,
413        partition: i32,
414        key: Bytes,
415        event: Option<Bytes>,
416    ) -> Result<i64, MetadataLogError> {
417        self.publish_record(partition, Some(key), event).await
418    }
419
420    async fn publish_keyed_group(
421        &self,
422        events: Vec<KeyedEvent>,
423    ) -> Result<Vec<i64>, MetadataLogError> {
424        self.publish_record_group(events).await
425    }
426
427    fn subscribe(
428        &self,
429        assignment: Vec<PartitionStart>,
430    ) -> (MetadataEventStream, Arc<dyn AssignmentHandle>) {
431        let MetadataEventChannel {
432            sender: tx,
433            receiver: rx,
434        } = metadata_event_channel(self.event_queue_capacity);
435        let state = Arc::new(ConsumerState {
436            bootstrap: self.bootstrap.clone(),
437            client_id: format!("{}-consumer", self.client_id),
438            security: self.security.clone(),
439            topic: self.topic.clone(),
440            topic_id: self.topic_id,
441            tx,
442            fetch_max_wait: self.fetch_max_wait,
443            fetch_max_bytes: self.fetch_max_bytes,
444            fetch_retry_backoff: self.fetch_retry_backoff,
445            dispatch_queue_capacity: self.dispatch_queue_capacity,
446            frame_max: self.frame_max,
447            tasks: StdMutex::new(HashMap::new()),
448        });
449        for ps in assignment {
450            state.spawn_partition(ps);
451        }
452        if let Ok(mut subs) = self.subscriptions.try_lock() {
453            subs.push(Arc::clone(&state));
454        } else {
455            warn!("KafkaMetadataEventLog: could not track subscription state");
456        }
457        let stream = unfold(rx, |mut rx| async move { rx.recv().await.map(|r| (r, rx)) }).boxed();
458        let handle: Arc<dyn AssignmentHandle> = Arc::new(KafkaAssignmentHandle { state });
459        (stream, handle)
460    }
461
462    async fn low_water_marks(&self) -> Result<Vec<i64>, MetadataLogError> {
463        self.list_offsets(-2).await // EARLIEST
464    }
465
466    #[instrument(
467        level = "debug",
468        skip_all,
469        fields(topic = %self.topic, partition_count = self.partition_count),
470        err
471    )]
472    async fn high_water_marks(&self) -> Result<Vec<i64>, MetadataLogError> {
473        self.list_offsets(-1).await // LATEST
474    }
475
476    #[instrument(
477        level = "debug",
478        skip_all,
479        fields(topic = %self.topic, partition, start, end),
480        err
481    )]
482    async fn visit_range(
483        &self,
484        partition: i32,
485        start: i64,
486        end: i64,
487        visit: &mut RangeVisitor<'_>,
488    ) -> Result<(), MetadataLogError> {
489        let topic = RangeTopic {
490            name: &self.topic,
491            partition_count: self.partition_count,
492            retry_backoff: self.fetch_retry_backoff.to_std(),
493        };
494        visit_range_pages(self, &topic, partition, start, end, visit).await
495    }
496}
497
498impl RangeFetcher for KafkaMetadataEventLog {
499    type Leader = Arc<Connection>;
500
501    // cargo-mutants: needs a live leader; `range` tests the loop around it
502    #[cfg_attr(test, mutants::skip)]
503    async fn leader(&self, partition: i32) -> Result<Arc<Connection>, RangeReadFailure> {
504        self.leader_connection(partition).await
505    }
506
507    // cargo-mutants: needs a live leader; `range` tests the loop around it
508    #[cfg_attr(test, mutants::skip)]
509    async fn fetch(
510        &self,
511        leader: &Arc<Connection>,
512        partition: i32,
513        offset: i64,
514    ) -> Result<FetchPartitionResult, ClientError> {
515        fetch_partition_with_isolation_progress(leader, IsolatedFetch {
516            topic: &self.topic,
517            topic_id: self.topic_id,
518            partition,
519            fetch_offset: offset,
520            max_wait: self.fetch_max_wait,
521            max: krabka_client_core::DEFAULT_FETCH_RESPONSE_MAX,
522            partition_max: self.fetch_max_bytes,
523            fetch_min: FetchMinBytes::default(),
524            isolation_level: 0,
525        })
526        .await
527    }
528}
529
530impl KafkaMetadataEventLog {
531    async fn publish_record(
532        &self,
533        partition: i32,
534        key: Option<Bytes>,
535        event: Option<Bytes>,
536    ) -> Result<i64, MetadataLogError> {
537        let record = producer_record(&self.topic, self.partition_count, partition, key, event)?;
538        let meta = self
539            .producer()?
540            .send(record)
541            .await
542            .map_err(|e| MetadataLogError::Publish(e.to_string()))?;
543        Ok(meta.offset)
544    }
545
546    /// Enqueue every record of the group, then wait for every
547    /// acknowledgment.
548    ///
549    /// The producer appends records to each partition's batch in enqueue order
550    /// and keeps one idempotent request in flight per partition, so a
551    /// partition's records land in the order given. Records stop being
552    /// enqueued at the first one the producer refuses, and every record
553    /// already enqueued is still awaited, so no record is in flight when this
554    /// returns.
555    async fn publish_record_group(
556        &self,
557        events: Vec<KeyedEvent>,
558    ) -> Result<Vec<i64>, MetadataLogError> {
559        self.publish_record_group_through(self.producer()?, events)
560            .await
561    }
562
563    /// The producer, or an error for a read-only log.
564    fn producer(&self) -> Result<&Producer, MetadataLogError> {
565        self.producer
566            .as_ref()
567            .ok_or_else(|| MetadataLogError::Publish("the metadata log is read-only".into()))
568    }
569
570    /// [`Self::publish_record_group`] through `producer`.
571    async fn publish_record_group_through(
572        &self,
573        producer: &Producer,
574        events: Vec<KeyedEvent>,
575    ) -> Result<Vec<i64>, MetadataLogError> {
576        let mut failure = None;
577        let mut handles = Vec::with_capacity(events.len());
578        for KeyedEvent {
579            partition,
580            key,
581            event,
582        } in events
583        {
584            let enqueued = match producer_record(
585                &self.topic,
586                self.partition_count,
587                partition,
588                Some(key),
589                event,
590            ) {
591                Ok(record) => producer
592                    .enqueue(record)
593                    .await
594                    .map_err(|e| MetadataLogError::Publish(e.to_string())),
595                Err(error) => Err(error),
596            };
597            match enqueued {
598                Ok(handle) => handles.push(handle),
599                Err(error) => {
600                    failure = Some(error);
601                    break;
602                }
603            }
604        }
605        let mut offsets = Vec::with_capacity(handles.len());
606        for handle in handles {
607            match handle.await {
608                Ok(meta) => offsets.push(meta.offset),
609                Err(error) => {
610                    failure.get_or_insert_with(|| MetadataLogError::Publish(error.to_string()));
611                }
612            }
613        }
614        failure.map_or(Ok(offsets), Err)
615    }
616
617    /// A connection to the current leader of `partition`.
618    ///
619    /// A single-broker cluster that advertises port `0` leaves its broker out
620    /// of the pool's registry. On such a cluster the bootstrap broker is the
621    /// leader, so the read falls back to the bootstrap connection.
622    async fn leader_connection(&self, partition: i32) -> Result<Arc<Connection>, RangeReadFailure> {
623        let metadata = self
624            .client
625            .refresh_metadata()
626            .await
627            .map_err(|error| RangeReadFailure::client("Metadata failed", &error))?;
628        let leader = partition_leader(&metadata, &self.topic, partition).ok_or_else(|| {
629            RangeReadFailure::retriable(format!(
630                "{} partition {partition} has no available leader",
631                self.topic
632            ))
633        })?;
634        self.readers.refresh_brokers(&brokers_of(&metadata)).await;
635        match self.readers.get(leader).await {
636            Err(ClientError::Disconnected) if !self.readers.knows_broker(leader) => {
637                self.readers.bootstrap_connection().await
638            }
639            connection => connection,
640        }
641        .map_err(|error| {
642            RangeReadFailure::client(&format!("connect to broker {leader} failed"), &error)
643        })
644    }
645}
646
647fn producer_record(
648    topic: &str,
649    partition_count: i32,
650    partition: i32,
651    key: Option<Bytes>,
652    event: Option<Bytes>,
653) -> Result<ProducerRecord, MetadataLogError> {
654    if partition < 0 || partition >= partition_count {
655        return Err(MetadataLogError::PartitionOutOfRange {
656            partition,
657            count: partition_count,
658        });
659    }
660    Ok(ProducerRecord {
661        topic: topic.to_owned(),
662        partition: Some(partition),
663        key,
664        value: event,
665        ..Default::default()
666    })
667}
668
669fn usize_count(n: i32) -> Result<usize, MetadataLogError> {
670    usize::try_from(n).map_err(|_| MetadataLogError::Other(format!("partition_count {n} negative")))
671}
672
673#[cfg(test)]
674mod tests {
675    use {
676        super::*,
677        assert2::{assert, check},
678    };
679
680    #[test]
681    fn metadata_requests_name_only_the_metadata_topic_without_auto_creation() {
682        use {
683            krabka_client_core::MetadataTopics,
684            krabka_protocol::owned::metadata_request::{MetadataRequest, MetadataRequestTopic},
685        };
686
687        let topics = MetadataTopics::new(METADATA_TOPIC_SCOPE);
688        topics.set([config::METADATA_TOPIC]);
689
690        check!(
691            topics.request()
692                == MetadataRequest {
693                    topics: Some(vec![MetadataRequestTopic {
694                        name: Some(config::METADATA_TOPIC.to_owned()),
695                        ..Default::default()
696                    }]),
697                    allow_auto_topic_creation: false,
698                    ..Default::default()
699                }
700        );
701    }
702
703    #[tokio::test]
704    async fn open_read_only_rejects_invalid_policy_before_connecting() {
705        let cfg = KafkaMetadataLogConfig {
706            topic_create_timeout: Time::ZERO,
707            ..KafkaMetadataLogConfig::new("not a socket address")
708        };
709
710        let error = KafkaMetadataEventLog::open_read_only(cfg)
711            .await
712            .err()
713            .expect("invalid policy must fail before network I/O");
714        assert!(error.to_string().contains("topic_create_timeout"));
715    }
716
717    #[yare::parameterized(
718        empty = { "", "bootstrap \"\" resolved to no address" },
719        separators_only = { " , ", "bootstrap \" , \" resolved to no address" },
720        missing_port = { "no-port", "DNS lookup of no-port failed" },
721    )]
722    #[test_macro(tokio::test)]
723    async fn bootstrap_resolution_names_the_address_it_could_not_use(
724        bootstrap: &str,
725        expected: &str,
726    ) {
727        let options = ConnectionOptions::default();
728        let error = resolve_bootstrap(bootstrap, &options).await.unwrap_err();
729        check!(
730            error.to_string().contains(expected),
731            "{bootstrap:?}: {error}"
732        );
733    }
734
735    #[tokio::test]
736    async fn bootstrap_resolution_keeps_valid_addresses_in_order() {
737        let options = ConnectionOptions::default();
738        let resolved = resolve_bootstrap(" 127.0.0.1:9092, ,localhost:9093", &options)
739            .await
740            .unwrap();
741        check!(
742            resolved.first() == Some(&("127.0.0.1:9092".parse().unwrap(), "127.0.0.1".to_owned()))
743        );
744        check!(
745            resolved
746                .iter()
747                .skip(1)
748                .all(|(address, host)| address.port() == 9093 && host == "localhost")
749        );
750    }
751
752    #[tokio::test]
753    async fn start_rejects_invalid_policy_before_connecting() {
754        let cfg = KafkaMetadataLogConfig {
755            topic_create_timeout: Time::ZERO,
756            ..KafkaMetadataLogConfig::new("not a socket address")
757        };
758
759        let Err(error) = KafkaMetadataEventLog::start(cfg).await else {
760            panic!("invalid policy must fail before network I/O");
761        };
762        assert!(error.to_string().contains("topic_create_timeout"));
763    }
764
765    #[test]
766    fn keyed_tombstone_record_is_partitioned_and_preserves_the_null_value() {
767        let record = producer_record(
768            "__diskless_wal_index",
769            3,
770            2,
771            Some(Bytes::from_static(b"range")),
772            None,
773        )
774        .unwrap();
775
776        assert!(record.topic == "__diskless_wal_index");
777        assert!(record.partition == Some(2));
778        assert!(record.key.as_deref() == Some(b"range".as_slice()));
779        assert!(record.value.is_none());
780    }
781
782    #[yare::parameterized(
783        negative = { -1 },
784        upper_bound = { 3 },
785    )]
786    fn keyed_record_rejects_out_of_range_partitions(partition: i32) {
787        let error = producer_record("index", 3, partition, None, None).unwrap_err();
788        assert2::assert!(matches!(
789            error,
790            MetadataLogError::PartitionOutOfRange {
791                partition: got,
792                count: 3
793            } if got == partition
794        ));
795    }
796}