Skip to main content

barnabas_client/
group.rs

1//! The group protocol on a socket: the classic one, behind the seam.
2//!
3//! [`barnabas_core::member`] holds the state machine and decides *what* to do;
4//! this sends it. The split is the seam KIP-848 will plug into — see
5//! `docs/completing-the-client.md` — and the rule that keeps it honest is that
6//! nothing above [`GroupProtocol`] names a generation id or a member epoch.
7//! Both are fencing tokens; the moment shared code branches on which kind it
8//! holds, the seam has leaked.
9//!
10//! # The embedded blobs
11//!
12//! `JoinGroup` and `SyncGroup` carry the subscription and the assignment as
13//! **opaque bytes**, in a format the group's members agree on rather than one
14//! the broker parses. That is why a Rust member and a Java member can share a
15//! group at all — and why these encoders have to match, byte for byte, what
16//! `ConsumerProtocol` writes. `kafka-protocol` generates both from Kafka's own
17//! schemas, so the agreement is inherited rather than hand-rolled.
18
19use std::collections::BTreeMap;
20use std::time::Duration;
21
22use barnabas_core::group::{Assignor, Subscription, TopicPartition};
23use barnabas_core::member::{codes, GroupMember, Step};
24use bytes::Bytes;
25use kafka_protocol::messages::{
26    consumer_protocol_assignment::{
27        ConsumerProtocolAssignment, TopicPartition as AssignmentTopicPartition,
28    },
29    consumer_protocol_subscription::{
30        ConsumerProtocolSubscription, TopicPartition as SubscriptionTopicPartition,
31    },
32    join_group_request::{JoinGroupRequest, JoinGroupRequestProtocol},
33    offset_commit_request::{OffsetCommitRequestPartition, OffsetCommitRequestTopic},
34    offset_fetch_request::OffsetFetchRequestTopic,
35    sync_group_request::{SyncGroupRequest, SyncGroupRequestAssignment},
36    ApiKey, FindCoordinatorRequest, FindCoordinatorResponse, GroupId, HeartbeatRequest,
37    HeartbeatResponse, JoinGroupResponse, LeaveGroupRequest, LeaveGroupResponse,
38    OffsetCommitRequest, OffsetCommitResponse, OffsetFetchRequest, OffsetFetchResponse,
39    SyncGroupResponse, TopicName,
40};
41use kafka_protocol::protocol::{Decodable, Encodable, StrBytes};
42
43use crate::cluster::Cluster;
44use crate::{Error, Result, Transport};
45
46/// `protocol_type` for a consumer group. The coordinator rejects a member whose
47/// type does not match the group's, which is what stops a consumer joining a
48/// Connect group by accident.
49const CONSUMER_PROTOCOL: &str = "consumer";
50
51/// How many times to wait for a coordinator that is still being created.
52const COORDINATOR_RETRIES: usize = 40;
53const COORDINATOR_BACKOFF: Duration = Duration::from_millis(250);
54
55/// Added to the rebalance timeout so the *broker* decides a slow rebalance has
56/// failed, rather than us abandoning a request it is still working on.
57const JOIN_SLACK: Duration = Duration::from_secs(5);
58
59/// Coordinator requests other than `JoinGroup` answer promptly.
60const COORDINATOR_TIMEOUT: Duration = Duration::from_secs(30);
61
62/// The `ConsumerProtocol` version this client writes.
63///
64/// **v2, because v1 cannot carry a generation.** A member that has been away
65/// still advertises the partitions it used to own, and a leader with no way to
66/// date that claim hands the same partition to two members — which is what
67/// `cooperative-sticky` did here until this changed. v2 added `generation_id`
68/// for exactly that.
69///
70/// Members negotiate down to the lowest version any of them wrote, so a reader
71/// must honour what it is given rather than assume this one.
72const PROTOCOL_VERSION: i16 = 2;
73
74/// The int16 that prefixes every subscription and assignment blob.
75fn read_version(cursor: &mut Bytes) -> Result<i16> {
76    use bytes::Buf;
77    if cursor.remaining() < 2 {
78        return Err(Error::Core(barnabas_core::Error::Codec(
79            "consumer protocol blob is too short for its version".to_owned(),
80        )));
81    }
82    Ok(cursor.get_i16())
83}
84
85/// A member's identity and its fencing token, opaque above the seam.
86///
87/// **Nothing outside this module reads the fields.** They are what
88/// `TxnOffsetCommit` must carry so the coordinator can reject a commit from a
89/// member that has already been replaced (KIP-447), and under KIP-848 the same
90/// two positions hold a member epoch instead of a generation. A caller that
91/// could name them would be a caller that has to change when the protocol does,
92/// so the only way to get one is to ask a live consumer for it and hand it
93/// straight to a producer.
94#[derive(Debug, Clone)]
95pub struct GroupMetadata {
96    pub(crate) group_id: String,
97    pub(crate) generation_id: i32,
98    pub(crate) member_id: String,
99    /// Static membership (KIP-345) is not implemented, so this is always
100    /// `None`. It is here because the field is on the wire and leaving it out
101    /// would mean changing this type later.
102    pub(crate) group_instance_id: Option<String>,
103}
104
105impl GroupMetadata {
106    /// The group these offsets belong to. The one field a caller may read,
107    /// because they chose it.
108    #[must_use]
109    pub fn group_id(&self) -> &str {
110        &self.group_id
111    }
112}
113
114/// How this client becomes and stays a member of a group, and how it learns
115/// what it is assigned.
116///
117/// **The only thing KIP-848 changes.** Offset commit, fetching, the callbacks
118/// and the assignment's effect all sit above this.
119pub trait GroupProtocol<T: Transport> {
120    /// Drive one step of membership. Called until it reports [`Membership`]
121    /// stable.
122    fn advance(
123        &mut self,
124        cluster: &mut Cluster<T>,
125    ) -> impl std::future::Future<Output = Result<Membership>>;
126
127    /// Leave deliberately, so the group rebalances now rather than at the
128    /// session timeout.
129    fn leave(&mut self, cluster: &mut Cluster<T>) -> impl std::future::Future<Output = Result<()>>;
130
131    /// Commit these offsets for the group.
132    ///
133    /// **On the seam, not above it**, because a commit carries the fencing
134    /// token — a generation id here, a member epoch under KIP-848 — and the
135    /// rule that keeps the seam honest is that nothing above it names either.
136    /// The caller supplies positions; which token proves they are still ours is
137    /// the protocol's business.
138    ///
139    /// The offset committed is **the next one to read**, not the last one read.
140    /// Kafka's convention, and getting it wrong replays or skips exactly one
141    /// record per partition per restart.
142    fn commit(
143        &mut self,
144        cluster: &mut Cluster<T>,
145        offsets: &BTreeMap<TopicPartition, i64>,
146    ) -> impl std::future::Future<Output = Result<()>>;
147
148    /// What this member is subscribed to, so the caller can watch those topics
149    /// for changes the group must agree on.
150    fn topics(&self) -> Vec<String>;
151
152    /// Ask to rejoin the group at the next [`Self::advance`].
153    ///
154    /// **On the seam** for the same reason [`Self::commit`] is: what a rejoin
155    /// costs and what it is called differ completely between the classic
156    /// protocol and KIP-848. The caller only says that something the group
157    /// agreed on has changed.
158    fn request_rejoin(&mut self);
159
160    /// What proves this member is current, for a transactional producer.
161    ///
162    /// `None` until the member is stable: an unjoined member has no token to
163    /// offer, and committing without one is how a zombie writes.
164    fn group_metadata(&self) -> Option<GroupMetadata>;
165
166    /// Where this group last committed, for the partitions given.
167    ///
168    /// A partition with no committed offset is **absent** from the result
169    /// rather than zero — "never committed" and "committed at 0" are different
170    /// answers, and conflating them replays a whole partition.
171    fn committed(
172        &mut self,
173        cluster: &mut Cluster<T>,
174        partitions: &[TopicPartition],
175    ) -> impl std::future::Future<Output = Result<BTreeMap<TopicPartition, i64>>>;
176}
177
178/// Where membership stands after a step.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum Membership {
181    /// Still joining or syncing; call again.
182    InProgress,
183    /// Assigned and stable. The partitions are this member's to fetch.
184    Assigned(Vec<TopicPartition>),
185    /// These partitions were given up. The caller must stop fetching them
186    /// **before** the next call, because another member is about to be given
187    /// them.
188    ///
189    /// Under the eager protocol that is everything this member held; under the
190    /// cooperative one it is only what moved, and the rest keeps being read.
191    Revoked(Vec<TopicPartition>),
192}
193
194/// The classic protocol: `JoinGroup`, `SyncGroup`, `Heartbeat`.
195pub struct ClassicProtocol {
196    member: GroupMember,
197    assignor: Box<dyn Assignor>,
198    coordinator: Option<String>,
199    session_timeout_ms: i32,
200    rebalance_timeout_ms: i32,
201    /// What the last `advance` reported, so a revocation is announced once.
202    announced_revoked: bool,
203    /// Whether anything was ever assigned. Without it the first join reports a
204    /// revocation, and a caller with a revoke callback runs it for partitions
205    /// it never had.
206    ever_assigned: bool,
207}
208
209impl ClassicProtocol {
210    #[must_use]
211    pub fn new(
212        group_id: impl Into<String>,
213        topics: Vec<String>,
214        assignor: Box<dyn Assignor>,
215    ) -> Self {
216        // **The assignor decides the protocol**, because they are the same
217        // choice: `cooperative-sticky` is a name the group agrees on *and* a
218        // different revocation flow. Letting them be set separately is letting
219        // them disagree.
220        let protocol = if assignor.name() == "cooperative-sticky" {
221            barnabas_core::member::RebalanceProtocol::Cooperative
222        } else {
223            barnabas_core::member::RebalanceProtocol::Eager
224        };
225        Self {
226            member: GroupMember::new(group_id, topics).with_protocol(protocol),
227            assignor,
228            coordinator: None,
229            session_timeout_ms: 45_000,
230            rebalance_timeout_ms: 300_000,
231            announced_revoked: false,
232            ever_assigned: false,
233        }
234    }
235
236    #[must_use]
237    pub fn member(&self) -> &GroupMember {
238        &self.member
239    }
240
241    /// How long the coordinator waits for a heartbeat before removing us.
242    pub fn set_session_timeout(&mut self, ms: i32) {
243        self.session_timeout_ms = ms;
244    }
245
246    /// How long the coordinator waits for every member to rejoin. Should exceed
247    /// the longest a caller can spend between polls, or a slow consumer is
248    /// dropped mid-rebalance.
249    pub fn set_rebalance_timeout(&mut self, ms: i32) {
250        self.rebalance_timeout_ms = ms;
251    }
252
253    /// The **group** coordinator, which is a different broker from a
254    /// transaction coordinator and is found the same way.
255    async fn coordinator_addr<T: Transport>(&mut self, cluster: &mut Cluster<T>) -> Result<String> {
256        if let Some(addr) = &self.coordinator {
257            return Ok(addr.clone());
258        }
259        let mut req = FindCoordinatorRequest::default();
260        req.key = StrBytes::from_string(self.member.group_id().to_owned());
261        req.key_type = 0; // GROUP
262
263        // **The first call for a new group is expected to fail.**
264        // `__consumer_offsets` is created lazily by this very request, so a
265        // fresh cluster answers COORDINATOR_NOT_AVAILABLE until it exists. The
266        // transactional producer met the same thing on `__transaction_state`.
267        for attempt in 0..COORDINATOR_RETRIES {
268            let resp: FindCoordinatorResponse =
269                cluster.call_any(ApiKey::FindCoordinator, 3, &req).await?;
270            match resp.error_code {
271                codes::NONE => {
272                    let addr = format!("{}:{}", resp.host.as_str(), resp.port);
273                    self.coordinator = Some(addr.clone());
274                    return Ok(addr);
275                }
276                codes::COORDINATOR_NOT_AVAILABLE | codes::COORDINATOR_LOAD_IN_PROGRESS => {
277                    let _ = attempt;
278                    T::sleep(COORDINATOR_BACKOFF).await;
279                }
280                code => crate::check("FindCoordinator", code)?,
281            }
282        }
283        Err(Error::Broker {
284            op: "FindCoordinator",
285            code: codes::COORDINATOR_NOT_AVAILABLE,
286            disposition: barnabas_core::Disposition::Retry,
287        })
288    }
289
290    async fn join<T: Transport>(&mut self, cluster: &mut Cluster<T>) -> Result<Step> {
291        let addr = self.coordinator_addr(cluster).await?;
292
293        let mut protocol = JoinGroupRequestProtocol::default();
294        protocol.name = StrBytes::from_string(self.assignor.name().to_owned());
295        protocol.metadata = encode_subscription(&self.member.subscription())?;
296
297        let mut req = JoinGroupRequest::default();
298        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
299        req.session_timeout_ms = self.session_timeout_ms;
300        req.rebalance_timeout_ms = self.rebalance_timeout_ms;
301        req.member_id = StrBytes::from_string(self.member.member_id().to_owned());
302        req.protocol_type = StrBytes::from_static_str(CONSUMER_PROTOCOL);
303        req.protocols = vec![protocol];
304
305        // The coordinator holds this until the whole group has joined, so its
306        // deadline is the rebalance timeout, not the general request timeout.
307        let deadline =
308            Duration::from_millis(u64::try_from(self.rebalance_timeout_ms).unwrap_or(300_000))
309                + JOIN_SLACK;
310        let resp: JoinGroupResponse = cluster
311            .call_coordinator(&addr, ApiKey::JoinGroup, 7, &req, deadline)
312            .await?;
313
314        // Only the leader is given the members, and only it needs to decode
315        // them.
316        let members = if resp.leader == resp.member_id {
317            resp.members
318                .iter()
319                .map(|m| decode_subscription(m.member_id.as_str(), &m.metadata))
320                .collect::<Result<Vec<_>>>()?
321        } else {
322            Vec::new()
323        };
324
325        if std::env::var("BARNABAS_TRACE").is_ok() {
326            eprintln!(
327                "[{}] JOIN<- err={} gen={} id={} leader={}",
328                self.member.member_id(),
329                resp.error_code,
330                resp.generation_id,
331                resp.member_id.as_str(),
332                resp.leader.as_str()
333            );
334        }
335        Ok(self.member.on_join(
336            resp.error_code,
337            resp.generation_id,
338            resp.member_id.as_str(),
339            resp.leader.as_str(),
340            members,
341        ))
342    }
343
344    async fn sync<T: Transport>(
345        &mut self,
346        cluster: &mut Cluster<T>,
347        assignments: Vec<SyncGroupRequestAssignment>,
348    ) -> Result<Step> {
349        let addr = self.coordinator_addr(cluster).await?;
350
351        if std::env::var("BARNABAS_TRACE").is_ok() {
352            let sub = self.member.subscription();
353            eprintln!(
354                "[{}] SYNC-> gen={} owned={:?} sending={:?}",
355                self.member.member_id(),
356                self.member.generation(),
357                sub.owned.iter().map(|t| t.partition).collect::<Vec<_>>(),
358                assignments
359                    .iter()
360                    .map(|a| (a.member_id.to_string(), a.assignment.len()))
361                    .collect::<Vec<_>>()
362            );
363        }
364        let mut req = SyncGroupRequest::default();
365        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
366        req.generation_id = self.member.generation();
367        req.member_id = StrBytes::from_string(self.member.member_id().to_owned());
368        req.protocol_type = Some(StrBytes::from_static_str(CONSUMER_PROTOCOL));
369        req.protocol_name = Some(StrBytes::from_string(self.assignor.name().to_owned()));
370        req.assignments = assignments;
371
372        let resp: SyncGroupResponse = cluster
373            .call_coordinator(&addr, ApiKey::SyncGroup, 4, &req, COORDINATOR_TIMEOUT)
374            .await?;
375        let assigned = if resp.error_code == codes::NONE && !resp.assignment.is_empty() {
376            decode_assignment(&resp.assignment)?
377        } else {
378            Vec::new()
379        };
380        if std::env::var("BARNABAS_TRACE").is_ok() {
381            eprintln!(
382                "[{}] SYNC<- err={} assigned={:?}",
383                self.member.member_id(),
384                resp.error_code,
385                assigned.iter().map(|t| t.partition).collect::<Vec<_>>()
386            );
387        }
388        let step = self.member.on_sync(resp.error_code, assigned);
389        if std::env::var("BARNABAS_TRACE").is_ok() {
390            eprintln!(
391                "[{}]   after: gen={} assignment={:?} lost={:?} step={:?}",
392                self.member.member_id(),
393                self.member.generation(),
394                self.member
395                    .assignment()
396                    .iter()
397                    .map(|t| t.partition)
398                    .collect::<Vec<_>>(),
399                self.member
400                    .lost()
401                    .iter()
402                    .map(|t| t.partition)
403                    .collect::<Vec<_>>(),
404                step
405            );
406        }
407        Ok(step)
408    }
409
410    async fn heartbeat<T: Transport>(&mut self, cluster: &mut Cluster<T>) -> Result<Step> {
411        let addr = self.coordinator_addr(cluster).await?;
412
413        let mut req = HeartbeatRequest::default();
414        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
415        req.generation_id = self.member.generation();
416        req.member_id = StrBytes::from_string(self.member.member_id().to_owned());
417
418        let resp: HeartbeatResponse = cluster
419            .call_coordinator(&addr, ApiKey::Heartbeat, 4, &req, COORDINATOR_TIMEOUT)
420            .await?;
421        if std::env::var("BARNABAS_TRACE").is_ok() {
422            eprintln!(
423                "[{}] HB<- err={} gen={}",
424                self.member.member_id(),
425                resp.error_code,
426                self.member.generation()
427            );
428        }
429        Ok(self.member.on_heartbeat(resp.error_code))
430    }
431
432    /// Compute the assignment as leader, and send it.
433    async fn assign_and_sync<T: Transport>(
434        &mut self,
435        cluster: &mut Cluster<T>,
436        members: Vec<Subscription>,
437    ) -> Result<Step> {
438        // The count comes from metadata, not from the members: a member only
439        // says which *topics* it wants.
440        let mut partitions_per_topic: BTreeMap<String, i32> = BTreeMap::new();
441        for topic in members.iter().flat_map(|m| m.topics.iter()) {
442            if !partitions_per_topic.contains_key(topic) {
443                let count = cluster.partition_count(topic).await?;
444                partitions_per_topic.insert(topic.clone(), count);
445            }
446        }
447
448        if std::env::var("BARNABAS_TRACE").is_ok() {
449            eprintln!(
450                "[{}] LEADER sees: {:?}",
451                self.member.member_id(),
452                members
453                    .iter()
454                    .map(|m| (
455                        m.member_id.clone(),
456                        m.generation,
457                        m.owned.iter().map(|t| t.partition).collect::<Vec<_>>()
458                    ))
459                    .collect::<Vec<_>>()
460            );
461        }
462        let assignment = self.assignor.assign(&members, &partitions_per_topic);
463        if std::env::var("BARNABAS_TRACE").is_ok() {
464            eprintln!(
465                "[{}] LEADER assigns: {:?}",
466                self.member.member_id(),
467                assignment
468                    .iter()
469                    .map(|(m, p)| (m.clone(), p.iter().map(|t| t.partition).collect::<Vec<_>>()))
470                    .collect::<Vec<_>>()
471            );
472        }
473        let encoded = assignment
474            .iter()
475            .map(|(member_id, partitions)| {
476                let mut entry = SyncGroupRequestAssignment::default();
477                entry.member_id = StrBytes::from_string(member_id.clone());
478                entry.assignment = encode_assignment(partitions)?;
479                Ok(entry)
480            })
481            .collect::<Result<Vec<_>>>()?;
482
483        self.sync(cluster, encoded).await
484    }
485}
486
487impl<T: Transport> GroupProtocol<T> for ClassicProtocol {
488    async fn advance(&mut self, cluster: &mut Cluster<T>) -> Result<Membership> {
489        let step = self.member.step();
490        let outcome = match step {
491            Step::Join { .. } => self.join(cluster).await?,
492            Step::AssignAndSync { members } => self.assign_and_sync(cluster, members).await?,
493            Step::Sync => self.sync(cluster, Vec::new()).await?,
494            Step::Heartbeat => self.heartbeat(cluster).await?,
495            Step::FindCoordinator => {
496                self.coordinator = None;
497                Step::Join {
498                    member_id: self.member.member_id().to_owned(),
499                }
500            }
501        };
502
503        // A step that asked us to re-discover clears the coordinator, whichever
504        // call produced it.
505        if outcome == Step::FindCoordinator {
506            self.coordinator = None;
507        }
508
509        if self.member.state() == barnabas_core::member::MemberState::Stable {
510            self.announced_revoked = false;
511            self.ever_assigned = true;
512            return Ok(Membership::Assigned(self.member.assignment().to_vec()));
513        }
514        // **Announced once, and before the next request goes out.** The caller
515        // has to stop fetching these partitions now: another member is about to
516        // be told it owns them.
517        if self.ever_assigned && !self.announced_revoked {
518            self.announced_revoked = true;
519            return Ok(Membership::Revoked(self.member.lost().to_vec()));
520        }
521        Ok(Membership::InProgress)
522    }
523
524    async fn commit(
525        &mut self,
526        cluster: &mut Cluster<T>,
527        offsets: &BTreeMap<TopicPartition, i64>,
528    ) -> Result<()> {
529        self.commit_offsets(cluster, offsets).await
530    }
531
532    fn topics(&self) -> Vec<String> {
533        self.member.topics().to_vec()
534    }
535
536    fn request_rejoin(&mut self) {
537        self.member.request_rejoin();
538    }
539
540    fn group_metadata(&self) -> Option<GroupMetadata> {
541        // `can_commit` is exactly the right gate: a metadata that cannot commit
542        // is a metadata that will be rejected, and finding that out at
543        // `TxnOffsetCommit` time aborts a transaction that never had to start.
544        if !self.member.can_commit() {
545            return None;
546        }
547        Some(GroupMetadata {
548            group_id: self.member.group_id().to_owned(),
549            generation_id: self.member.generation(),
550            member_id: self.member.member_id().to_owned(),
551            group_instance_id: None,
552        })
553    }
554
555    async fn committed(
556        &mut self,
557        cluster: &mut Cluster<T>,
558        partitions: &[TopicPartition],
559    ) -> Result<BTreeMap<TopicPartition, i64>> {
560        self.fetch_offsets(cluster, partitions).await
561    }
562
563    async fn leave(&mut self, cluster: &mut Cluster<T>) -> Result<()> {
564        if self.member.member_id().is_empty() {
565            return Ok(());
566        }
567        let addr = self.coordinator_addr(cluster).await?;
568
569        let mut req = LeaveGroupRequest::default();
570        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
571        req.member_id = StrBytes::from_string(self.member.member_id().to_owned());
572
573        // A failure here costs a rebalance delay, not correctness: the
574        // coordinator drops us at the session timeout anyway.
575        let _: std::result::Result<LeaveGroupResponse, Error> = cluster
576            .call_coordinator(&addr, ApiKey::LeaveGroup, 3, &req, COORDINATOR_TIMEOUT)
577            .await;
578        self.member.on_leave();
579        Ok(())
580    }
581}
582
583impl ClassicProtocol {
584    async fn commit_offsets<T: Transport>(
585        &mut self,
586        cluster: &mut Cluster<T>,
587        offsets: &BTreeMap<TopicPartition, i64>,
588    ) -> Result<()> {
589        if offsets.is_empty() {
590            return Ok(());
591        }
592        // Refused rather than sent: between a revocation and the next
593        // assignment these partitions may already belong to someone else, and
594        // the coordinator will not reject the write because the generation has
595        // not moved yet.
596        if !self.member.can_commit() {
597            return Err(Error::Broker {
598                op: "OffsetCommit",
599                code: codes::REBALANCE_IN_PROGRESS,
600                disposition: barnabas_core::Disposition::Retry,
601            });
602        }
603        let addr = self.coordinator_addr(cluster).await?;
604
605        let mut by_topic: BTreeMap<String, Vec<OffsetCommitRequestPartition>> = BTreeMap::new();
606        for (tp, offset) in offsets {
607            let mut partition = OffsetCommitRequestPartition::default();
608            partition.partition_index = tp.partition;
609            partition.committed_offset = *offset;
610            partition.committed_leader_epoch = -1;
611            by_topic
612                .entry(tp.topic.clone())
613                .or_default()
614                .push(partition);
615        }
616
617        let mut req = OffsetCommitRequest::default();
618        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
619        req.generation_id_or_member_epoch = self.member.generation();
620        req.member_id = StrBytes::from_string(self.member.member_id().to_owned());
621        req.topics = by_topic
622            .into_iter()
623            .map(|(name, partitions)| {
624                let mut topic = OffsetCommitRequestTopic::default();
625                topic.name = TopicName(StrBytes::from_string(name));
626                topic.partitions = partitions;
627                topic
628            })
629            .collect();
630
631        let resp: OffsetCommitResponse = cluster
632            .call_coordinator(&addr, ApiKey::OffsetCommit, 8, &req, COORDINATOR_TIMEOUT)
633            .await?;
634        for topic in &resp.topics {
635            for partition in &topic.partitions {
636                crate::check("OffsetCommit", partition.error_code)?;
637            }
638        }
639        Ok(())
640    }
641
642    async fn fetch_offsets<T: Transport>(
643        &mut self,
644        cluster: &mut Cluster<T>,
645        partitions: &[TopicPartition],
646    ) -> Result<BTreeMap<TopicPartition, i64>> {
647        if partitions.is_empty() {
648            return Ok(BTreeMap::new());
649        }
650        let addr = self.coordinator_addr(cluster).await?;
651
652        let mut by_topic: BTreeMap<String, Vec<i32>> = BTreeMap::new();
653        for tp in partitions {
654            by_topic
655                .entry(tp.topic.clone())
656                .or_default()
657                .push(tp.partition);
658        }
659
660        let mut req = OffsetFetchRequest::default();
661        req.group_id = GroupId(StrBytes::from_string(self.member.group_id().to_owned()));
662        req.topics = Some(
663            by_topic
664                .into_iter()
665                .map(|(name, partition_indexes)| {
666                    let mut topic = OffsetFetchRequestTopic::default();
667                    topic.name = TopicName(StrBytes::from_string(name));
668                    topic.partition_indexes = partition_indexes;
669                    topic
670                })
671                .collect(),
672        );
673
674        let resp: OffsetFetchResponse = cluster
675            .call_coordinator(&addr, ApiKey::OffsetFetch, 6, &req, COORDINATOR_TIMEOUT)
676            .await?;
677        crate::check("OffsetFetch", resp.error_code)?;
678
679        let mut out = BTreeMap::new();
680        for topic in &resp.topics {
681            for partition in &topic.partitions {
682                crate::check("OffsetFetch partition", partition.error_code)?;
683                // -1 is "never committed", which is not an offset. Left out so
684                // the caller falls back to its reset policy rather than
685                // replaying from zero.
686                if partition.committed_offset >= 0 {
687                    out.insert(
688                        TopicPartition::new(topic.name.0.to_string(), partition.partition_index),
689                        partition.committed_offset,
690                    );
691                }
692            }
693        }
694        Ok(out)
695    }
696}
697
698// ── the embedded blobs ───────────────────────────────────────────────────────
699
700fn encode_subscription(subscription: &Subscription) -> Result<Bytes> {
701    let mut owned: BTreeMap<String, Vec<i32>> = BTreeMap::new();
702    for tp in &subscription.owned {
703        owned
704            .entry(tp.topic.clone())
705            .or_default()
706            .push(tp.partition);
707    }
708
709    let mut body = ConsumerProtocolSubscription::default();
710    body.topics = subscription
711        .topics
712        .iter()
713        .map(|t| StrBytes::from_string(t.clone()))
714        .collect();
715    body.generation_id = subscription.generation;
716    body.owned_partitions = owned
717        .into_iter()
718        .map(|(topic, partitions)| {
719            let mut entry = SubscriptionTopicPartition::default();
720            entry.topic = TopicName(StrBytes::from_string(topic));
721            entry.partitions = partitions;
722            entry
723        })
724        .collect();
725
726    // **The version goes on the wire ahead of the struct.** Kafka's
727    // `ConsumerProtocol.serializeSubscription` writes an int16 version and then
728    // the body; `kafka-protocol`'s generated type is only the body, so the
729    // prefix is ours to add. Without it every member — ours and Java's — reads
730    // the blob shifted by two bytes.
731    let mut buf = bytes::BytesMut::new();
732    buf.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes());
733    body.encode(&mut buf, PROTOCOL_VERSION)
734        .map_err(|e| Error::Core(barnabas_core::Error::Codec(format!("subscription: {e}"))))?;
735    Ok(buf.freeze())
736}
737
738fn decode_subscription(member_id: &str, metadata: &Bytes) -> Result<Subscription> {
739    let mut cursor = metadata.clone();
740    let version = read_version(&mut cursor)?;
741    let body = ConsumerProtocolSubscription::decode(&mut cursor, version)
742        .map_err(|e| Error::Core(barnabas_core::Error::Codec(format!("subscription: {e}"))))?;
743
744    Ok(Subscription {
745        member_id: member_id.to_owned(),
746        // A v0 or v1 blob has no generation; -1 says "unknown", and an unknown
747        // claim is treated as stale rather than trusted.
748        generation: if version >= 2 { body.generation_id } else { -1 },
749        topics: body.topics.iter().map(|t| t.to_string()).collect(),
750        owned: body
751            .owned_partitions
752            .iter()
753            .flat_map(|tp| {
754                let topic = tp.topic.0.to_string();
755                tp.partitions
756                    .iter()
757                    .map(move |p| TopicPartition::new(topic.clone(), *p))
758            })
759            .collect(),
760    })
761}
762
763fn encode_assignment(partitions: &[TopicPartition]) -> Result<Bytes> {
764    let mut by_topic: BTreeMap<String, Vec<i32>> = BTreeMap::new();
765    for tp in partitions {
766        by_topic
767            .entry(tp.topic.clone())
768            .or_default()
769            .push(tp.partition);
770    }
771
772    let mut body = ConsumerProtocolAssignment::default();
773    body.assigned_partitions = by_topic
774        .into_iter()
775        .map(|(topic, partitions)| {
776            let mut entry = AssignmentTopicPartition::default();
777            entry.topic = TopicName(StrBytes::from_string(topic));
778            entry.partitions = partitions;
779            entry
780        })
781        .collect();
782
783    let mut buf = bytes::BytesMut::new();
784    buf.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes());
785    body.encode(&mut buf, PROTOCOL_VERSION)
786        .map_err(|e| Error::Core(barnabas_core::Error::Codec(format!("assignment: {e}"))))?;
787    Ok(buf.freeze())
788}
789
790fn decode_assignment(assignment: &Bytes) -> Result<Vec<TopicPartition>> {
791    let mut cursor = assignment.clone();
792    let version = read_version(&mut cursor)?;
793    let body = ConsumerProtocolAssignment::decode(&mut cursor, version)
794        .map_err(|e| Error::Core(barnabas_core::Error::Codec(format!("assignment: {e}"))))?;
795
796    let mut out: Vec<TopicPartition> = body
797        .assigned_partitions
798        .iter()
799        .flat_map(|tp| {
800            let topic = tp.topic.0.to_string();
801            tp.partitions
802                .iter()
803                .map(move |p| TopicPartition::new(topic.clone(), *p))
804        })
805        .collect();
806    out.sort();
807    Ok(out)
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    /// The blobs must survive a round trip: they are what a Java member reads.
815    #[test]
816    fn a_subscription_round_trips() {
817        let subscription = Subscription {
818            member_id: "m-1".to_owned(),
819            topics: vec!["a".to_owned(), "b".to_owned()],
820            owned: vec![TopicPartition::new("a", 0), TopicPartition::new("a", 3)],
821            generation: 7,
822        };
823        let encoded = encode_subscription(&subscription).expect("encode");
824        let decoded = decode_subscription("m-1", &encoded).expect("decode");
825        assert_eq!(decoded.topics, subscription.topics);
826        assert_eq!(decoded.owned, subscription.owned);
827        assert_eq!(
828            decoded.generation, 7,
829            "the generation must survive: without it a leader cannot tell a \
830             live ownership claim from a stale one"
831        );
832    }
833
834    #[test]
835    fn an_assignment_round_trips() {
836        let partitions = vec![
837            TopicPartition::new("a", 0),
838            TopicPartition::new("a", 1),
839            TopicPartition::new("b", 7),
840        ];
841        let encoded = encode_assignment(&partitions).expect("encode");
842        assert_eq!(decode_assignment(&encoded).expect("decode"), partitions);
843    }
844
845    /// An empty assignment is a real answer — "you got nothing this round" —
846    /// and must not decode as an error.
847    #[test]
848    fn an_empty_assignment_decodes_to_nothing() {
849        let encoded = encode_assignment(&[]).expect("encode");
850        assert!(decode_assignment(&encoded).expect("decode").is_empty());
851    }
852}