Skip to main content

krabka_client_coordination/
broker.rs

1//! The [`CoordinationTransport`] that talks to a Kafka cluster.
2//!
3//! [`BrokerTransport`] composes `krabka-client-producer`,
4//! `krabka-client-admin`, and `krabka-client-core`. It adds no wire message.
5//! The points below are the parts of the composition that a reader cannot see
6//! from the call sites, and each one is load-bearing.
7//!
8//! # One role uses two producers
9//!
10//! The transactional producer carries `transactional.id = <role>`, and it
11//! writes the lease records of that role. A second, plain producer appends the
12//! registration records of every role. Kafka requires every send of a
13//! transactional producer to sit inside a transaction, and a candidate holds no
14//! epoch to open a transaction with, so a registration cannot travel on the
15//! transactional producer.
16//!
17//! # The transactional producer is the epoch
18//!
19//! [`BrokerTransport::acquire_epoch`] builds the transactional producer and
20//! calls `init_transactions`. That call sends `InitProducerId` to the
21//! transaction coordinator, and the coordinator mints the producer epoch of the
22//! role. The transport keeps the producer, because the same instance stays
23//! bound to the epoch that the coordinator minted for it, and every write it
24//! makes carries that epoch.
25//! [`BrokerTransport::bound_producer`] hands the producer to the caller, so a
26//! leadership handle gives out a writer that the broker already fences.
27//!
28//! # A fence is the loss of the role
29//!
30//! [`BrokerTransport::write_lease`] maps `FencedProducer`, and the broker codes
31//! 47 `INVALID_PRODUCER_EPOCH` and 90 `PRODUCER_FENCED`, onto
32//! [`CoordinationError::Fenced`]. That mapping is the whole mechanism by which
33//! a deposed leader learns that it lost the role. No clock takes part in it.
34//!
35//! [`BrokerTransport::register`] does not use the mapping. Its producer holds
36//! no epoch for the role, so a fence on the plain producer says something about
37//! the idempotent identity of that producer and says nothing about the role.
38//!
39//! # All records of one role go to one partition
40//!
41//! The succession rules rank candidates on the offset of their registration, so
42//! the records of one role need a total order, and one partition gives that
43//! order. Both producers pin the partition with
44//! [`ProducerRecord::partition`](krabka_client_producer::ProducerRecord), and
45//! they compute it from the role name.
46//!
47//! Pinning is a correctness requirement and not a preference. Kafka's default
48//! partitioner hashes the record key. A registration key and a lease key of the
49//! same role differ, because the registration key names a member and the lease
50//! key does not. A partitioner that reads the key puts the two kinds in two
51//! partitions, and the total order is gone.
52//!
53//! `krabka-streams-java` and `krabka-streams-go` write the same topic, so they
54//! need the same partition for the same role. [`role_partition`] is the rule
55//! the three implementations share. It calls
56//! `krabka_client_producer::partition_for_key`, which is Kafka's own rule:
57//! `murmur2` of the role name in UTF-8, masked with `Utils.toPositive`, then
58//! the remainder of the partition count.
59//!
60//! # The read is committed
61//!
62//! [`BrokerTransport::read_role_records`] fetches at isolation level 1, so an
63//! aborted lease write is invisible. It walks from the first offset of the
64//! partition to the last stable offset, and it drops every record whose key
65//! belongs to another role.
66
67use std::{
68    collections::HashMap,
69    io,
70    net::SocketAddr,
71    sync::Arc,
72    time::{SystemTime, UNIX_EPOCH},
73};
74
75use async_trait::async_trait;
76use krabka_client_admin::{
77    AdminClient, AdminClientLike as _, AdminError, CreateTopicOutcome, CreateTopicSpec,
78    TopicMutationOptions, TransactionDescription,
79};
80use krabka_client_core::{
81    BrokerInfo, BrokerPool, ClientDnsTimeout, ClientError, ClientSecurity, Connection,
82    ConnectionOptions, DEFAULT_FETCH_RESPONSE_MAX, FetchMinBytes, FetchedRecord, IsolatedFetch,
83    connection_target_host, fetch_partition_with_isolation_progress, transport::resolve,
84};
85use krabka_client_producer::{
86    Acks, Producer, ProducerError, ProducerRecord, RecordMetadata, partition_for_key,
87};
88use krabka_protocol::{
89    owned::{
90        list_offsets_request::{ListOffsetsPartition, ListOffsetsRequest, ListOffsetsTopic},
91        list_offsets_response::ListOffsetsResponse,
92        metadata_request::{MetadataRequest, MetadataRequestTopic},
93        metadata_response,
94        metadata_response::MetadataResponse,
95    },
96    primitives::uuid::Uuid as WireUuid,
97};
98use krabka_units::{ByteSize, Time, convert::TimeExt as _, mebibytes, millis, secs};
99use tokio::{
100    sync::{Mutex, OnceCell},
101    time::{error::Elapsed, timeout},
102};
103
104use crate::{
105    error::CoordinationError,
106    lease::DEFAULT_LEASE_DURATION,
107    record::{
108        COORDINATION_STATE_TOPIC, CoordinationKey, CoordinationRecord, FencingToken, Lease,
109        MemberId, Registration, Role, decode_key, decode_value, encode_key, encode_value,
110    },
111    transport::{CoordinationTransport, RoleRecords},
112};
113
114/// The number of partitions that the transport gives the coordination topic
115/// when it creates it.
116pub const DEFAULT_COORDINATION_PARTITIONS: i32 = 16;
117
118/// The replication factor that the transport gives the coordination topic when
119/// it creates it.
120///
121/// The design of the coordination client asks an operator for
122/// `min.insync.replicas >= 2`, and three replicas leave room for one broker to
123/// fail. A single-broker cluster rejects this factor, so a development cluster
124/// needs a lower one on the builder.
125pub const DEFAULT_COORDINATION_REPLICATION: i32 = 3;
126
127/// The deadline that the transport puts on one request.
128pub const DEFAULT_COORDINATION_REQUEST_TIMEOUT: Time = secs(30);
129
130/// The time that one fetch of the coordination topic waits for records.
131pub const DEFAULT_COORDINATION_FETCH_MAX_WAIT: Time = millis(500);
132
133/// The byte limit that the transport puts on one partition fetch.
134pub const DEFAULT_COORDINATION_FETCH_PARTITION_MAX: ByteSize = mebibytes(1);
135
136/// The client id that the transport gives every connection it opens.
137pub const DEFAULT_COORDINATION_CLIENT_ID: &str = "krabka-coordination";
138
139/// Kafka's `UNKNOWN_TOPIC_OR_PARTITION`.
140const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
141
142/// Kafka's `LEADER_NOT_AVAILABLE`.
143const LEADER_NOT_AVAILABLE: i16 = 5;
144
145/// Kafka's `TOPIC_ALREADY_EXISTS`. A second member that starts against the
146/// same cluster gets this code, and it is the expected answer.
147const TOPIC_ALREADY_EXISTS: i16 = 36;
148
149/// Kafka's `INVALID_PRODUCER_EPOCH`. The broker answers this when a write
150/// carries an epoch that a later holder superseded.
151const INVALID_PRODUCER_EPOCH: i16 = 47;
152
153/// Kafka's `PRODUCER_FENCED`. The transaction coordinator answers this when a
154/// later holder superseded the producer of an open transaction.
155const PRODUCER_FENCED: i16 = 90;
156
157/// Kafka's `TRANSACTIONAL_ID_NOT_FOUND`. The coordinator answers this for a
158/// role that no member has ever held.
159const TRANSACTIONAL_ID_NOT_FOUND: i16 = 105;
160
161/// The `isolation_level` of a committed read.
162const READ_COMMITTED: i8 = 1;
163
164/// The `ListOffsets` timestamp that asks for the first offset of a partition.
165const LIST_OFFSETS_EARLIEST: i64 = -2;
166
167/// The `ListOffsets` timestamp that asks for the offset past the last record.
168/// Under [`READ_COMMITTED`] the broker answers with the last stable offset.
169const LIST_OFFSETS_LATEST: i64 = -1;
170
171/// The partition count and the topic id of the coordination topic.
172///
173/// The transport reads this once and keeps it. An operator that adds
174/// partitions to the topic afterwards moves the partition of every role, so the
175/// transport does not follow such a change. Restart the members after such a
176/// change.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178struct TopicLayout {
179    /// The number of partitions the topic has.
180    partitions: i32,
181    /// The topic id that a `Fetch` of version 13 and later carries.
182    topic_id: WireUuid,
183}
184
185/// The transactional producer of one role and the epoch it holds.
186struct Holder {
187    /// The producer that `init_transactions` bound to `token`.
188    producer: Arc<Producer>,
189    /// The epoch the transaction coordinator minted for the role.
190    token: FencingToken,
191}
192
193/// The [`CoordinationTransport`] that talks to a Kafka cluster.
194///
195/// Build one with [`BrokerTransport::builder`]. One transport serves every
196/// role of one process. It opens the connections it needs on first use, and
197/// [`BrokerTransport::close`] shuts them down.
198///
199/// The module documentation states the parts of the design that the call sites
200/// do not show.
201pub struct BrokerTransport {
202    /// The bootstrap address list, as the caller wrote it.
203    bootstrap: String,
204    /// The client id of every connection and every producer.
205    client_id: String,
206    /// The TLS and SASL policy of every connection and every producer.
207    security: Option<ClientSecurity>,
208    /// The lease duration, which becomes the transaction timeout of a role.
209    lease_duration: Time,
210    /// The deadline of one request.
211    request_timeout: Time,
212    /// The time one fetch waits for records.
213    fetch_max_wait: Time,
214    /// The byte limit of one partition fetch.
215    fetch_partition_max: ByteSize,
216    /// The smallest response one fetch accepts.
217    fetch_min: FetchMinBytes,
218    /// The partition count the transport asks for when it creates the topic.
219    topic_partitions: i32,
220    /// The replication factor the transport asks for when it creates the topic.
221    topic_replication: i32,
222    /// The admin client. `create_topics` needs unique access, so a mutex owns
223    /// it.
224    admin: Mutex<AdminClient>,
225    /// The connections to the brokers. `read_role_records` fetches from the
226    /// leader of the partition of the role, so it needs a connection per
227    /// broker id.
228    pool: BrokerPool,
229    /// The layout of the coordination topic, read on first use.
230    layout: OnceCell<TopicLayout>,
231    /// The plain producer that appends registrations, built on first use.
232    registrar: OnceCell<Producer>,
233    /// The transactional producer of every role this process holds an epoch
234    /// for.
235    holders: Mutex<HashMap<Role, Holder>>,
236}
237
238#[bon::bon]
239impl BrokerTransport {
240    /// Builds a transport against the cluster at `bootstrap`.
241    ///
242    /// `bootstrap` takes one `host:port`, or several separated by commas.
243    /// `lease_duration` becomes the transaction timeout of every role, so the
244    /// transaction coordinator aborts the open transaction of a dead holder
245    /// after that extent.
246    ///
247    /// The call opens the admin connection. It does not create the topic and
248    /// it does not build a producer. The first call that needs the topic
249    /// creates it.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`CoordinationError::InvalidConfig`] when `bootstrap` holds no
254    /// address, when an address does not resolve, or when the partition count
255    /// or the replication factor is not positive. Returns
256    /// [`CoordinationError::Admin`] when no bootstrap address accepts the
257    /// admin connection.
258    #[builder(start_fn = builder, finish_fn = build)]
259    #[tracing::instrument(
260        level = "info",
261        skip_all,
262        fields(bootstrap = %bootstrap, client_id = %client_id),
263        err(level = "info"),
264    )]
265    pub async fn start(
266        #[builder(into)] bootstrap: String,
267        #[builder(into, default = DEFAULT_COORDINATION_CLIENT_ID.to_owned())] client_id: String,
268        #[builder(default = DEFAULT_LEASE_DURATION)] lease_duration: Time,
269        #[builder(default = DEFAULT_COORDINATION_REQUEST_TIMEOUT)] request_timeout: Time,
270        #[builder(default = DEFAULT_COORDINATION_FETCH_MAX_WAIT)] fetch_max_wait: Time,
271        #[builder(default = DEFAULT_COORDINATION_FETCH_PARTITION_MAX)]
272        fetch_partition_max: ByteSize,
273        #[builder(default = DEFAULT_COORDINATION_PARTITIONS)] topic_partitions: i32,
274        #[builder(default = DEFAULT_COORDINATION_REPLICATION)] topic_replication: i32,
275        security: Option<ClientSecurity>,
276    ) -> Result<Self, CoordinationError> {
277        check_topic_shape(topic_partitions, topic_replication)?;
278        let addresses = bootstrap_addresses(&bootstrap);
279        if addresses.is_empty() {
280            return Err(CoordinationError::InvalidConfig(format!(
281                "the bootstrap list {bootstrap:?} holds no address"
282            )));
283        }
284        let fetch_min = FetchMinBytes::new(1).map_err(CoordinationError::InvalidConfig)?;
285        let options = ConnectionOptions {
286            client_id: client_id.clone(),
287            request_timeout,
288            security: security.clone().map(Box::new),
289            ..ConnectionOptions::default()
290        };
291        let resolved = resolve_addresses(&addresses, options.dns_timeout).await?;
292        let admin = AdminClient::connect_secured(&addresses, security.clone()).await?;
293        Ok(Self {
294            bootstrap,
295            client_id,
296            security,
297            lease_duration,
298            request_timeout,
299            fetch_max_wait,
300            fetch_partition_max,
301            fetch_min,
302            topic_partitions,
303            topic_replication,
304            admin: Mutex::new(admin),
305            pool: BrokerPool::new_with_server_names(resolved, options),
306            layout: OnceCell::new(),
307            registrar: OnceCell::new(),
308            holders: Mutex::new(HashMap::new()),
309        })
310    }
311}
312
313impl BrokerTransport {
314    /// The producer that [`CoordinationTransport::acquire_epoch`] bound to the
315    /// epoch of `role`.
316    ///
317    /// The result is `None` when this process holds no epoch for `role`. A
318    /// leadership handle gives the producer to the caller, and the broker
319    /// fences every write the caller makes with it.
320    pub async fn bound_producer(&self, role: &Role) -> Option<Arc<Producer>> {
321        let holders = self.holders.lock().await;
322        holders.get(role).map(|holder| Arc::clone(&holder.producer))
323    }
324
325    /// The epoch that [`CoordinationTransport::acquire_epoch`] minted for
326    /// `role` in this process.
327    ///
328    /// The result is `None` when this process holds no epoch for `role`. The
329    /// value is what this process believes. The cluster is the authority, and
330    /// [`CoordinationTransport::describe`] asks it.
331    pub async fn held_token(&self, role: &Role) -> Option<FencingToken> {
332        let holders = self.holders.lock().await;
333        holders.get(role).map(|holder| holder.token)
334    }
335
336    /// Closes every producer and every connection this transport opened.
337    ///
338    /// Each producer runs a sender task, and this call stops those tasks. Drop
339    /// every leadership handle before this call. A producer that a handle still
340    /// shares stays open, and this call reports it in a warning.
341    pub async fn close(self) {
342        for (role, holder) in self.holders.into_inner() {
343            close_producer(&role, holder.producer).await;
344        }
345        let closed = match self.registrar.into_inner() {
346            Some(registrar) => registrar.close().await,
347            None => Ok(()),
348        };
349        if let Err(error) = closed {
350            tracing::warn!(%error, "the registration producer did not close cleanly");
351        }
352        self.pool.close_all();
353    }
354
355    /// The partition that holds every record of `role`.
356    ///
357    /// This reads the layout of the topic on first use, and it creates the
358    /// topic when the cluster does not have it.
359    async fn partition_of(&self, role: &Role) -> Result<i32, CoordinationError> {
360        let layout = self.topic_layout().await?;
361        role_partition(role, layout.partitions)
362    }
363
364    /// The layout of the coordination topic, read once and kept.
365    async fn topic_layout(&self) -> Result<TopicLayout, CoordinationError> {
366        self.layout
367            .get_or_try_init(|| Box::pin(self.resolve_topic()))
368            .await
369            .copied()
370    }
371
372    /// Creates the topic when it is absent, then reads its layout.
373    async fn resolve_topic(&self) -> Result<TopicLayout, CoordinationError> {
374        self.create_topic().await?;
375        let metadata = self.read_metadata().await?;
376        topic_layout(&metadata)
377    }
378
379    /// Creates the coordination topic with `cleanup.policy=compact`, and takes
380    /// `TOPIC_ALREADY_EXISTS` for success.
381    #[tracing::instrument(level = "info", skip_all, err(level = "info"))]
382    async fn create_topic(&self) -> Result<(), CoordinationError> {
383        let spec = coordination_topic_spec(self.topic_partitions, self.topic_replication);
384        let outcomes = self
385            .admin
386            .lock()
387            .await
388            .create_topics(
389                &[spec],
390                TopicMutationOptions::with_timeout(self.request_timeout),
391            )
392            .await?;
393        accept_topic_creation(&outcomes)
394    }
395
396    /// Reads the metadata of the coordination topic from the bootstrap broker.
397    async fn read_metadata(&self) -> Result<MetadataResponse, CoordinationError> {
398        let connection = self.pool.bootstrap_connection().await?;
399        let metadata = connection.send(coordination_metadata_request()).await?;
400        Ok(metadata)
401    }
402
403    /// Opens a connection to the broker that leads `partition`.
404    async fn leader_connection(
405        &self,
406        partition: i32,
407    ) -> Result<Arc<Connection>, CoordinationError> {
408        let metadata = self.read_metadata().await?;
409        self.pool.refresh_brokers(&brokers_of(&metadata)).await;
410        let leader = partition_leader(&metadata, partition)?;
411        Ok(self.pool.get(leader).await?)
412    }
413
414    /// Asks the leader for one boundary offset of `partition`.
415    async fn list_offset(
416        &self,
417        connection: &Connection,
418        partition: i32,
419        timestamp: i64,
420    ) -> Result<i64, CoordinationError> {
421        let response = connection
422            .send(list_offsets_request(partition, timestamp))
423            .await?;
424        list_offsets_offset(&response, partition)
425    }
426
427    /// Mints the epoch of `role` with its transactional `producer`, and makes
428    /// the producer the holder of `role`.
429    ///
430    /// # Errors
431    ///
432    /// As [`CoordinationTransport::acquire_epoch`].
433    async fn hold_role(
434        &self,
435        role: &Role,
436        producer: Producer,
437    ) -> Result<FencingToken, CoordinationError> {
438        producer
439            .init_transactions()
440            .await
441            .map_err(|error| fenced_or(role, error))?;
442        // Read the minted pair back from the producer itself. A read through
443        // DescribeTransactions would report whatever the coordinator holds at
444        // that moment, so a competitor that mints between the two requests
445        // would give this member the token of another member.
446        let (producer_id, producer_epoch) =
447            producer
448                .transactional_identity()
449                .await
450                .ok_or(CoordinationError::Producer(
451                    ProducerError::InvalidTransactionState(
452                        "init_transactions returned without an identity",
453                    ),
454                ))?;
455        let token = FencingToken::new(producer_id, producer_epoch)?;
456        self.install_holder(
457            role,
458            Holder {
459                producer: Arc::new(producer),
460                token,
461            },
462        )
463        .await;
464        Ok(token)
465    }
466
467    /// Makes `holder` the holder of `role`, and closes the producer of the
468    /// holder it replaces.
469    async fn install_holder(&self, role: &Role, holder: Holder) {
470        let replaced = self.holders.lock().await.insert(role.clone(), holder);
471        if let Some(replaced) = replaced {
472            close_producer(role, replaced.producer).await;
473        }
474    }
475
476    /// The plain producer that appends registrations, built on first use.
477    async fn registrar(&self) -> Result<&Producer, CoordinationError> {
478        self.registrar
479            .get_or_try_init(|| Box::pin(self.build_registrar()))
480            .await
481    }
482
483    /// Builds the plain producer. It holds no `transactional.id`, so it appends
484    /// outside a transaction.
485    async fn build_registrar(&self) -> Result<Producer, CoordinationError> {
486        let producer = Producer::builder()
487            .bootstrap(self.bootstrap.clone())
488            .client_id(self.client_id.clone())
489            .acks(Acks::All)
490            .request_timeout(self.request_timeout.to_std())
491            .maybe_security(self.security.clone())
492            .build()
493            .await?;
494        Ok(producer)
495    }
496
497    /// Builds the transactional producer of `role`. Its `transactional.id` is
498    /// the role name, and its transaction timeout is the lease duration.
499    async fn build_role_producer(&self, role: &Role) -> Result<Producer, CoordinationError> {
500        let producer = Producer::builder()
501            .bootstrap(self.bootstrap.clone())
502            .client_id(self.client_id.clone())
503            .acks(Acks::All)
504            .request_timeout(self.request_timeout.to_std())
505            .transactional_id(role.as_str().to_owned())
506            .transaction_timeout(self.lease_duration.to_std())
507            .maybe_security(self.security.clone())
508            .build()
509            .await
510            .map_err(|error| fenced_or(role, error))?;
511        Ok(producer)
512    }
513
514    /// Reads the transactional producer and the epoch this process holds for
515    /// `role`.
516    async fn holder_of(&self, role: &Role) -> Option<(Arc<Producer>, FencingToken)> {
517        let holders = self.holders.lock().await;
518        holders
519            .get(role)
520            .map(|holder| (Arc::clone(&holder.producer), holder.token))
521    }
522}
523
524#[async_trait]
525impl CoordinationTransport for BrokerTransport {
526    /// Mints the epoch of `role` and fences the member that held it.
527    ///
528    /// The call builds the transactional producer of the role and calls
529    /// `init_transactions`, which sends `InitProducerId` to the transaction
530    /// coordinator. The transport keeps that producer, because it stays bound
531    /// to the epoch the coordinator minted.
532    ///
533    /// The call then reads the minted pair out of that producer with
534    /// `Producer::transactional_identity`. It does not ask the coordinator
535    /// again. A second request would report whatever the coordinator holds at
536    /// that moment, so a competitor that mints in between would hand this
537    /// member the token of the competitor. `Producer::producer_id` is a
538    /// different pair again: it reports the build-time `InitProducerId`, which
539    /// carries no `transactional.id`, and the coordinator never advances it.
540    ///
541    /// # Errors
542    ///
543    /// Returns [`CoordinationError::Fenced`] when a later member already took
544    /// `role` while this call ran. Returns [`CoordinationError::Producer`] when
545    /// the coordinator refuses the call or returns no identity, and
546    /// [`CoordinationError::Client`] when a connection fails.
547    #[tracing::instrument(level = "info", skip_all, fields(role = %role), err(level = "info"))]
548    async fn acquire_epoch(&self, role: &Role) -> Result<FencingToken, CoordinationError> {
549        self.topic_layout().await?;
550        let producer = self.build_role_producer(role).await?;
551        self.hold_role(role, producer).await
552    }
553
554    /// Reads the partition of `role` and returns its records in offset order.
555    ///
556    /// The scan runs at isolation level 1, so an aborted lease write is
557    /// invisible. It starts at the first offset the partition still holds, and
558    /// it stops when the cursor reaches the last stable offset that the cluster
559    /// reported at the start of the scan. The scan does not wait for a record
560    /// that a writer appends while it runs.
561    ///
562    /// The scan drops a record whose key does not decode, and a record whose
563    /// key names another role. Both belong to a writer that this role does not
564    /// own.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`CoordinationError::Client`] when the metadata read, the offset
569    /// lookup, or a fetch fails, and when the cluster reports no leader for the
570    /// partition of `role`. Returns [`CoordinationError::Record`] when the
571    /// value of a record of `role` does not decode. Returns
572    /// [`CoordinationError::InvalidConfig`] when the topic reports no
573    /// partition.
574    #[tracing::instrument(level = "debug", skip_all, fields(role = %role), err(level = "debug"))]
575    async fn read_role_records(&self, role: &Role) -> Result<RoleRecords, CoordinationError> {
576        let layout = self.topic_layout().await?;
577        let partition = role_partition(role, layout.partitions)?;
578        let connection = self.leader_connection(partition).await?;
579        let start = self
580            .list_offset(&connection, partition, LIST_OFFSETS_EARLIEST)
581            .await?;
582        let end = self
583            .list_offset(&connection, partition, LIST_OFFSETS_LATEST)
584            .await?;
585
586        let mut offset = start;
587        let mut records = RoleRecords::new();
588        while offset < end {
589            let page = fetch_partition_with_isolation_progress(
590                &connection,
591                IsolatedFetch {
592                    topic: COORDINATION_STATE_TOPIC,
593                    topic_id: layout.topic_id,
594                    partition,
595                    fetch_offset: offset,
596                    max_wait: self.fetch_max_wait,
597                    max: DEFAULT_FETCH_RESPONSE_MAX,
598                    partition_max: self.fetch_partition_max,
599                    fetch_min: self.fetch_min,
600                    isolation_level: READ_COMMITTED,
601                },
602            )
603            .await?;
604            records.append(&mut select_role_records(role, &page.records)?);
605            match next_scan_offset(offset, end, page.next_offset) {
606                Some(next) => offset = next,
607                None => break,
608            }
609        }
610        Ok(records)
611    }
612
613    /// Appends the registration of `member` to the partition of `role`.
614    ///
615    /// The append travels on the plain producer and sits outside a
616    /// transaction, because a candidate holds no epoch. The offset the broker
617    /// gives it is the join sequence of the member.
618    ///
619    /// # Errors
620    ///
621    /// Returns [`CoordinationError::Producer`] when the append fails, and
622    /// [`CoordinationError::Client`] when a connection fails. This method never
623    /// reports [`CoordinationError::Fenced`], because its producer holds no
624    /// epoch for the role.
625    #[tracing::instrument(level = "info", skip_all, fields(role = %role, member = %member), err(level = "info"))]
626    async fn register(&self, role: &Role, member: &MemberId) -> Result<(), CoordinationError> {
627        let partition = self.partition_of(role).await?;
628        send_one(
629            self.registrar().await?,
630            registration_record(role, member, partition, current_millis()),
631        )
632        .await?;
633        Ok(())
634    }
635
636    /// Writes the lease of `role` in a transaction under `token`.
637    ///
638    /// The write travels on the producer that
639    /// [`CoordinationTransport::acquire_epoch`] bound to `token`. The broker
640    /// checks the epoch of that producer, so a superseded holder cannot append
641    /// a lease. That check is how a deposed holder learns that it lost the
642    /// role.
643    ///
644    /// A failed send aborts the transaction, so the partial write stays
645    /// invisible to a committed read.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`CoordinationError::NotHeld`] when this process holds no epoch
650    /// for `role`, and when `token` is not the epoch it holds. Returns
651    /// [`CoordinationError::Fenced`] when the broker rejects the epoch, and
652    /// [`CoordinationError::Producer`] for every other failure of the write.
653    #[tracing::instrument(
654        level = "info",
655        skip_all,
656        fields(role = %role, token = %token, member = %lease.member),
657        err(level = "info"),
658    )]
659    async fn write_lease(
660        &self,
661        role: &Role,
662        token: FencingToken,
663        lease: &Lease,
664    ) -> Result<(), CoordinationError> {
665        let partition = self.partition_of(role).await?;
666        let bound = self
667            .holder_of(role)
668            .await
669            .filter(|(_producer, held)| *held == token);
670        let Some((producer, _held)) = bound else {
671            return Err(CoordinationError::NotHeld {
672                role: role.clone(),
673                member: lease.member.clone(),
674            });
675        };
676
677        let transaction = producer
678            .begin_transaction()
679            .await
680            .map_err(|error| fenced_or(role, error))?;
681        if let Err(error) = send_one(&producer, lease_record(role, lease, partition)).await {
682            if let Err(failed) = transaction.abort().await {
683                tracing::warn!(
684                    role = %role,
685                    error = %failed.source,
686                    "the abort of a failed lease write did not complete",
687                );
688            }
689            return Err(fenced_or(role, error));
690        }
691        transaction
692            .commit()
693            .await
694            .map_err(|failed| fenced_or(role, failed.source))
695    }
696
697    /// Asks the transaction coordinator which epoch holds `role` now.
698    ///
699    /// The call sends `DescribeTransactions` (KIP-664). It joins no group and
700    /// it takes no epoch, so any process makes it. The result is `None` when
701    /// the coordinator answers `TRANSACTIONAL_ID_NOT_FOUND`, which says that no
702    /// member has ever held `role`.
703    ///
704    /// # Errors
705    ///
706    /// Returns [`CoordinationError::Admin`] when the coordinator lookup fails,
707    /// or when the coordinator reports a fault other than an unknown role.
708    #[tracing::instrument(level = "debug", skip_all, fields(role = %role), err(level = "debug"))]
709    async fn describe(&self, role: &Role) -> Result<Option<FencingToken>, CoordinationError> {
710        let admin = self.admin.lock().await;
711        match admin.describe_transaction(role.as_str()).await {
712            Ok(description) => Ok(token_from_description(&description)),
713            Err(error) if reports_unknown_transactional_id(&error) => Ok(None),
714            Err(error) => Err(error.into()),
715        }
716    }
717}
718
719/// Splits a bootstrap list on commas and drops the empty entries.
720fn bootstrap_addresses(bootstrap: &str) -> Vec<String> {
721    bootstrap
722        .split(',')
723        .map(str::trim)
724        .filter(|address| !address.is_empty())
725        .map(ToOwned::to_owned)
726        .collect()
727}
728
729/// Rejects a topic shape that Kafka does not accept.
730fn check_topic_shape(partitions: i32, replication: i32) -> Result<(), CoordinationError> {
731    if partitions <= 0 {
732        return Err(CoordinationError::InvalidConfig(format!(
733            "the coordination topic needs at least one partition, got {partitions}"
734        )));
735    }
736    if replication <= 0 {
737        return Err(CoordinationError::InvalidConfig(format!(
738            "the coordination topic needs at least one replica, got {replication}"
739        )));
740    }
741    Ok(())
742}
743
744/// Resolves every bootstrap address inside the DNS deadline.
745async fn resolve_addresses(
746    addresses: &[String],
747    dns_timeout: ClientDnsTimeout,
748) -> Result<Vec<(SocketAddr, String)>, CoordinationError> {
749    let mut resolved = Vec::new();
750    for address in addresses {
751        let lookup = timeout(dns_timeout.time().to_std(), resolve(address)).await;
752        resolved.extend(
753            lookup_addresses(address, lookup)?
754                .into_iter()
755                .map(|socket| (socket, connection_target_host(address).to_owned())),
756        );
757    }
758    if resolved.is_empty() {
759        return Err(CoordinationError::InvalidConfig(format!(
760            "no address of {addresses:?} resolved"
761        )));
762    }
763    Ok(resolved)
764}
765
766/// The addresses one bootstrap lookup found, or the configuration error it
767/// ended in: the DNS deadline passed, or the resolver refused the address.
768fn lookup_addresses(
769    address: &str,
770    lookup: Result<io::Result<Vec<SocketAddr>>, Elapsed>,
771) -> Result<Vec<SocketAddr>, CoordinationError> {
772    lookup
773        .map_err(|_elapsed| {
774            CoordinationError::InvalidConfig(format!("the DNS lookup of {address} timed out"))
775        })?
776        .map_err(|error| {
777            CoordinationError::InvalidConfig(format!("the DNS lookup of {address} failed: {error}"))
778        })
779}
780
781/// The partition that holds every record of `role`.
782///
783/// The rule is Kafka's own key partitioning, which
784/// `krabka_client_producer::partition_for_key` implements: `murmur2` of the
785/// role name in UTF-8, masked with `Utils.toPositive`, then the remainder of
786/// `partitions`. `krabka-streams-java` and `krabka-streams-go` write the same
787/// topic, so they implement the same rule. A change here needs the same change
788/// in the two ports.
789///
790/// Both producers of a role call this and pin the partition on the record. The
791/// key of a registration and the key of a lease differ, so a partitioner that
792/// hashes the key would split one role across two partitions and destroy the
793/// total order the succession rules rank on.
794///
795/// # Errors
796///
797/// Returns [`CoordinationError::InvalidConfig`] when `partitions` is not
798/// positive.
799pub fn role_partition(role: &Role, partitions: i32) -> Result<i32, CoordinationError> {
800    if partitions <= 0 {
801        return Err(CoordinationError::InvalidConfig(format!(
802            "topic {COORDINATION_STATE_TOPIC} reports {partitions} partitions"
803        )));
804    }
805    Ok(partition_for_key(role.as_str().as_bytes(), partitions))
806}
807
808/// The spec that creates the coordination topic.
809///
810/// The topic is compacted, so it keeps the last record of every role and every
811/// member and drops the rest.
812fn coordination_topic_spec(partitions: i32, replication: i32) -> CreateTopicSpec {
813    CreateTopicSpec::builder()
814        .name(COORDINATION_STATE_TOPIC)
815        .partitions(partitions)
816        .replicas(replication)
817        .configs(maplit::btreemap! {"cleanup.policy".to_owned() => "compact".to_owned()})
818        .build()
819}
820
821/// Takes `TOPIC_ALREADY_EXISTS` for success and reports every other code.
822///
823/// Every member of a cluster creates the topic on first use, so all but the
824/// first get code 36.
825///
826/// # Errors
827///
828/// Returns [`CoordinationError::Admin`] with the code the broker reported.
829fn accept_topic_creation(outcomes: &[CreateTopicOutcome]) -> Result<(), CoordinationError> {
830    for outcome in outcomes {
831        match &outcome.error {
832            Some(error) if error.code != TOPIC_ALREADY_EXISTS => {
833                return Err(CoordinationError::Admin(AdminError::Broker {
834                    api: "CreateTopics",
835                    code: error.code,
836                    name: error.name,
837                    message: error.message.clone(),
838                }));
839            }
840            _ => {}
841        }
842    }
843    Ok(())
844}
845
846/// The `Metadata` request that names the coordination topic.
847///
848/// It sets `allow_auto_topic_creation` to `false`, because
849/// [`accept_topic_creation`] already created the topic with the configuration
850/// the design needs.
851fn coordination_metadata_request() -> MetadataRequest {
852    MetadataRequest {
853        topics: Some(vec![MetadataRequestTopic {
854            name: Some(COORDINATION_STATE_TOPIC.to_owned()),
855            ..Default::default()
856        }]),
857        allow_auto_topic_creation: false,
858        ..Default::default()
859    }
860}
861
862/// Reads the partition count and the topic id out of a `Metadata` response.
863fn topic_layout(response: &MetadataResponse) -> Result<TopicLayout, CoordinationError> {
864    let topic = coordination_topic(response).ok_or_else(|| server(UNKNOWN_TOPIC_OR_PARTITION))?;
865    if topic.error_code != 0 {
866        return Err(server(topic.error_code));
867    }
868    let partitions = i32::try_from(topic.partitions.len()).map_err(|_error| {
869        CoordinationError::InvalidConfig(format!(
870            "topic {COORDINATION_STATE_TOPIC} reports {} partitions",
871            topic.partitions.len()
872        ))
873    })?;
874    if partitions == 0 {
875        return Err(server(UNKNOWN_TOPIC_OR_PARTITION));
876    }
877    Ok(TopicLayout {
878        partitions,
879        topic_id: topic.topic_id,
880    })
881}
882
883/// Reads the leader of `partition` out of a `Metadata` response.
884fn partition_leader(response: &MetadataResponse, partition: i32) -> Result<i32, CoordinationError> {
885    let topic = coordination_topic(response).ok_or_else(|| server(UNKNOWN_TOPIC_OR_PARTITION))?;
886    let entry = topic
887        .partitions
888        .iter()
889        .find(|entry| entry.partition_index == partition)
890        .ok_or_else(|| server(UNKNOWN_TOPIC_OR_PARTITION))?;
891    if entry.error_code != 0 {
892        return Err(server(entry.error_code));
893    }
894    if entry.leader_id < 0 {
895        return Err(server(LEADER_NOT_AVAILABLE));
896    }
897    Ok(entry.leader_id)
898}
899
900/// Picks the coordination topic out of a `Metadata` response.
901fn coordination_topic(
902    response: &MetadataResponse,
903) -> Option<&metadata_response::MetadataResponseTopic> {
904    response
905        .topics
906        .iter()
907        .find(|topic| topic.name.as_deref() == Some(COORDINATION_STATE_TOPIC))
908}
909
910/// The broker list of a `Metadata` response, in the shape the pool takes.
911fn brokers_of(response: &MetadataResponse) -> Vec<BrokerInfo> {
912    response
913        .brokers
914        .iter()
915        .map(|broker| BrokerInfo {
916            id: broker.node_id,
917            host: broker.host.clone(),
918            port: broker.port,
919            rack: broker.rack.clone(),
920        })
921        .collect()
922}
923
924/// The `ListOffsets` request for one boundary of one partition.
925fn list_offsets_request(partition: i32, timestamp: i64) -> ListOffsetsRequest {
926    ListOffsetsRequest {
927        replica_id: -1,
928        isolation_level: READ_COMMITTED,
929        topics: vec![ListOffsetsTopic {
930            name: COORDINATION_STATE_TOPIC.to_owned(),
931            partitions: vec![ListOffsetsPartition {
932                partition_index: partition,
933                current_leader_epoch: -1,
934                timestamp,
935                ..Default::default()
936            }],
937            ..Default::default()
938        }],
939        ..Default::default()
940    }
941}
942
943/// Reads the offset of `partition` out of a `ListOffsets` response.
944fn list_offsets_offset(
945    response: &ListOffsetsResponse,
946    partition: i32,
947) -> Result<i64, CoordinationError> {
948    let entry = response
949        .topics
950        .iter()
951        .filter(|topic| topic.name == COORDINATION_STATE_TOPIC)
952        .flat_map(|topic| topic.partitions.iter())
953        .find(|entry| entry.partition_index == partition)
954        .ok_or_else(|| server(UNKNOWN_TOPIC_OR_PARTITION))?;
955    if entry.error_code != 0 {
956        return Err(server(entry.error_code));
957    }
958    Ok(entry.offset)
959}
960
961/// The offset the next fetch of a scan starts at, or `None` when the scan is
962/// finished.
963///
964/// The scan stops when the broker reported no batch, when the cursor did not
965/// move, and when the cursor reached the end the scan started with. The first
966/// two guard the loop against a fetch that makes no progress.
967fn next_scan_offset(current: i64, end: i64, next_offset: Option<i64>) -> Option<i64> {
968    let next = next_offset?;
969    if next <= current || next >= end {
970        return None;
971    }
972    Some(next)
973}
974
975/// Decodes the records of `role` out of one fetch and drops the rest.
976///
977/// A key that does not decode belongs to a writer this crate does not know, and
978/// a key of another role belongs to a role that shares the partition. The
979/// function drops both. A value that does not decode under a key of `role` is a
980/// fault in the state of this role, and the function reports it.
981///
982/// # Errors
983///
984/// Returns [`CoordinationError::Record`] when the value of a record of `role`
985/// does not decode.
986fn select_role_records(
987    role: &Role,
988    records: &[FetchedRecord],
989) -> Result<RoleRecords, CoordinationError> {
990    let mut selected = RoleRecords::new();
991    for record in records {
992        let Some(bytes) = record.key.as_deref() else {
993            continue;
994        };
995        let Ok(key) = decode_key(bytes) else {
996            continue;
997        };
998        if key.role != *role {
999            continue;
1000        }
1001        let value = decode_value(key.kind, record.value.as_deref())?;
1002        selected.push((record.offset, key, value));
1003    }
1004    Ok(selected)
1005}
1006
1007/// The registration record of `member`, pinned to the partition of `role`.
1008fn registration_record(
1009    role: &Role,
1010    member: &MemberId,
1011    partition: i32,
1012    registered_at: i64,
1013) -> ProducerRecord {
1014    coordination_record(
1015        &CoordinationKey::registration(role.clone(), member.clone()),
1016        &CoordinationRecord::Registration(Registration {
1017            member: member.clone(),
1018            registered_at,
1019        }),
1020        partition,
1021    )
1022}
1023
1024/// The lease record of `role`, pinned to the partition of `role`.
1025fn lease_record(role: &Role, lease: &Lease, partition: i32) -> ProducerRecord {
1026    coordination_record(
1027        &CoordinationKey::lease(role.clone()),
1028        &CoordinationRecord::Lease(lease.clone()),
1029        partition,
1030    )
1031}
1032
1033/// Encodes one record of the coordination topic and pins its partition.
1034fn coordination_record(
1035    key: &CoordinationKey,
1036    value: &CoordinationRecord,
1037    partition: i32,
1038) -> ProducerRecord {
1039    ProducerRecord {
1040        topic: COORDINATION_STATE_TOPIC.to_owned(),
1041        partition: Some(partition),
1042        key: Some(encode_key(key)),
1043        value: encode_value(value),
1044        headers: Vec::new(),
1045        timestamp_ms: None,
1046    }
1047}
1048
1049/// Sends one record and waits for the acknowledgement of the broker.
1050async fn send_one(
1051    producer: &Producer,
1052    record: ProducerRecord,
1053) -> Result<RecordMetadata, ProducerError> {
1054    producer.send(record).await
1055}
1056
1057/// Reports whether the broker fenced the writer.
1058///
1059/// The producer reports a fence as `FencedProducer`, and it passes the raw
1060/// broker codes through for the paths it does not translate. Kafka answers 47
1061/// `INVALID_PRODUCER_EPOCH` on a produce and on `EndTxn`, and 90
1062/// `PRODUCER_FENCED` when a later holder superseded the producer.
1063fn is_fenced(error: &ProducerError) -> bool {
1064    match error {
1065        ProducerError::FencedProducer => true,
1066        ProducerError::Server(code) => {
1067            matches!(*code, INVALID_PRODUCER_EPOCH | PRODUCER_FENCED)
1068        }
1069        ProducerError::Client(ClientError::Server { error_code }) => {
1070            matches!(*error_code, INVALID_PRODUCER_EPOCH | PRODUCER_FENCED)
1071        }
1072        _ => false,
1073    }
1074}
1075
1076/// Maps a fence onto the loss of `role`, and passes every other fault through.
1077///
1078/// This is the mechanism by which a deposed leader learns that it lost the
1079/// role, so every guarded write goes through it.
1080fn fenced_or(role: &Role, error: ProducerError) -> CoordinationError {
1081    if is_fenced(&error) {
1082        return CoordinationError::Fenced { role: role.clone() };
1083    }
1084    CoordinationError::Producer(error)
1085}
1086
1087/// The token that a `DescribeTransactions` row reports.
1088///
1089/// The result is `None` when the coordinator reports the `-1` pair, which says
1090/// that it holds no producer for the transactional id.
1091fn token_from_description(description: &TransactionDescription) -> Option<FencingToken> {
1092    FencingToken::new(description.producer_id, description.producer_epoch).ok()
1093}
1094
1095/// Reports whether the coordinator answered that it never saw the role.
1096fn reports_unknown_transactional_id(error: &AdminError) -> bool {
1097    matches!(
1098        error,
1099        AdminError::Broker {
1100            code: TRANSACTIONAL_ID_NOT_FOUND,
1101            ..
1102        }
1103    )
1104}
1105
1106/// A broker error code, in the shape the coordination error carries it.
1107fn server(error_code: i16) -> CoordinationError {
1108    CoordinationError::Client(ClientError::Server { error_code })
1109}
1110
1111/// The current wall clock, in milliseconds since the Unix epoch.
1112///
1113/// A clock before the Unix epoch reports `0`. The lease deadline is an
1114/// anti-flap device, so a wrong instant moves the failover time and does not
1115/// affect safety.
1116fn current_millis() -> i64 {
1117    SystemTime::now()
1118        .duration_since(UNIX_EPOCH)
1119        .map_or(0, |since| {
1120            i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
1121        })
1122}
1123
1124/// Closes the producer of a role, and reports a producer a handle still shares.
1125async fn close_producer(role: &Role, producer: Arc<Producer>) {
1126    let Some(producer) = Arc::into_inner(producer) else {
1127        tracing::warn!(
1128            role = %role,
1129            "a leadership handle still shares the producer of this role, so it stays open",
1130        );
1131        return;
1132    };
1133    if let Err(error) = producer.close().await {
1134        tracing::warn!(role = %role, %error, "the producer of a role did not close cleanly");
1135    }
1136}
1137
1138#[cfg(test)]
1139mod mock_tests;
1140
1141#[cfg(test)]
1142mod tests {
1143    /// The `ListOffsets` timestamps are Kafka's own sentinels. A wrong sign
1144    /// asks the broker for a real timestamp instead of the first or the last
1145    /// offset, so the scan reads the wrong end of the partition.
1146    #[test]
1147    fn the_list_offsets_timestamps_are_the_sentinels_kafka_defines() {
1148        check!(LIST_OFFSETS_EARLIEST == -2);
1149        check!(LIST_OFFSETS_LATEST == -1);
1150        check!(READ_COMMITTED == 1);
1151    }
1152
1153    /// `partition_leader` reads a `Metadata` response. Broker id zero is a
1154    /// real broker, so the check for a missing leader compares against a
1155    /// negative id and not against a non-positive one.
1156    #[test]
1157    fn the_leader_of_a_partition_comes_from_the_metadata_response() {
1158        use metadata_response::{MetadataResponsePartition, MetadataResponseTopic};
1159
1160        let response = |partition: i32, leader_id: i32, error_code: i16| MetadataResponse {
1161            topics: vec![MetadataResponseTopic {
1162                name: Some(COORDINATION_STATE_TOPIC.to_owned()),
1163                partitions: vec![MetadataResponsePartition {
1164                    partition_index: partition,
1165                    leader_id,
1166                    error_code,
1167                    ..Default::default()
1168                }],
1169                ..Default::default()
1170            }],
1171            ..Default::default()
1172        };
1173
1174        // Broker zero is a leader, not a missing one.
1175        check!(partition_leader(&response(3, 0, 0), 3).unwrap() == 0);
1176        check!(partition_leader(&response(3, 7, 0), 3).unwrap() == 7);
1177
1178        let code = |result: Result<i32, CoordinationError>| {
1179            broker_code(&result.expect_err("the lookup fails"))
1180        };
1181
1182        // A negative leader id means the cluster has no leader yet.
1183        check!(code(partition_leader(&response(3, -1, 0), 3)) == Some(LEADER_NOT_AVAILABLE));
1184        // A partition error is reported as the broker sent it.
1185        check!(code(partition_leader(&response(3, 7, 9), 3)) == Some(9));
1186        // A partition the response does not name is unknown.
1187        check!(code(partition_leader(&response(3, 7, 0), 4)) == Some(UNKNOWN_TOPIC_OR_PARTITION));
1188        // A response without the coordination topic is unknown.
1189        check!(
1190            code(partition_leader(&MetadataResponse::default(), 0))
1191                == Some(UNKNOWN_TOPIC_OR_PARTITION)
1192        );
1193    }
1194
1195    #[tokio::test]
1196    async fn address_resolution_returns_every_address_and_rejects_an_empty_list() {
1197        let timeout = ClientDnsTimeout::default();
1198
1199        let resolved = resolve_addresses(&["127.0.0.1:9092".to_owned()], timeout)
1200            .await
1201            .expect("a literal address resolves");
1202        check!(resolved.len() == 1);
1203        check!(resolved[0].0.port() == 9092);
1204        check!(resolved[0].1 == "127.0.0.1");
1205
1206        let both = resolve_addresses(
1207            &["127.0.0.1:9092".to_owned(), "127.0.0.1:9093".to_owned()],
1208            timeout,
1209        )
1210        .await
1211        .expect("both literal addresses resolve");
1212        check!(both.len() == 2);
1213
1214        // An empty bootstrap list resolves to nothing, which is a config fault.
1215        let empty = resolve_addresses(&[], timeout).await;
1216        check!(matches!(empty, Err(CoordinationError::InvalidConfig(_))));
1217    }
1218
1219    #[tokio::test]
1220    async fn a_bootstrap_lookup_that_fails_or_times_out_is_a_config_error() {
1221        // The resolver refuses an address without a port.
1222        let failed = resolve_addresses(
1223            &["broker-without-port".to_owned()],
1224            ClientDnsTimeout::default(),
1225        )
1226        .await;
1227        assert!(let Err(CoordinationError::InvalidConfig(message)) = failed);
1228        check!(message == "the DNS lookup of broker-without-port failed: invalid socket address");
1229
1230        // A lookup still running at the deadline times out.
1231        let unanswered = timeout(
1232            Duration::ZERO,
1233            future::pending::<io::Result<Vec<SocketAddr>>>(),
1234        )
1235        .await;
1236        assert!(
1237            let Err(CoordinationError::InvalidConfig(message)) =
1238                lookup_addresses("broker:9092", unanswered)
1239        );
1240        check!(message == "the DNS lookup of broker:9092 timed out");
1241    }
1242
1243    use std::{collections::BTreeSet, future, time::Duration};
1244
1245    use assert2::{assert, check};
1246    use krabka_client_admin::KafkaError;
1247    use krabka_client_core::mock_test_support::recorded_levels;
1248    use krabka_protocol::owned::{
1249        list_offsets_response::{ListOffsetsPartitionResponse, ListOffsetsTopicResponse},
1250        metadata_response::{
1251            MetadataResponseBroker, MetadataResponsePartition, MetadataResponseTopic,
1252        },
1253    };
1254    use krabka_units::secs;
1255
1256    use super::*;
1257    use crate::record::RecordKind;
1258
1259    const GRANTED_AT: i64 = 1_700_000_000_000;
1260    const DEADLINE: i64 = 1_700_000_030_000;
1261
1262    fn role(name: &str) -> Role {
1263        Role::new(name).expect("a valid role")
1264    }
1265
1266    fn member(name: &str) -> MemberId {
1267        MemberId::new(name).expect("a valid member id")
1268    }
1269
1270    fn token(producer_id: i64, producer_epoch: i16) -> FencingToken {
1271        FencingToken::new(producer_id, producer_epoch).expect("a valid token")
1272    }
1273
1274    fn lease(member_name: &str) -> Lease {
1275        Lease {
1276            member: member(member_name),
1277            token: token(4242, 7),
1278            granted_at: GRANTED_AT,
1279            deadline: DEADLINE,
1280        }
1281    }
1282
1283    fn fetched(offset: i64, key: Option<&[u8]>, value: Option<&[u8]>) -> FetchedRecord {
1284        FetchedRecord {
1285            offset,
1286            key: key.map(bytes::Bytes::copy_from_slice),
1287            value: value.map(bytes::Bytes::copy_from_slice),
1288            timestamp: GRANTED_AT,
1289            headers: Vec::new(),
1290        }
1291    }
1292
1293    fn registration_bytes(role_name: &str, member_name: &str) -> (Vec<u8>, Vec<u8>) {
1294        let key = CoordinationKey::registration(role(role_name), member(member_name));
1295        let value = CoordinationRecord::Registration(Registration {
1296            member: member(member_name),
1297            registered_at: GRANTED_AT,
1298        });
1299        (
1300            encode_key(&key).to_vec(),
1301            encode_value(&value)
1302                .expect("a registration is not a tombstone")
1303                .to_vec(),
1304        )
1305    }
1306
1307    fn metadata_topic(partitions: Vec<MetadataResponsePartition>) -> MetadataResponseTopic {
1308        MetadataResponseTopic {
1309            name: Some(COORDINATION_STATE_TOPIC.to_owned()),
1310            topic_id: WireUuid([9; 16]),
1311            partitions,
1312            ..Default::default()
1313        }
1314    }
1315
1316    fn metadata_partition(index: i32, leader: i32) -> MetadataResponsePartition {
1317        MetadataResponsePartition {
1318            partition_index: index,
1319            leader_id: leader,
1320            ..Default::default()
1321        }
1322    }
1323
1324    fn metadata(topics: Vec<MetadataResponseTopic>) -> MetadataResponse {
1325        MetadataResponse {
1326            topics,
1327            ..Default::default()
1328        }
1329    }
1330
1331    fn offsets_response(partition: i32, error_code: i16, offset: i64) -> ListOffsetsResponse {
1332        ListOffsetsResponse {
1333            topics: vec![ListOffsetsTopicResponse {
1334                name: COORDINATION_STATE_TOPIC.to_owned(),
1335                partitions: vec![ListOffsetsPartitionResponse {
1336                    partition_index: partition,
1337                    error_code,
1338                    offset,
1339                    ..Default::default()
1340                }],
1341                ..Default::default()
1342            }],
1343            ..Default::default()
1344        }
1345    }
1346
1347    fn broker_code(error: &CoordinationError) -> Option<i16> {
1348        match error {
1349            CoordinationError::Client(ClientError::Server { error_code }) => Some(*error_code),
1350            CoordinationError::Admin(AdminError::Broker { code, .. }) => Some(*code),
1351            _ => None,
1352        }
1353    }
1354
1355    /// The partition rule is frozen, because three implementations write the
1356    /// same topic. A change to these values is a change to the wire contract.
1357    /// Each value is `toPositive(murmur2(role)) % partitions`, which is what
1358    /// Kafka's own key partitioning computes.
1359    #[test]
1360    fn a_role_maps_onto_a_frozen_partition() {
1361        let cases = [
1362            ("controller", 16, 12),
1363            ("dispatcher", 16, 10),
1364            ("role-a", 16, 10),
1365            ("role-b", 16, 12),
1366            ("controller", 1, 0),
1367        ];
1368        for (name, partitions, want) in cases {
1369            check!(
1370                role_partition(&role(name), partitions).unwrap() == want,
1371                "role {name} over {partitions} partitions"
1372            );
1373        }
1374    }
1375
1376    /// Both producers of a role call the same rule, so the registration and
1377    /// the lease of one role land in one partition even though their keys
1378    /// differ.
1379    #[test]
1380    fn both_record_kinds_of_a_role_take_the_same_partition() {
1381        let name = role("controller");
1382        let partition = role_partition(&name, 16).unwrap();
1383        let registration = registration_record(&name, &member("node-1"), partition, GRANTED_AT);
1384        let lease = lease_record(&name, &lease("node-1"), partition);
1385        check!(registration.partition == lease.partition);
1386        check!(registration.partition == Some(partition));
1387        check!(registration.key != lease.key);
1388    }
1389
1390    #[test]
1391    fn a_role_partition_stays_inside_the_partition_count() {
1392        for count in [1_i32, 2, 3, 7, 16, 50] {
1393            for name in ["a", "controller", "a-very-long-role-name-0123456789"] {
1394                let partition = role_partition(&role(name), count).unwrap();
1395                check!(partition >= 0, "role {name} over {count} partitions");
1396                check!(partition < count, "role {name} over {count} partitions");
1397            }
1398        }
1399    }
1400
1401    #[test]
1402    fn a_role_partition_rejects_a_topic_with_no_partition() {
1403        for count in [0_i32, -1, i32::MIN] {
1404            assert!(let Err(CoordinationError::InvalidConfig(_)) =
1405                role_partition(&role("controller"), count));
1406        }
1407    }
1408
1409    #[test]
1410    fn a_bootstrap_list_splits_on_commas_and_drops_the_empty_entries() {
1411        let cases: [(&str, Vec<String>); 5] = [
1412            ("localhost:9092", vec!["localhost:9092".to_owned()]),
1413            (
1414                "a:1, b:2 ,c:3",
1415                vec!["a:1".to_owned(), "b:2".to_owned(), "c:3".to_owned()],
1416            ),
1417            (",a:1,,", vec!["a:1".to_owned()]),
1418            ("", Vec::new()),
1419            ("  ,  ", Vec::new()),
1420        ];
1421        for (input, want) in cases {
1422            check!(bootstrap_addresses(input) == want, "bootstrap {input:?}");
1423        }
1424    }
1425
1426    #[test]
1427    fn a_topic_shape_needs_a_partition_and_a_replica() {
1428        check!(check_topic_shape(16, 3).is_ok());
1429        check!(check_topic_shape(1, 1).is_ok());
1430        assert!(let Err(CoordinationError::InvalidConfig(_)) = check_topic_shape(0, 3));
1431        assert!(let Err(CoordinationError::InvalidConfig(_)) = check_topic_shape(16, 0));
1432        assert!(let Err(CoordinationError::InvalidConfig(_)) = check_topic_shape(-1, -1));
1433    }
1434
1435    /// `CreateTopicSpec` carries no `PartialEq`, so this test compares the
1436    /// fields.
1437    #[test]
1438    fn the_topic_spec_asks_for_a_compacted_topic() {
1439        let spec = coordination_topic_spec(16, 3);
1440        check!(spec.name == COORDINATION_STATE_TOPIC);
1441        check!(spec.partitions == 16);
1442        check!(spec.replicas == 3);
1443        check!(
1444            spec.configs == maplit::btreemap! {"cleanup.policy".to_owned() => "compact".to_owned()}
1445        );
1446    }
1447
1448    /// Every member creates the topic on first use, so all but the first get
1449    /// `TOPIC_ALREADY_EXISTS`.
1450    #[test]
1451    fn topic_creation_takes_an_existing_topic_for_success() {
1452        let outcome = |code: Option<i16>| CreateTopicOutcome {
1453            name: COORDINATION_STATE_TOPIC.to_owned(),
1454            topic_id: None,
1455            error: code.map(|code| KafkaError {
1456                code,
1457                name: "TEST",
1458                message: None,
1459            }),
1460            throttle_time: None,
1461        };
1462        check!(accept_topic_creation(&[]).is_ok());
1463        check!(accept_topic_creation(&[outcome(None)]).is_ok());
1464        check!(accept_topic_creation(&[outcome(Some(TOPIC_ALREADY_EXISTS))]).is_ok());
1465
1466        let refused = accept_topic_creation(&[outcome(Some(38))])
1467            .expect_err("an invalid replication factor is a fault");
1468        check!(broker_code(&refused) == Some(38));
1469    }
1470
1471    #[test]
1472    fn the_metadata_request_names_the_topic_and_never_creates_it() {
1473        check!(
1474            coordination_metadata_request()
1475                == MetadataRequest {
1476                    topics: Some(vec![MetadataRequestTopic {
1477                        name: Some(COORDINATION_STATE_TOPIC.to_owned()),
1478                        ..Default::default()
1479                    }]),
1480                    allow_auto_topic_creation: false,
1481                    ..Default::default()
1482                }
1483        );
1484    }
1485
1486    #[test]
1487    fn the_topic_layout_reads_the_partition_count_and_the_topic_id() {
1488        let response = metadata(vec![metadata_topic(vec![
1489            metadata_partition(0, 1),
1490            metadata_partition(1, 2),
1491        ])]);
1492        check!(
1493            topic_layout(&response).unwrap()
1494                == TopicLayout {
1495                    partitions: 2,
1496                    topic_id: WireUuid([9; 16]),
1497                }
1498        );
1499    }
1500
1501    #[test]
1502    fn the_topic_layout_reports_a_topic_the_cluster_does_not_have() {
1503        let absent = metadata(Vec::new());
1504        check!(
1505            broker_code(&topic_layout(&absent).unwrap_err()) == Some(UNKNOWN_TOPIC_OR_PARTITION)
1506        );
1507
1508        let empty = metadata(vec![metadata_topic(Vec::new())]);
1509        check!(broker_code(&topic_layout(&empty).unwrap_err()) == Some(UNKNOWN_TOPIC_OR_PARTITION));
1510
1511        let refused = metadata(vec![MetadataResponseTopic {
1512            error_code: 29,
1513            ..metadata_topic(vec![metadata_partition(0, 1)])
1514        }]);
1515        check!(broker_code(&topic_layout(&refused).unwrap_err()) == Some(29));
1516    }
1517
1518    #[test]
1519    fn the_partition_leader_comes_from_the_metadata_row_of_that_partition() {
1520        let response = metadata(vec![metadata_topic(vec![
1521            metadata_partition(0, 11),
1522            metadata_partition(1, 22),
1523        ])]);
1524        check!(partition_leader(&response, 0).unwrap() == 11);
1525        check!(partition_leader(&response, 1).unwrap() == 22);
1526        check!(
1527            broker_code(&partition_leader(&response, 9).unwrap_err())
1528                == Some(UNKNOWN_TOPIC_OR_PARTITION)
1529        );
1530    }
1531
1532    #[test]
1533    fn a_partition_with_no_leader_is_a_broker_error() {
1534        let without_leader = metadata(vec![metadata_topic(vec![metadata_partition(0, -1)])]);
1535        check!(
1536            broker_code(&partition_leader(&without_leader, 0).unwrap_err())
1537                == Some(LEADER_NOT_AVAILABLE)
1538        );
1539
1540        let refused = metadata(vec![metadata_topic(vec![MetadataResponsePartition {
1541            error_code: 9,
1542            ..metadata_partition(0, 1)
1543        }])]);
1544        check!(broker_code(&partition_leader(&refused, 0).unwrap_err()) == Some(9));
1545    }
1546
1547    #[test]
1548    fn the_broker_list_carries_every_field_the_pool_needs() {
1549        let response = MetadataResponse {
1550            brokers: vec![MetadataResponseBroker {
1551                node_id: 3,
1552                host: "broker-3".to_owned(),
1553                port: 9092,
1554                rack: Some("rack-a".to_owned()),
1555                ..Default::default()
1556            }],
1557            ..Default::default()
1558        };
1559        let brokers = brokers_of(&response);
1560        check!(brokers.len() == 1);
1561        check!(brokers[0].id == 3);
1562        check!(brokers[0].host == "broker-3");
1563        check!(brokers[0].port == 9092);
1564        check!(brokers[0].rack.as_deref() == Some("rack-a"));
1565    }
1566
1567    #[test]
1568    fn the_offset_request_asks_one_partition_at_committed_isolation() {
1569        check!(
1570            list_offsets_request(4, LIST_OFFSETS_LATEST)
1571                == ListOffsetsRequest {
1572                    replica_id: -1,
1573                    isolation_level: READ_COMMITTED,
1574                    topics: vec![ListOffsetsTopic {
1575                        name: COORDINATION_STATE_TOPIC.to_owned(),
1576                        partitions: vec![ListOffsetsPartition {
1577                            partition_index: 4,
1578                            current_leader_epoch: -1,
1579                            timestamp: LIST_OFFSETS_LATEST,
1580                            ..Default::default()
1581                        }],
1582                        ..Default::default()
1583                    }],
1584                    ..Default::default()
1585                }
1586        );
1587    }
1588
1589    #[test]
1590    fn the_offset_response_gives_the_offset_of_the_asked_partition() {
1591        check!(list_offsets_offset(&offsets_response(4, 0, 91), 4).unwrap() == 91);
1592        check!(
1593            broker_code(&list_offsets_offset(&offsets_response(4, 0, 91), 5).unwrap_err())
1594                == Some(UNKNOWN_TOPIC_OR_PARTITION)
1595        );
1596        check!(
1597            broker_code(&list_offsets_offset(&offsets_response(4, 6, -1), 4).unwrap_err())
1598                == Some(6)
1599        );
1600    }
1601
1602    /// The scan reads until the cursor reaches the end that the cluster
1603    /// reported. It also stops on a fetch that makes no progress, so a broker
1604    /// that returns nothing cannot spin the loop.
1605    #[test]
1606    fn the_scan_advances_until_it_reaches_the_end_it_started_with() {
1607        let cases = [
1608            ("a batch inside the range", 0, 10, Some(4), Some(4)),
1609            ("a batch that reaches the end", 0, 10, Some(10), None),
1610            ("a batch past the end", 0, 10, Some(11), None),
1611            ("no batch in the response", 0, 10, None, None),
1612            ("a cursor that did not move", 4, 10, Some(4), None),
1613            ("a cursor that went backwards", 4, 10, Some(2), None),
1614        ];
1615        for (name, current, end, next_offset, want) in cases {
1616            check!(
1617                next_scan_offset(current, end, next_offset) == want,
1618                "{name}"
1619            );
1620        }
1621    }
1622
1623    #[test]
1624    fn the_scan_keeps_the_records_of_the_role_in_offset_order() {
1625        let (mine_key, mine_value) = registration_bytes("controller", "node-1");
1626        let (other_key, other_value) = registration_bytes("dispatcher", "node-9");
1627        let records = [
1628            fetched(4, Some(&other_key), Some(&other_value)),
1629            fetched(5, Some(&mine_key), Some(&mine_value)),
1630            fetched(6, Some(b"not a coordination key"), Some(&mine_value)),
1631            fetched(7, None, Some(&mine_value)),
1632            fetched(8, Some(&mine_key), None),
1633        ];
1634
1635        let selected = select_role_records(&role("controller"), &records).unwrap();
1636        check!(
1637            selected
1638                == vec![
1639                    (
1640                        5,
1641                        CoordinationKey::registration(role("controller"), member("node-1")),
1642                        CoordinationRecord::Registration(Registration {
1643                            member: member("node-1"),
1644                            registered_at: GRANTED_AT,
1645                        }),
1646                    ),
1647                    (
1648                        8,
1649                        CoordinationKey::registration(role("controller"), member("node-1")),
1650                        CoordinationRecord::Tombstone,
1651                    ),
1652                ]
1653        );
1654    }
1655
1656    #[test]
1657    fn a_malformed_value_under_a_key_of_the_role_is_a_fault() {
1658        let (key, _) = registration_bytes("controller", "node-1");
1659        let records = [fetched(1, Some(&key), Some(b"truncated"))];
1660        assert!(let Err(CoordinationError::Record(_)) =
1661            select_role_records(&role("controller"), &records));
1662    }
1663
1664    #[test]
1665    fn a_registration_record_carries_the_frozen_key_and_value() {
1666        let name = role("controller");
1667        let who = member("node-1");
1668        let key = CoordinationKey::registration(name.clone(), who.clone());
1669        let value = CoordinationRecord::Registration(Registration {
1670            member: who.clone(),
1671            registered_at: GRANTED_AT,
1672        });
1673        check!(
1674            registration_record(&name, &who, 3, GRANTED_AT)
1675                == ProducerRecord {
1676                    topic: COORDINATION_STATE_TOPIC.to_owned(),
1677                    partition: Some(3),
1678                    key: Some(encode_key(&key)),
1679                    value: encode_value(&value),
1680                    headers: Vec::new(),
1681                    timestamp_ms: None,
1682                }
1683        );
1684    }
1685
1686    #[test]
1687    fn a_lease_record_carries_the_frozen_key_and_value() {
1688        let name = role("controller");
1689        let held = lease("node-1");
1690        let key = CoordinationKey::lease(name.clone());
1691        let value = CoordinationRecord::Lease(held.clone());
1692        check!(
1693            lease_record(&name, &held, 3)
1694                == ProducerRecord {
1695                    topic: COORDINATION_STATE_TOPIC.to_owned(),
1696                    partition: Some(3),
1697                    key: Some(encode_key(&key)),
1698                    value: encode_value(&value),
1699                    headers: Vec::new(),
1700                    timestamp_ms: None,
1701                }
1702        );
1703        let decoded = decode_key(&encode_key(&key)).unwrap();
1704        check!(decoded.kind == RecordKind::Lease);
1705        check!(decoded.member.is_none());
1706    }
1707
1708    /// The fence mapping is how a deposed leader learns that it lost the role,
1709    /// so every shape the producer reports a fence in maps onto
1710    /// [`CoordinationError::Fenced`].
1711    #[test]
1712    fn a_fence_from_the_producer_becomes_the_loss_of_the_role() {
1713        let fences = [
1714            ProducerError::FencedProducer,
1715            ProducerError::Server(INVALID_PRODUCER_EPOCH),
1716            ProducerError::Server(PRODUCER_FENCED),
1717            ProducerError::Client(ClientError::Server {
1718                error_code: INVALID_PRODUCER_EPOCH,
1719            }),
1720            ProducerError::Client(ClientError::Server {
1721                error_code: PRODUCER_FENCED,
1722            }),
1723        ];
1724        for error in fences {
1725            let reported = error.to_string();
1726            check!(is_fenced(&error), "producer error {reported}");
1727            let mapped = fenced_or(&role("controller"), error);
1728            check!(mapped.is_fenced(), "producer error {reported}");
1729            assert!(let CoordinationError::Fenced { .. } = &mapped);
1730            check!(mapped.to_string() == "fenced: another member holds role controller");
1731        }
1732    }
1733
1734    #[test]
1735    fn a_fault_that_is_not_a_fence_passes_through() {
1736        let faults = [
1737            ProducerError::Closed,
1738            ProducerError::FlushTimeout,
1739            ProducerError::Server(1),
1740            ProducerError::Client(ClientError::Disconnected),
1741            ProducerError::Client(ClientError::Server { error_code: 46 }),
1742        ];
1743        for error in faults {
1744            let reported = error.to_string();
1745            check!(!is_fenced(&error), "producer error {reported}");
1746            let mapped = fenced_or(&role("controller"), error);
1747            check!(!mapped.is_fenced(), "producer error {reported}");
1748            assert!(let CoordinationError::Producer(_) = &mapped);
1749        }
1750    }
1751
1752    #[test]
1753    fn a_described_transaction_becomes_a_fencing_token() {
1754        let describe = |producer_id, producer_epoch| TransactionDescription {
1755            transactional_id: "controller".to_owned(),
1756            coordinator_id: 1,
1757            state: "Empty".to_owned(),
1758            producer_id,
1759            producer_epoch,
1760            timeout: secs(30),
1761            start_time_ms: None,
1762            topic_partitions: BTreeSet::new(),
1763        };
1764        check!(token_from_description(&describe(4242, 7)) == Some(token(4242, 7)));
1765        check!(token_from_description(&describe(0, 0)) == Some(token(0, 0)));
1766        check!(token_from_description(&describe(-1, -1)).is_none());
1767        check!(token_from_description(&describe(4242, -1)).is_none());
1768    }
1769
1770    /// A role that no member has ever held is not a fault. The succession
1771    /// rules read `None` and let the first candidate take it.
1772    #[test]
1773    fn an_unknown_transactional_id_is_not_a_fault() {
1774        let broker = |code| AdminError::Broker {
1775            api: "DescribeTransactions",
1776            code,
1777            name: "TEST",
1778            message: None,
1779        };
1780        check!(reports_unknown_transactional_id(&broker(
1781            TRANSACTIONAL_ID_NOT_FOUND
1782        )));
1783        check!(!reports_unknown_transactional_id(&broker(16)));
1784        check!(!reports_unknown_transactional_id(&AdminError::Protocol(
1785            "no row".to_owned()
1786        )));
1787    }
1788
1789    #[test]
1790    fn the_clock_reports_an_instant_after_2020() {
1791        check!(current_millis() > 1_577_836_800_000);
1792    }
1793
1794    /// The builder checks its configuration before it opens a connection, so
1795    /// these cases need no cluster.
1796    #[tokio::test]
1797    async fn the_builder_refuses_a_configuration_kafka_would_reject() {
1798        assert!(matches!(
1799            BrokerTransport::builder().bootstrap("").build().await,
1800            Err(CoordinationError::InvalidConfig(_))
1801        ));
1802        assert!(matches!(
1803            BrokerTransport::builder()
1804                .bootstrap("localhost:9092")
1805                .topic_partitions(0)
1806                .build()
1807                .await,
1808            Err(CoordinationError::InvalidConfig(_))
1809        ));
1810        assert!(matches!(
1811            BrokerTransport::builder()
1812                .bootstrap("localhost:9092")
1813                .topic_replication(0)
1814                .build()
1815                .await,
1816            Err(CoordinationError::InvalidConfig(_))
1817        ));
1818    }
1819
1820    /// A refused start is an error returned to the caller, so `start` records
1821    /// it at its span's own level, info, not at ERROR
1822    /// (krabka-io/krabka-broker#1515).
1823    #[tokio::test]
1824    async fn a_refused_start_is_recorded_at_its_span_level() {
1825        let (result, levels) =
1826            recorded_levels(BrokerTransport::builder().bootstrap("").build()).await;
1827        assert!(let Err(CoordinationError::InvalidConfig(_)) = result);
1828        check!(levels == vec![tracing::Level::INFO]);
1829    }
1830}