use std::{
collections::{HashMap, HashSet},
sync::{
Arc,
atomic::{AtomicI32, Ordering},
},
time::Duration,
};
use bytes::Bytes;
use krabka_client_core::Client;
use krabka_protocol::{
owned::{
find_coordinator_request::FindCoordinatorRequest,
find_coordinator_response::FindCoordinatorResponse,
heartbeat_request::HeartbeatRequest,
join_group_request::{JoinGroupRequest, JoinGroupRequestProtocol},
join_group_response::JoinGroupResponse,
leave_group_request::{LeaveGroupRequest, MemberIdentity},
offset_fetch_response::OffsetFetchResponse,
sync_group_request::{SyncGroupRequest, SyncGroupRequestAssignment},
sync_group_response::SyncGroupResponse,
},
primitives::uuid::Uuid as WireUuid,
};
use krabka_units::{
Time,
convert::{StdDurationExt as _, TimeExt as _},
};
use tokio::sync::{Mutex, Notify, OwnedMutexGuard};
use tokio_util::sync::CancellationToken;
use crate::{
GroupMembershipOperation,
assignor::{Assignor, RebalanceProtocol},
builder::{
AutoOffsetReset, decode_assignment, decode_subscription, encode_assignment,
encode_subscription,
},
commit::{
AutoCommitOutcome, auto_commit_outcome, build_commit_request, names_moved_coordinator,
},
consumer::{CommitIdentity, ConsumerRetryPolicy, reset_starting_offset, starting_offset},
error::ConsumerError,
offset_wire::{
OffsetFetchAction, TopicNameOffsetFetch, build_commit_topics, build_offset_fetch,
classify_offset_fetch, parse_offset_fetch,
},
};
pub(crate) const COORDINATOR_LOAD_IN_PROGRESS: i16 = 14;
pub(crate) const COORDINATOR_NOT_AVAILABLE: i16 = 15;
pub(crate) const NOT_COORDINATOR: i16 = 16;
const FENCED_INSTANCE_ID: i16 = 82;
const UNKNOWN_MEMBER_ID: i16 = 25;
fn heartbeat_rejoin_reason(error_code: i16) -> String {
let error = match error_code {
27 => return "group is already rebalancing".into(),
22 => "ILLEGAL_GENERATION",
UNKNOWN_MEMBER_ID => "UNKNOWN_MEMBER_ID",
FENCED_INSTANCE_ID => "FENCED_INSTANCE_ID",
_ => "UNKNOWN_SERVER_ERROR",
};
format!("encountered {error} from HEARTBEAT response")
}
fn rebalance_failure_reason(error: &ConsumerError) -> String {
let exception = match error {
ConsumerError::Server(14) => "CoordinatorLoadInProgressException",
ConsumerError::Server(15) | ConsumerError::CoordinatorUnavailable => {
"CoordinatorNotAvailableException"
}
ConsumerError::Server(16) => "NotCoordinatorException",
ConsumerError::Server(22) => "IllegalGenerationException",
ConsumerError::Server(23) => "InconsistentGroupProtocolException",
ConsumerError::Server(24) => "InvalidGroupIdException",
ConsumerError::Server(UNKNOWN_MEMBER_ID) => "UnknownMemberIdException",
ConsumerError::Server(26) => "InvalidSessionTimeoutException",
ConsumerError::Server(27) => "RebalanceInProgressException",
ConsumerError::Server(30) | ConsumerError::GroupAuthorizationFailed(_) => {
"GroupAuthorizationException"
}
ConsumerError::Server(81) => "GroupMaxSizeReachedException",
ConsumerError::FencedInstanceId(_) => "FencedInstanceIdException",
ConsumerError::TopicAuthorizationFailed(_) => "TopicAuthorizationException",
ConsumerError::Client(krabka_client_core::ClientError::Timeout(_)) => "TimeoutException",
ConsumerError::Client(_) => "DisconnectException",
_ => "KafkaException",
};
format!("rebalance failed due to {exception}")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CoordinatorRetryPolicy {
pub timeout: Duration,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl From<ConsumerRetryPolicy> for CoordinatorRetryPolicy {
fn from(value: ConsumerRetryPolicy) -> Self {
Self {
timeout: value.coordinator_retry_timeout().to_std(),
initial_backoff: value.coordinator_initial_backoff().to_std(),
max_backoff: value.coordinator_max_backoff().to_std(),
}
}
}
pub(crate) fn is_retriable_coordinator_code(code: i16) -> bool {
matches!(
code,
COORDINATOR_LOAD_IN_PROGRESS | COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR
)
}
pub(crate) fn is_retriable_transport_error(e: &krabka_client_core::ClientError) -> bool {
matches!(
e,
krabka_client_core::ClientError::Connect { .. }
| krabka_client_core::ClientError::Tls { .. }
| krabka_client_core::ClientError::Sasl { .. }
| krabka_client_core::ClientError::Disconnected
| krabka_client_core::ClientError::Io(_)
)
}
fn coordinator_error_code(r: &FindCoordinatorResponse) -> i16 {
r.coordinators
.first()
.map_or(r.error_code, |c| c.error_code)
}
fn coordinator_node_id(r: &FindCoordinatorResponse) -> i32 {
r.coordinators.first().map_or(r.node_id, |c| c.node_id)
}
#[tracing::instrument(
name = "consumer.find_coordinator",
level = "info",
skip_all,
fields(group_id = %group_id, coordinator_id = tracing::field::Empty),
err
)]
pub(crate) async fn find_coordinator(
client: &Client,
group_id: &str,
retry: CoordinatorRetryPolicy,
) -> Result<i32, ConsumerError> {
let resp = with_coordinator_retry(retry, coordinator_error_code, || {
let group_id = group_id.to_string();
async move {
match client.send(build_find_coordinator_request(group_id)).await {
Ok(resp) => Ok(resp),
Err(e) => {
if is_retriable_transport_error(&e) {
client.reconnect_bootstrap().await;
}
Err(ConsumerError::from(e))
}
}
}
})
.await?;
let code = coordinator_error_code(&resp);
if code != 0 {
return Err(ConsumerError::Server(code));
}
let node_id = coordinator_node_id(&resp);
client.refresh_metadata().await?;
tracing::Span::current().record("coordinator_id", node_id);
Ok(node_id)
}
fn build_find_coordinator_request(group_id: String) -> FindCoordinatorRequest {
FindCoordinatorRequest {
key: group_id.clone(),
coordinator_keys: vec![group_id],
..Default::default()
}
}
pub(crate) fn retry_deadline_elapsed(start: tokio::time::Instant, timeout: Duration) -> bool {
start.elapsed() >= timeout
}
pub(crate) fn next_backoff(backoff: Duration, max_backoff: Duration) -> Duration {
(backoff * 2).min(max_backoff)
}
pub(crate) fn build_leave_group_request(
group_id: String,
member_id: String,
reason: Option<&str>,
) -> LeaveGroupRequest {
LeaveGroupRequest {
group_id,
member_id: member_id.clone(),
members: vec![MemberIdentity {
member_id,
reason: reason.map(|reason| truncate_reason(reason).to_owned()),
..Default::default()
}],
..Default::default()
}
}
fn build_heartbeat_request(
group_id: String,
generation_id: i32,
member_id: String,
group_instance_id: Option<String>,
) -> HeartbeatRequest {
HeartbeatRequest {
group_id,
generation_id,
member_id,
group_instance_id,
..Default::default()
}
}
pub(crate) async fn with_coordinator_refind<R, F, Fut>(
client: &Client,
group_id: &str,
coordinator_id: &AtomicI32,
retry: CoordinatorRetryPolicy,
code: impl Fn(&R) -> i16,
make: F,
) -> Result<R, ConsumerError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<R, ConsumerError>>,
{
let start = tokio::time::Instant::now();
let mut backoff = retry.initial_backoff;
loop {
let needs_refind = match make().await {
Ok(r) if !is_retriable_coordinator_code(code(&r)) => return Ok(r),
Ok(r) => {
if retry_deadline_elapsed(start, retry.timeout) {
return Ok(r);
}
true
}
Err(ConsumerError::Client(e)) if is_retriable_transport_error(&e) => {
if retry_deadline_elapsed(start, retry.timeout) {
return Err(ConsumerError::CoordinatorUnavailable);
}
client.evict_broker(coordinator_id.load(Ordering::Relaxed));
true
}
Err(e) => return Err(e),
};
if needs_refind {
match find_coordinator(client, group_id, retry).await {
Ok(id) => coordinator_id.store(id, Ordering::Relaxed),
Err(e) => {
tracing::warn!(
error = %e,
"coordinator re-discovery failed; retrying with last-known id"
);
}
}
}
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff, retry.max_backoff);
}
}
pub(crate) async fn with_coordinator_retry<R, F, Fut>(
retry: CoordinatorRetryPolicy,
code: impl Fn(&R) -> i16,
make: F,
) -> Result<R, ConsumerError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<R, ConsumerError>>,
{
let start = tokio::time::Instant::now();
let mut backoff = retry.initial_backoff;
loop {
match make().await {
Ok(r) if !is_retriable_coordinator_code(code(&r)) => return Ok(r),
Ok(r) => {
if retry_deadline_elapsed(start, retry.timeout) {
return Ok(r);
}
}
Err(ConsumerError::Client(e)) if is_retriable_transport_error(&e) => {
if retry_deadline_elapsed(start, retry.timeout) {
return Err(ConsumerError::CoordinatorUnavailable);
}
}
Err(e) => return Err(e),
}
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff, retry.max_backoff);
}
}
pub(crate) struct CoordinatorState {
pub client: Client,
pub group_id: String,
pub coordinator_id: Arc<AtomicI32>,
pub member_id: String,
pub published_member_id: tokio::sync::watch::Sender<String>,
pub commit_identity: Arc<Mutex<CommitIdentity>>,
pub group_instance_id: Option<String>,
pub generation_id: i32,
pub current_generation: Arc<AtomicI32>,
pub assignors: Vec<Assignor>,
pub rebalance_protocol: RebalanceProtocol,
pub subscription: crate::subscription::SharedSubscription,
pub subscription_changes: tokio::sync::watch::Receiver<crate::subscription::Subscription>,
pub joined_topics: Vec<String>,
pub seen_unsubscribes: u64,
pub group_protocol: crate::GroupProtocol,
pub server_assignor: Option<String>,
pub member_epoch: i32,
pub sent_heartbeat_fields: crate::consumer_group::SentFields,
pub assigned: Arc<Mutex<Vec<(String, i32)>>>,
pub assignment_changed: Arc<Notify>,
pub next_ownership_id: u64,
pub next_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
pub end_offsets: Arc<Mutex<HashMap<(String, i32), i64>>>,
pub positions: Arc<Mutex<HashMap<(String, i32), crate::position::PartitionPosition>>>,
pub topic_ids: Arc<Mutex<HashMap<String, WireUuid>>>,
pub session_timeout: Time,
pub max_poll_interval: Time,
pub heartbeat_interval: Time,
pub subscription_metadata_refresh_interval: Time,
pub leave_group_timeout: Time,
pub auto_offset_reset: AutoOffsetReset,
pub client_rack: Option<String>,
pub initial_subscribed_counts: HashMap<String, i32>,
pub retry_policy: CoordinatorRetryPolicy,
pub poll_error: PollErrorSlot,
pub auto_commit: Option<crate::commit::AutoCommit>,
pub commit_serialization: Arc<Mutex<()>>,
pub join_prepared: bool,
pub polls: tokio::sync::watch::Receiver<u64>,
pub rebalance_pending: tokio::sync::watch::Sender<bool>,
pub close_operation: tokio::sync::watch::Receiver<crate::control::CloseRequest>,
pub rejoin_reason: String,
pub listener_calls: Option<crate::rebalance_listener::ListenerCalls>,
pub enforced_rebalances: tokio::sync::mpsc::UnboundedReceiver<(String, u64)>,
pub lost_partitions: Vec<(String, i32)>,
pub assigned_callback_pending: crate::rebalance_listener::AssignedCallbackPending,
pub poll_timer: PollTimer,
pub poll_timeout_in_callback: bool,
}
async fn remember_lost_partitions(state: &mut CoordinatorState) {
let owned = state.assigned.lock().await.clone();
for partition in owned {
if !state.lost_partitions.contains(&partition) {
state.lost_partitions.push(partition);
}
}
}
pub(crate) async fn call_listener_kind(
state: &mut CoordinatorState,
kind: crate::rebalance_listener::ListenerCallKind,
partitions: Vec<(String, i32)>,
) -> Result<(), ConsumerError> {
call_listener(state, kind, partitions).await
}
pub(crate) async fn prime_added_offsets(
state: &CoordinatorState,
partitions: &[(String, i32)],
) -> Result<(), ConsumerError> {
prime_offsets(state, partitions).await
}
pub(crate) async fn publish_group_assignment(
state: &mut CoordinatorState,
assignment: &[(String, i32)],
member_epoch: i32,
) {
install_assignment(state, assignment, true, member_epoch, false).await;
}
pub(crate) async fn forget_member_keeping_id(state: &mut CoordinatorState) {
state.rebalance_pending.send_replace(true);
remember_lost_partitions(state).await;
install_assignment(state, &[], false, JOIN_GROUP_EPOCH, false).await;
}
pub(crate) async fn publish_member_epoch(state: &mut CoordinatorState) {
let epoch = state.member_epoch;
let mut identity = state.commit_identity.lock().await;
identity.generation = epoch;
identity.member_id.clone_from(&state.member_id);
drop(identity);
if *state.published_member_id.borrow() != state.member_id {
state
.published_member_id
.send_replace(state.member_id.clone());
}
set_generation(state, epoch);
}
pub(crate) async fn call_lost_listener(state: &mut CoordinatorState) -> Result<(), ConsumerError> {
let lost = std::mem::take(&mut state.lost_partitions);
if lost.is_empty() {
return Ok(());
}
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Lost,
lost,
)
.await
}
pub(crate) async fn release_partitions_after_poll_timeout(state: &mut CoordinatorState) {
state.rebalance_pending.send_replace(true);
remember_lost_partitions(state).await;
install_assignment(state, &[], false, JOIN_GROUP_EPOCH, true).await;
}
pub(crate) fn report_fatal_error(slot: &PollErrorSlot, error: ConsumerError) {
*slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(error);
}
const JOIN_GROUP_EPOCH: i32 = 0;
pub(crate) async fn find_coordinator_again(state: &CoordinatorState) {
match find_coordinator(&state.client, &state.group_id, state.retry_policy).await {
Ok(id) => state.coordinator_id.store(id, Ordering::Relaxed),
Err(error) => {
tracing::warn!(group = %state.group_id, %error, "coordinator lookup failed");
}
}
}
async fn call_listener(
state: &mut CoordinatorState,
kind: crate::rebalance_listener::ListenerCallKind,
partitions: Vec<(String, i32)>,
) -> Result<(), ConsumerError> {
loop {
let Some(calls) = &state.listener_calls else {
return Ok(());
};
let (done, ran) = tokio::sync::oneshot::channel();
if calls
.send(crate::rebalance_listener::ListenerCall {
kind,
partitions: partitions.clone(),
done,
})
.is_err()
{
return Ok(());
}
let (ran_in_time, heartbeat) = {
let state = &*state;
let call_done = CancellationToken::new();
let wait = async {
let ran_in_time = wait_for_listener_call(state, ran).await;
call_done.cancel();
ran_in_time
};
tokio::join!(wait, heartbeat_during_join_prepare(state, &call_done))
};
let resend = match ran_in_time {
CallWait::Ran => {
state.poll_timer.reset();
false
}
CallWait::Dropped(deadline) => {
state.poll_timer.deadline = deadline;
true
}
CallWait::PollTimeout => {
state.poll_timeout_in_callback = true;
return Err(ConsumerError::RebalanceFailed(
"max_poll_interval expired while a rebalance listener callback waited".into(),
));
}
};
match heartbeat {
Some(HeartbeatOutcome::Fenced) => {
return Err(ConsumerError::FencedInstanceId(
state.group_instance_id.clone().unwrap_or_default(),
));
}
Some(HeartbeatOutcome::RejoinFromScratch) => {
forget_member(state).await;
state.rejoin_reason = heartbeat_rejoin_reason(UNKNOWN_MEMBER_ID);
return Ok(());
}
_ if resend => {}
_ => return Ok(()),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CallWait {
Ran,
Dropped(tokio::time::Instant),
PollTimeout,
}
async fn wait_for_listener_call(
state: &CoordinatorState,
ran: tokio::sync::oneshot::Receiver<()>,
) -> CallWait {
let mut ran = ran;
let mut deadline = state.poll_timer.deadline;
let mut polls = *state.polls.borrow();
loop {
tokio::select! {
biased;
result = &mut ran => {
return if result.is_ok() {
CallWait::Ran
} else {
CallWait::Dropped(deadline)
};
}
() = tokio::time::sleep_until(deadline) => {
let now_polls = *state.polls.borrow();
if now_polls == polls {
return CallWait::PollTimeout;
}
polls = now_polls;
deadline = tokio::time::Instant::now() + state.poll_timer.interval;
}
}
}
}
async fn join_prepare(state: &mut CoordinatorState) -> Result<(), ConsumerError> {
if state.join_prepared {
return Ok(());
}
commit_before_join(state).await?;
state.join_prepared = true;
if state.member_id.is_empty() || state.generation_id < 0 {
let lost = std::mem::take(&mut state.lost_partitions);
if !lost.is_empty() {
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Lost,
lost,
)
.await?;
}
} else if state.rebalance_protocol == RebalanceProtocol::Eager {
let owned = state.assigned.lock().await.clone();
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Revoked,
owned,
)
.await?;
} else {
let owned = state.assigned.lock().await.clone();
let (kept, revoked): (Vec<_>, Vec<_>) = {
let subscription = state.subscription.borrow();
owned
.into_iter()
.partition(|(topic, _)| subscription.contains(topic))
};
if !revoked.is_empty() {
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Revoked,
revoked,
)
.await?;
let generation = state.generation_id;
publish_assignment(state, &kept, true, generation).await;
}
}
Ok(())
}
pub(crate) type PollErrorSlot = Arc<std::sync::Mutex<Option<ConsumerError>>>;
pub(crate) type PollSignal = tokio::sync::watch::Sender<u64>;
pub(crate) fn note_poll(signal: &PollSignal) {
signal.send_modify(|polls| *polls = polls.wrapping_add(1));
}
pub(crate) fn poll_error_pending(slot: &PollErrorSlot) -> bool {
slot.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
}
async fn wait_for_poll_after_error(
polls: &mut tokio::sync::watch::Receiver<u64>,
poll_error: &PollErrorSlot,
shutdown: &CancellationToken,
) -> bool {
polls.borrow_and_update();
loop {
tokio::select! {
() = shutdown.cancelled() => return false,
changed = polls.changed() => {
if changed.is_err() {
return false;
}
if !poll_error_pending(poll_error) {
return true;
}
}
}
}
}
pub(crate) fn report_rejoin_error(slot: &PollErrorSlot, error: ConsumerError) {
if error.is_fatal_offset_fetch_error()
|| matches!(&error, ConsumerError::Client(client) if client.is_authentication_failure())
{
*slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(error);
}
}
pub(crate) fn take_poll_error(slot: &PollErrorSlot) -> Option<ConsumerError> {
slot.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
fn set_generation(state: &mut CoordinatorState, generation_id: i32) {
state.generation_id = generation_id;
state
.current_generation
.store(generation_id, Ordering::Release);
}
async fn install_generation(state: &mut CoordinatorState, generation_id: i32) {
let mut identity = state.commit_identity.lock().await;
identity.generation = generation_id;
identity.member_id.clone_from(&state.member_id);
drop(identity);
set_generation(state, generation_id);
}
pub(crate) fn assigned_callback_gate(
state: &CoordinatorState,
partitions: &[(String, i32)],
) -> Option<crate::rebalance_listener::AssignedCallbackGate> {
state.listener_calls.as_ref().map(|_| {
crate::rebalance_listener::AssignedCallbackGate::new(
&state.assigned_callback_pending,
partitions,
)
})
}
async fn publish_assignment(
state: &mut CoordinatorState,
assignment: &[(String, i32)],
preserve_retained: bool,
generation_id: i32,
) {
install_assignment(state, assignment, preserve_retained, generation_id, false).await;
}
async fn install_assignment(
state: &mut CoordinatorState,
assignment: &[(String, i32)],
preserve_retained: bool,
generation_id: i32,
rejoin_on_poll: bool,
) {
let mut next_id = state.next_ownership_id;
let mut assigned = state.assigned.lock().await;
let mut identity = state.commit_identity.lock().await;
update_ownership(
&mut identity.ownership_ids,
assignment,
preserve_retained,
&mut next_id,
);
assigned.clear();
assigned.extend_from_slice(assignment);
identity.generation = generation_id;
identity.member_id.clone_from(&state.member_id);
identity.rejoin_on_poll = rejoin_on_poll;
drop(identity);
state
.published_member_id
.send_replace(state.member_id.clone());
drop(assigned);
state.end_offsets.lock().await.clear();
state.next_ownership_id = next_id;
set_generation(state, generation_id);
state.assignment_changed.notify_waiters();
}
fn update_ownership(
ownership: &mut HashMap<(String, i32), u64>,
assignment: &[(String, i32)],
preserve_retained: bool,
next_id: &mut u64,
) {
if preserve_retained {
let assigned = assignment.iter().collect::<HashSet<_>>();
ownership.retain(|partition, _| assigned.contains(partition));
} else {
ownership.clear();
}
for partition in assignment {
ownership.entry(partition.clone()).or_insert_with(|| {
let id = *next_id;
*next_id = next_id.wrapping_add(1);
id
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HeartbeatOutcome {
Ok,
NeedRejoin(i16),
RejoinFromScratch,
Fenced,
Transient,
}
fn heartbeat_outcome(error_code: i16) -> HeartbeatOutcome {
match error_code {
0 => HeartbeatOutcome::Ok,
27 | 22 => HeartbeatOutcome::NeedRejoin(error_code),
25 => HeartbeatOutcome::RejoinFromScratch,
FENCED_INSTANCE_ID => HeartbeatOutcome::Fenced,
_ => HeartbeatOutcome::Transient,
}
}
#[cfg_attr(test, mutants::skip)] pub(crate) fn subscription_metadata_refresh_due(
last_check: tokio::time::Instant,
interval: Time,
) -> bool {
last_check.elapsed().as_time() >= interval
}
#[tracing::instrument(
name = "consumer.subscribed_partition_counts",
level = "debug",
skip_all,
fields(group_id = %state.group_id, topics = tracing::field::Empty),
err
)]
async fn subscribed_partition_counts(
state: &CoordinatorState,
) -> Result<HashMap<String, i32>, ConsumerError> {
let md = state.client.refresh_metadata().await?;
let watched = state.client.metadata_topics().names();
let mut counts = HashMap::new();
for t in &md.topics {
let Some(name) = &t.name else { continue };
if watched.binary_search(name).is_ok() {
counts.insert(
name.clone(),
i32::try_from(t.partitions.len()).unwrap_or(i32::MAX),
);
}
}
tracing::Span::current().record("topics", counts.len());
Ok(counts)
}
fn subscribed_topics_grew(known: &HashMap<String, i32>, current: &HashMap<String, i32>) -> bool {
current
.iter()
.any(|(topic, count)| *count > known.get(topic).copied().unwrap_or(0))
}
fn merge_counts(known: &mut HashMap<String, i32>, current: &HashMap<String, i32>) {
for (topic, &count) in current {
let entry = known.entry(topic.clone()).or_insert(0);
*entry = (*entry).max(count);
}
}
pub(crate) async fn run(mut state: CoordinatorState, shutdown: CancellationToken) {
if let Err(e) = state.client.refresh_metadata().await {
tracing::warn!(error = %e, "coordinator client metadata refresh failed at startup");
}
let mut ticker = tokio::time::interval(state.heartbeat_interval.to_std());
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut rejoin = RejoinRequest::default();
let mut known_counts = std::mem::take(&mut state.initial_subscribed_counts);
let mut last_meta_check = tokio::time::Instant::now();
state.poll_timer = PollTimer::new(state.max_poll_interval);
let mut polls_open = true;
loop {
let event = tokio::select! {
biased;
() = shutdown.cancelled() => break,
_ = ticker.tick() => TaskEvent::Tick,
changed = state.polls.changed(), if polls_open => {
if changed.is_ok() {
TaskEvent::Poll
} else {
polls_open = false;
continue;
}
}
Some((reason, polls)) = state.enforced_rebalances.recv() => {
state.rejoin_reason = reason;
rejoin.request_after_poll(polls, &state.rebalance_pending);
continue;
}
Ok(()) = state.subscription_changes.changed() => {
handle_subscription_change(&mut state, &mut rejoin, &mut known_counts).await;
continue;
}
() = tokio::time::sleep_until(state.poll_timer.deadline), if !state.member_id.is_empty() => {
TaskEvent::PollTimeout
}
};
if event == TaskEvent::Poll {
state.poll_timer.reset();
if !rejoin.due(&state.polls) {
continue;
}
} else if state.polls.has_changed().unwrap_or(false) {
state.polls.borrow_and_update();
state.poll_timer.reset();
}
if event != TaskEvent::Poll && !state.member_id.is_empty() && state.poll_timer.expired() {
state.rejoin_reason = "consumer pro-actively leaving the group".into();
rejoin.request_after_next_poll(&state);
leave_on_poll_timeout(&mut state).await;
continue;
}
if !rejoin.requested()
&& subscription_metadata_refresh_due(
last_meta_check,
state.subscription_metadata_refresh_interval,
)
{
last_meta_check = tokio::time::Instant::now();
if refresh_pattern_topics(&mut state).await
&& state.subscription.borrow().topics != state.joined_topics
{
let topics = state.subscription.borrow().topics.clone();
state.rejoin_reason =
crate::subscription::subscription_changed_reason(&state.joined_topics, &topics);
rejoin.request_after_next_poll(&state);
} else if let Ok(current) = subscribed_partition_counts(&state).await
&& subscribed_topics_grew(&known_counts, ¤t)
{
tracing::info!(
group = %state.group_id,
"subscribed-topic partitions changed; rejoining to update assignment"
);
state.rejoin_reason = "cached metadata has changed".into();
rejoin.request_after_next_poll(&state);
}
}
if rejoin.due(&state.polls) {
tokio::select! {
() = shutdown.cancelled() => break,
result = rejoin_group(&mut state) => match result {
Ok(snapshot) => {
rejoin.complete(&state);
state.poll_timer.reset();
merge_counts(&mut known_counts, &snapshot);
}
Err(ConsumerError::FencedInstanceId(group_instance_id)) => {
fence_member(&mut state, group_instance_id).await;
if !wait_for_poll_after_error(&mut state.polls, &state.poll_error, &shutdown).await {
break;
}
state.poll_timer.reset();
state.rejoin_reason = rebalance_failure_reason(&ConsumerError::FencedInstanceId(String::new()));
rejoin.request_now(&state);
}
Err(_) if std::mem::take(&mut state.poll_timeout_in_callback) => {
state.rejoin_reason = "consumer pro-actively leaving the group".into();
rejoin = RejoinRequest::None;
rejoin.request_after_next_poll(&state);
leave_on_poll_timeout(&mut state).await;
}
Err(e) => {
tracing::warn!(error = %e, "rejoin failed; will retry on next tick");
state.rejoin_reason = rebalance_failure_reason(&e);
report_rejoin_error(&state.poll_error, e);
}
},
}
} else if event == TaskEvent::Tick && !state.member_id.is_empty() {
tokio::select! {
() = shutdown.cancelled() => break,
outcome = heartbeat_once(&state) => match outcome {
HeartbeatOutcome::Ok | HeartbeatOutcome::Transient => {}
HeartbeatOutcome::NeedRejoin(error_code) => {
state.rejoin_reason = heartbeat_rejoin_reason(error_code);
rejoin.request_after_next_poll(&state);
}
HeartbeatOutcome::RejoinFromScratch => {
forget_member(&mut state).await;
state.rejoin_reason = heartbeat_rejoin_reason(UNKNOWN_MEMBER_ID);
rejoin.request_after_next_poll(&state);
}
HeartbeatOutcome::Fenced => {
let group_instance_id = state.group_instance_id.clone().unwrap_or_default();
fence_member(&mut state, group_instance_id).await;
if !wait_for_poll_after_error(&mut state.polls, &state.poll_error, &shutdown).await {
break;
}
state.poll_timer.reset();
state.rejoin_reason = heartbeat_rejoin_reason(FENCED_INSTANCE_ID);
rejoin.request_now(&state);
}
},
}
}
}
let close = *state.close_operation.borrow();
leave_group(
&state,
&state.member_id,
close.operation,
CLOSE_LEAVE_REASON,
)
.await;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TaskEvent {
Tick,
Poll,
PollTimeout,
}
pub(crate) struct PollTimer {
interval: Duration,
deadline: tokio::time::Instant,
}
impl PollTimer {
pub(crate) fn new(interval: Time) -> Self {
let interval = interval.to_std();
Self {
interval,
deadline: tokio::time::Instant::now() + interval,
}
}
pub(crate) fn reset(&mut self) {
self.deadline = tokio::time::Instant::now() + self.interval;
}
pub(crate) fn deadline(&self) -> tokio::time::Instant {
self.deadline
}
fn expired(&self) -> bool {
tokio::time::Instant::now() >= self.deadline
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum RejoinRequest {
#[default]
None,
AfterPoll(u64),
Now,
}
impl RejoinRequest {
fn requested(self) -> bool {
self != Self::None
}
fn request_after_next_poll(&mut self, state: &CoordinatorState) {
self.request_after_poll(*state.polls.borrow(), &state.rebalance_pending);
}
fn request_after_poll(
&mut self,
polls: u64,
rebalance_pending: &tokio::sync::watch::Sender<bool>,
) {
if *self == Self::None {
*self = Self::AfterPoll(polls);
}
rebalance_pending.send_replace(true);
}
fn request_now(&mut self, state: &CoordinatorState) {
*self = Self::Now;
state.rebalance_pending.send_replace(true);
}
fn due(self, polls: &tokio::sync::watch::Receiver<u64>) -> bool {
match self {
Self::None => false,
Self::Now => true,
Self::AfterPoll(count) => *polls.borrow() != count,
}
}
fn complete(&mut self, state: &CoordinatorState) {
*self = Self::None;
state.rebalance_pending.send_replace(false);
}
}
const CLOSE_LEAVE_REASON: &str = "the consumer is being closed";
const POLL_TIMEOUT_LEAVE_REASON: &str = "consumer poll timeout has expired.";
async fn leave_on_poll_timeout(state: &mut CoordinatorState) {
tracing::warn!(
group = %state.group_id,
max_poll_interval = ?state.max_poll_interval,
"consumer poll timeout has expired: the time between two poll calls was longer than \
max_poll_interval; the member leaves the group and joins again on the next poll"
);
let member_id = std::mem::take(&mut state.member_id);
state.rebalance_pending.send_replace(true);
remember_lost_partitions(state).await;
install_assignment(state, &[], false, -1, true).await;
if should_send_leave_group(
&member_id,
state.group_instance_id.as_deref(),
GroupMembershipOperation::Default,
) {
leave_group(
state,
&member_id,
GroupMembershipOperation::Default,
POLL_TIMEOUT_LEAVE_REASON,
)
.await;
}
}
async fn handle_subscription_change(
state: &mut CoordinatorState,
rejoin: &mut RejoinRequest,
known_counts: &mut HashMap<String, i32>,
) {
let subscription = state.subscription_changes.borrow_and_update().clone();
if subscription.unsubscribes != state.seen_unsubscribes {
state.seen_unsubscribes = subscription.unsubscribes;
let member_id = std::mem::take(&mut state.member_id);
state.lost_partitions.clear();
if !subscription.manual_assignment {
install_assignment(state, &[], false, -1, false).await;
}
*rejoin = RejoinRequest::None;
state.rebalance_pending.send_replace(false);
state.rejoin_reason = "consumer pro-actively leaving the group".into();
leave_group(
state,
&member_id,
GroupMembershipOperation::Default,
UNSUBSCRIBE_LEAVE_REASON,
)
.await;
}
if subscription.is_none() {
return;
}
refresh_pattern_topics(state).await;
let topics = state.subscription.borrow().topics.clone();
state.client.metadata_topics().set(topics.iter().cloned());
if topics != state.joined_topics {
state.rejoin_reason =
crate::subscription::subscription_changed_reason(&state.joined_topics, &topics);
rejoin.request_after_next_poll(state);
if let Ok(counts) = subscribed_partition_counts(state).await {
*known_counts = counts;
}
} else if state.member_id.is_empty() {
rejoin.request_after_next_poll(state);
}
}
const UNSUBSCRIBE_LEAVE_REASON: &str = "the consumer unsubscribed from all topics";
pub(crate) async fn refresh_pattern_topics(state: &mut CoordinatorState) -> bool {
let (pattern, version) = {
let subscription = state.subscription.borrow();
let Some(pattern) = subscription.pattern.clone() else {
return false;
};
(pattern, subscription.version)
};
let Ok(metadata) = state
.client
.refresh_metadata_with(krabka_client_core::all_topics_request())
.await
else {
return false;
};
let matched = crate::subscription::Subscription {
pattern: Some(pattern),
..crate::subscription::Subscription::topics(
Vec::new(),
state.subscription.borrow().exclude_internal_topics,
)
}
.matching_topics(&metadata)
.unwrap_or_default();
let stored = state
.subscription
.send_if_modified(|subscription| subscription.store_pattern_topics(version, &matched));
if state.subscription.borrow().version == version {
state.client.metadata_topics().set(matched.iter().cloned());
if stored {
state.subscription_changes.borrow_and_update();
}
}
stored
}
async fn forget_member(state: &mut CoordinatorState) {
state.member_id.clear();
state.rebalance_pending.send_replace(true);
remember_lost_partitions(state).await;
install_assignment(state, &[], false, -1, false).await;
}
async fn fence_member(state: &mut CoordinatorState, group_instance_id: String) {
tracing::error!(
group = %state.group_id,
group_instance_id = %group_instance_id,
"another consumer joined with the same group.instance.id; the member joins again on the next poll"
);
state.member_id.clear();
state.rebalance_pending.send_replace(true);
remember_lost_partitions(state).await;
install_assignment(state, &[], false, -1, true).await;
*state
.poll_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(ConsumerError::FencedInstanceId(group_instance_id));
}
fn group_response_error(error_code: i16, group_instance_id: Option<&str>) -> ConsumerError {
if error_code == FENCED_INSTANCE_ID {
ConsumerError::FencedInstanceId(group_instance_id.unwrap_or_default().to_string())
} else {
ConsumerError::Server(error_code)
}
}
pub(crate) fn truncate_reason(reason: &str) -> &str {
reason
.char_indices()
.nth(255)
.map_or(reason, |(end, _)| &reason[..end])
}
pub(crate) fn should_send_leave_group(
member_id: &str,
group_instance_id: Option<&str>,
operation: GroupMembershipOperation,
) -> bool {
!member_id.is_empty()
&& match operation {
GroupMembershipOperation::LeaveGroup => true,
GroupMembershipOperation::RemainInGroup => false,
GroupMembershipOperation::Default => group_instance_id.is_none(),
}
}
#[cfg_attr(test, mutants::skip)] #[tracing::instrument(
name = "consumer.leave_group",
level = "info",
skip_all,
fields(group_id = %state.group_id, member_id = %state.member_id)
)]
async fn leave_group(
state: &CoordinatorState,
member_id: &str,
operation: GroupMembershipOperation,
reason: &str,
) {
if !should_send_leave_group(member_id, state.group_instance_id.as_deref(), operation) {
return;
}
let coordinator = state
.client
.broker(state.coordinator_id.load(Ordering::Relaxed));
let send = coordinator.send(build_leave_group_request(
state.group_id.clone(),
member_id.to_owned(),
Some(reason),
));
let _ = tokio::time::timeout(state.leave_group_timeout.to_std(), send).await;
}
#[tracing::instrument(
name = "consumer.heartbeat",
level = "debug",
skip_all,
fields(
group_id = %state.group_id,
member_id = %state.member_id,
generation = state.generation_id,
)
)]
pub(crate) async fn heartbeat_once(state: &CoordinatorState) -> HeartbeatOutcome {
let result = send_heartbeat(state).await;
heartbeat_result_outcome(state, result).await
}
fn send_heartbeat(
state: &CoordinatorState,
) -> impl std::future::Future<
Output = Result<
krabka_protocol::owned::heartbeat_response::HeartbeatResponse,
krabka_client_core::ClientError,
>,
> + Send
+ 'static {
let client = state.client.clone();
let coordinator = state.coordinator_id.load(Ordering::Relaxed);
let request = build_heartbeat_request(
state.group_id.clone(),
state.generation_id,
state.member_id.clone(),
state.group_instance_id.clone(),
);
async move { client.broker(coordinator).send(request).await }
}
async fn heartbeat_result_outcome(
state: &CoordinatorState,
result: Result<
krabka_protocol::owned::heartbeat_response::HeartbeatResponse,
krabka_client_core::ClientError,
>,
) -> HeartbeatOutcome {
match result {
Ok(r) => {
let outcome = heartbeat_outcome(r.error_code);
if matches!(outcome, HeartbeatOutcome::Transient) {
if is_retriable_coordinator_code(r.error_code) {
refind_after(state, "heartbeat").await;
} else {
tracing::warn!(error_code = r.error_code, "unexpected heartbeat error");
}
}
outcome
}
Err(e) if is_retriable_transport_error(&e) => {
state
.client
.evict_broker(state.coordinator_id.load(Ordering::Relaxed));
refind_after(state, "heartbeat").await;
HeartbeatOutcome::Transient
}
Err(e) => {
tracing::warn!(error = %e, "heartbeat send failed");
report_rejoin_error(&state.poll_error, ConsumerError::Client(e));
HeartbeatOutcome::Transient
}
}
}
#[cfg_attr(test, mutants::skip)] async fn refind_after(state: &CoordinatorState, ctx: &str) {
match find_coordinator(&state.client, &state.group_id, state.retry_policy).await {
Ok(id) => state.coordinator_id.store(id, Ordering::Relaxed),
Err(e) => {
tracing::warn!(error = %e, context = ctx, "coordinator re-discovery failed");
}
}
}
#[tracing::instrument(
name = "consumer.rejoin",
level = "info",
skip_all,
fields(
group_id = %state.group_id,
member_id = %state.member_id,
protocol = ?state.rebalance_protocol,
generation = tracing::field::Empty,
revoked = tracing::field::Empty,
added = tracing::field::Empty,
),
err
)]
async fn rejoin_group(state: &mut CoordinatorState) -> Result<HashMap<String, i32>, ConsumerError> {
let JoinOutcome {
owned,
assignment: new_assignment,
generation: new_generation,
topic_partitions,
} = join_and_sync(state).await?;
let old_set: HashSet<(String, i32)> = owned.iter().cloned().collect();
let new_set: HashSet<(String, i32)> = new_assignment.iter().cloned().collect();
let revoked: Vec<(String, i32)> = old_set.difference(&new_set).cloned().collect();
let added: Vec<(String, i32)> = new_set.difference(&old_set).cloned().collect();
let span = tracing::Span::current();
span.record("generation", new_generation);
span.record("revoked", revoked.len());
span.record("added", added.len());
let final_counts = match state.rebalance_protocol {
RebalanceProtocol::Eager => {
prime_offsets(state, &added).await?;
let _gate = assigned_callback_gate(state, &new_assignment);
publish_assignment(state, &new_assignment, false, new_generation).await;
{
let mut off = state.next_offsets.lock().await;
off.retain(|k, _| new_set.contains(k));
let mut pos = state.positions.lock().await;
pos.retain(|k, _| new_set.contains(k));
}
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Assigned,
new_assignment.clone(),
)
.await?;
topic_partitions
}
RebalanceProtocol::Cooperative => {
if revoked.is_empty() {
prime_offsets(state, &added).await?;
let _gate = assigned_callback_gate(state, &added);
publish_assignment(state, &new_assignment, true, new_generation).await;
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Assigned,
added.clone(),
)
.await?;
topic_partitions
} else {
install_generation(state, new_generation).await;
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Revoked,
revoked.clone(),
)
.await?;
prime_offsets(state, &added).await?;
let gate = assigned_callback_gate(state, &added);
publish_assignment(state, &new_assignment, true, new_generation).await;
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Assigned,
added.clone(),
)
.await?;
drop(gate);
{
let mut off = state.next_offsets.lock().await;
let mut pos = state.positions.lock().await;
for p in &revoked {
off.remove(p);
pos.remove(p);
}
}
state.rejoin_reason = "need to revoke partitions and re-join".into();
let JoinOutcome {
owned: owned_after_revoke,
assignment: assignment2,
generation: gen2,
topic_partitions: topic_partitions2,
} = join_and_sync(state).await?;
let owned_after_revoke_set: HashSet<(String, i32)> =
owned_after_revoke.iter().cloned().collect();
let added2: Vec<(String, i32)> = assignment2
.iter()
.filter(|p| !owned_after_revoke_set.contains(*p))
.cloned()
.collect();
let assignment2_set: HashSet<(String, i32)> = assignment2.iter().cloned().collect();
let revoked2: Vec<(String, i32)> = owned_after_revoke
.iter()
.filter(|p| !assignment2_set.contains(*p))
.cloned()
.collect();
if !revoked2.is_empty() {
install_generation(state, gen2).await;
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Revoked,
revoked2,
)
.await?;
}
prime_offsets(state, &added2).await?;
let _gate = assigned_callback_gate(state, &added2);
publish_assignment(state, &assignment2, true, gen2).await;
call_listener(
state,
crate::rebalance_listener::ListenerCallKind::Assigned,
added2,
)
.await?;
topic_partitions2
}
}
};
Ok(final_counts)
}
pub(crate) fn join_protocols(
assignors: &[Assignor],
topics: &[String],
owned: &[(String, i32)],
generation_id: i32,
rack_id: Option<&str>,
last_assignment: Option<&[(String, i32)]>,
) -> Vec<(String, Bytes)> {
assignors
.iter()
.map(|assignor| {
(
assignor.protocol_name().to_owned(),
encode_subscription(
topics,
owned,
generation_id,
rack_id,
crate::assignor::subscription_user_data(
*assignor,
last_assignment,
generation_id,
),
),
)
})
.collect()
}
#[derive(Clone, Debug)]
pub(crate) struct JoinRequestFields {
pub group_id: String,
pub group_instance_id: Option<String>,
pub session_timeout_ms: i32,
pub rebalance_timeout_ms: i32,
pub protocols: Vec<(String, Bytes)>,
pub reason: String,
}
impl JoinRequestFields {
pub(crate) fn request(&self, member_id: String) -> JoinGroupRequest {
JoinGroupRequest {
group_id: self.group_id.clone(),
protocol_type: "consumer".into(),
member_id,
group_instance_id: self.group_instance_id.clone(),
session_timeout_ms: self.session_timeout_ms,
rebalance_timeout_ms: self.rebalance_timeout_ms,
protocols: self
.protocols
.iter()
.map(|(name, metadata)| JoinGroupRequestProtocol {
name: name.clone(),
metadata: metadata.clone(),
..Default::default()
})
.collect(),
reason: Some(truncate_reason(&self.reason).to_owned()),
..Default::default()
}
}
}
pub(crate) fn build_sync_group_assignment(
member_id: String,
partitions: &[(String, i32)],
) -> SyncGroupRequestAssignment {
SyncGroupRequestAssignment {
member_id,
assignment: encode_assignment(partitions),
..Default::default()
}
}
fn build_sync_group_request(
group_id: String,
generation_id: i32,
member_id: String,
group_instance_id: Option<String>,
chosen_protocol: String,
assignments: Vec<SyncGroupRequestAssignment>,
) -> SyncGroupRequest {
SyncGroupRequest {
group_id,
generation_id,
member_id,
group_instance_id,
protocol_type: Some("consumer".into()),
protocol_name: Some(chosen_protocol),
assignments,
..Default::default()
}
}
#[tracing::instrument(
name = "consumer.commit_before_join",
level = "debug",
skip_all,
fields(group_id = %state.group_id, generation = state.generation_id)
)]
async fn commit_before_join(state: &mut CoordinatorState) -> Result<(), ConsumerError> {
let Some(auto_commit) = state.auto_commit.clone() else {
return Ok(());
};
let heartbeat = {
let state = &*state;
let commit_done = CancellationToken::new();
let stop_commit = CancellationToken::new();
let commit = async {
tokio::select! {
() = commit_consumed_before_join(state, &auto_commit) => {}
() = stop_commit.cancelled() => {}
}
commit_done.cancel();
};
let heartbeats = async {
let outcome = heartbeat_during_join_prepare(state, &commit_done).await;
stop_commit.cancel();
outcome
};
tokio::join!(commit, heartbeats).1
};
match heartbeat {
Some(HeartbeatOutcome::Fenced) => Err(ConsumerError::FencedInstanceId(
state.group_instance_id.clone().unwrap_or_default(),
)),
Some(HeartbeatOutcome::RejoinFromScratch) => {
tracing::warn!(
"the coordinator does not know the member; joining the group from scratch"
);
forget_member(state).await;
state.rejoin_reason = heartbeat_rejoin_reason(UNKNOWN_MEMBER_ID);
Ok(())
}
_ => Ok(()),
}
}
async fn heartbeat_during_join_prepare(
state: &CoordinatorState,
commit_done: &CancellationToken,
) -> Option<HeartbeatOutcome> {
if state.group_protocol == crate::GroupProtocol::Consumer {
crate::consumer_group::heartbeat_during_callback(state, commit_done).await;
return None;
}
let interval = state.heartbeat_interval.to_std();
let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + interval, interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
() = commit_done.cancelled() => return None,
_ = ticker.tick() => {}
}
let mut request = tokio::spawn(send_heartbeat(state));
let result = tokio::select! {
biased;
() = commit_done.cancelled() => return None,
result = &mut request => result,
};
let Ok(result) = result else {
continue;
};
let outcome = tokio::select! {
biased;
() = commit_done.cancelled() => return None,
outcome = heartbeat_result_outcome(state, result) => outcome,
};
match outcome {
HeartbeatOutcome::Ok
| HeartbeatOutcome::NeedRejoin(_)
| HeartbeatOutcome::Transient => {}
outcome @ (HeartbeatOutcome::RejoinFromScratch | HeartbeatOutcome::Fenced) => {
return Some(outcome);
}
}
}
}
async fn commit_consumed_before_join(
state: &CoordinatorState,
auto_commit: &crate::commit::AutoCommit,
) {
let deadline = tokio::time::Instant::now() + state.max_poll_interval.to_std();
let Some(_turn) = commit_turn(&state.commit_serialization, deadline).await else {
tracing::error!(
"auto commit before the rebalance timed out waiting for another commit; joining the group"
);
return;
};
loop {
let identity = state.commit_identity.lock().await.clone();
let offsets = auto_commit.polled_offsets(&identity.ownership_ids).await;
if offsets.is_empty() {
return;
}
let coordinator = state.coordinator_id.load(Ordering::Relaxed);
let broker = state.client.broker(coordinator);
let send = broker.send(build_commit_request(
state.group_id.clone(),
identity.generation,
identity.member_id,
state.group_instance_id.clone(),
build_commit_topics(crate::commit::position_commits(offsets)),
));
let Ok(result) = tokio::time::timeout_at(deadline, send).await else {
tracing::error!("auto commit before the rebalance timed out; joining the group");
return;
};
let result = result.map_err(ConsumerError::from);
match auto_commit_outcome(&result) {
AutoCommitOutcome::Committed => return,
AutoCommitOutcome::Failed => {
tracing::error!(
?result,
"auto commit before the rebalance failed; joining the group"
);
return;
}
AutoCommitOutcome::Retriable => {
let moved = match &result {
Ok(response) => names_moved_coordinator(response),
Err(_) => true,
};
if moved {
state.client.evict_broker(coordinator);
if tokio::time::timeout_at(deadline, refind_after(state, "auto commit"))
.await
.is_err()
{
tracing::error!(
?result,
"auto commit before the rebalance timed out; joining the group"
);
return;
}
}
let now = tokio::time::Instant::now();
if now >= deadline {
tracing::error!(
?result,
"auto commit before the rebalance timed out; joining the group"
);
return;
}
tokio::time::sleep(state.retry_policy.initial_backoff.min(deadline - now)).await;
}
}
}
}
async fn commit_turn(
commit_serialization: &Arc<Mutex<()>>,
deadline: tokio::time::Instant,
) -> Option<OwnedMutexGuard<()>> {
tokio::time::timeout_at(deadline, Arc::clone(commit_serialization).lock_owned())
.await
.ok()
}
struct JoinOutcome {
owned: Vec<(String, i32)>,
assignment: Vec<(String, i32)>,
generation: i32,
topic_partitions: HashMap<String, i32>,
}
async fn perform_join(
state: &mut CoordinatorState,
owned: &[(String, i32)],
) -> Result<JoinGroupResponse, ConsumerError> {
let topics = state.subscription.borrow().topics.clone();
state.joined_topics.clone_from(&topics);
let mut fields = JoinRequestFields {
group_id: state.group_id.clone(),
group_instance_id: state.group_instance_id.clone(),
session_timeout_ms: crate::consumer::protocol_millis_i32(state.session_timeout),
rebalance_timeout_ms: crate::consumer::protocol_millis_i32(state.max_poll_interval),
protocols: join_protocols(
&state.assignors,
&topics,
match state.rebalance_protocol {
RebalanceProtocol::Eager => &[],
RebalanceProtocol::Cooperative => owned,
},
state.generation_id,
state.client_rack.as_deref(),
(state.generation_id >= 0).then_some(owned),
),
reason: state.rejoin_reason.clone(),
};
let client = state.client.clone();
let group_id = state.group_id.clone();
let group_instance_id = state.group_instance_id.clone();
let coordinator_id = Arc::clone(&state.coordinator_id);
let r1 = with_coordinator_refind(
&client,
&group_id,
&coordinator_id,
state.retry_policy,
|r: &JoinGroupResponse| r.error_code,
|| {
let request = fields.request(state.member_id.clone());
let client = &client;
let target = coordinator_id.load(Ordering::Relaxed);
async move {
client
.broker(target)
.send(request)
.await
.map_err(ConsumerError::from)
}
},
)
.await?;
let join_resp = if r1.error_code == 0 {
r1
} else if r1.error_code == 79 {
let assigned_id = r1.member_id.clone();
if assigned_id.is_empty() {
return Err(ConsumerError::RebalanceFailed(
"broker did not assign a member_id".into(),
));
}
state.member_id.clone_from(&assigned_id);
state.rejoin_reason = format!("need to re-join with the given member-id: {assigned_id}");
fields.reason.clone_from(&state.rejoin_reason);
let r2 = with_coordinator_refind(
&client,
&group_id,
&coordinator_id,
state.retry_policy,
|r: &JoinGroupResponse| r.error_code,
|| {
let request = fields.request(assigned_id.clone());
let client = &client;
let target = coordinator_id.load(Ordering::Relaxed);
async move {
client
.broker(target)
.send(request)
.await
.map_err(ConsumerError::from)
}
},
)
.await?;
if r2.error_code != 0 {
return Err(group_response_error(
r2.error_code,
group_instance_id.as_deref(),
));
}
r2
} else {
return Err(group_response_error(
r1.error_code,
group_instance_id.as_deref(),
));
};
Ok(join_resp)
}
#[tracing::instrument(
name = "consumer.join_and_sync",
level = "info",
skip_all,
fields(
group_id = %state.group_id,
member_id = tracing::field::Empty,
generation = tracing::field::Empty,
is_leader = tracing::field::Empty,
protocol = tracing::field::Empty,
assigned_partitions = tracing::field::Empty,
),
err
)]
async fn join_and_sync(state: &mut CoordinatorState) -> Result<JoinOutcome, ConsumerError> {
join_prepare(state).await?;
let own_topics = state.subscription.borrow().topics.clone();
state.client.metadata_topics().set(own_topics);
let owned = state.assigned.lock().await.clone();
let join_resp = perform_join(state, &owned).await?;
if !join_resp.member_id.is_empty() {
state.member_id.clone_from(&join_resp.member_id);
}
let chosen_protocol = join_resp.protocol_name.clone().unwrap_or_default();
let generation_id = join_resp.generation_id;
let is_leader = join_resp.leader == state.member_id;
{
let span = tracing::Span::current();
span.record("member_id", state.member_id.as_str());
span.record("generation", generation_id);
span.record("is_leader", is_leader);
span.record("protocol", chosen_protocol.as_str());
}
let leader = compute_leader_assignment(state, &join_resp, is_leader).await?;
let my_assignment =
sync_assignment(state, generation_id, &chosen_protocol, leader.assignments).await?;
tracing::Span::current().record("assigned_partitions", my_assignment.len());
state.join_prepared = false;
state.rejoin_reason.clear();
if let Some(auto_commit) = &state.auto_commit {
auto_commit.restart_interval().await;
}
Ok(JoinOutcome {
owned,
assignment: my_assignment,
generation: generation_id,
topic_partitions: leader.topic_partitions,
})
}
pub(crate) fn leader_assignments(
assignors: &[Assignor],
response: &JoinGroupResponse,
metadata: &krabka_protocol::owned::metadata_response::MetadataResponse,
) -> Result<Vec<SyncGroupRequestAssignment>, ConsumerError> {
let name = response.protocol_name.as_deref().unwrap_or_default();
let assignor = Assignor::by_protocol_name(assignors, name).ok_or_else(|| {
ConsumerError::IllegalState(format!(
"Coordinator selected invalid assignment protocol: {name}"
))
})?;
let members: Vec<crate::assignor::GroupMember> = response
.members
.iter()
.map(|member| crate::assignor::GroupMember {
key: crate::assignor::MemberKey {
member_id: member.member_id.clone(),
group_instance_id: member.group_instance_id.clone(),
},
subscription: decode_subscription(&member.metadata),
})
.collect();
let all_topics: HashSet<&String> = members
.iter()
.flat_map(|member| &member.subscription.topics)
.collect();
let topic_partitions: HashMap<String, i32> = metadata
.topics
.iter()
.filter_map(|topic| {
let name = topic.name.as_ref()?;
all_topics.contains(name).then(|| {
(
name.clone(),
i32::try_from(topic.partitions.len()).unwrap_or(i32::MAX),
)
})
})
.collect();
let mut assignments: Vec<_> = crate::assignor::assign(assignor, &members, &topic_partitions)
.into_iter()
.collect();
assignments.sort();
Ok(assignments
.into_iter()
.map(|(member, partitions)| build_sync_group_assignment(member, &partitions))
.collect())
}
struct LeaderAssignment {
assignments: Vec<SyncGroupRequestAssignment>,
topic_partitions: HashMap<String, i32>,
}
pub(crate) fn group_subscription_topics(
response: &JoinGroupResponse,
own: &[String],
) -> Vec<String> {
response
.members
.iter()
.flat_map(|member| decode_subscription(&member.metadata).topics)
.chain(own.iter().cloned())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
async fn compute_leader_assignment(
state: &CoordinatorState,
response: &JoinGroupResponse,
is_leader: bool,
) -> Result<LeaderAssignment, ConsumerError> {
if !is_leader {
return Ok(LeaderAssignment {
assignments: Vec::new(),
topic_partitions: HashMap::new(),
});
}
let group_topics = group_subscription_topics(response, &state.subscription.borrow().topics);
state
.client
.metadata_topics()
.set(group_topics.iter().cloned());
let metadata = state.client.refresh_metadata().await?;
let mut topic_partitions = HashMap::new();
let mut resolved_ids = HashMap::new();
for topic in &metadata.topics {
let Some(name) = &topic.name else { continue };
if group_topics.binary_search(name).is_ok() {
topic_partitions.insert(
name.clone(),
i32::try_from(topic.partitions.len()).unwrap_or(i32::MAX),
);
resolved_ids.insert(name.clone(), topic.topic_id);
}
}
state.topic_ids.lock().await.extend(resolved_ids);
let assignments = leader_assignments(&state.assignors, response, &metadata)?;
Ok(LeaderAssignment {
assignments,
topic_partitions,
})
}
async fn sync_assignment(
state: &CoordinatorState,
generation_id: i32,
protocol: &str,
assignments: Vec<SyncGroupRequestAssignment>,
) -> Result<Vec<(String, i32)>, ConsumerError> {
let response = with_coordinator_refind(
&state.client,
&state.group_id,
&state.coordinator_id,
state.retry_policy,
|response: &SyncGroupResponse| response.error_code,
|| {
let request = build_sync_group_request(
state.group_id.clone(),
generation_id,
state.member_id.clone(),
state.group_instance_id.clone(),
protocol.to_string(),
assignments.clone(),
);
let target = state.coordinator_id.load(Ordering::Relaxed);
async move {
state
.client
.broker(target)
.send(request)
.await
.map_err(ConsumerError::from)
}
},
)
.await?;
if response.error_code != 0 {
return Err(group_response_error(
response.error_code,
state.group_instance_id.as_deref(),
));
}
Ok(decode_assignment(&response.assignment))
}
#[tracing::instrument(
name = "consumer.prime_offsets",
level = "debug",
skip_all,
fields(group_id = %state.group_id, partitions = partitions.len()),
err
)]
async fn prime_offsets(
state: &CoordinatorState,
partitions: &[(String, i32)],
) -> Result<(), ConsumerError> {
if partitions.is_empty() {
return Ok(());
}
let mut by_topic: HashMap<String, Vec<i32>> = HashMap::new();
for (t, p) in partitions {
by_topic.entry(t.clone()).or_default().push(*p);
}
let of = send_offset_fetch(
&state.client,
&state.group_id,
&state.coordinator_id,
&build_offset_fetch(&state.group_id, &by_topic),
state.retry_policy,
)
.await?;
let mut offsets = state.next_offsets.lock().await;
let mut positions = state.positions.lock().await;
let mut seen: HashSet<(String, i32)> = HashSet::new();
for (name, partition_index, committed, committed_epoch) in parse_offset_fetch(&of) {
let starting = starting_offset(committed, state.auto_offset_reset);
let key = (name, partition_index);
seen.insert(key.clone());
offsets.insert(key.clone(), starting);
positions.entry(key).or_default().offset_epoch = krabka_ids::LeaderEpoch(committed_epoch);
}
for tp in partitions {
if should_prime_missing_partition(seen.contains(tp)) {
let starting = reset_starting_offset(state.auto_offset_reset);
offsets.insert(tp.clone(), starting);
positions.entry(tp.clone()).or_default();
}
}
Ok(())
}
pub(crate) async fn send_offset_fetch(
client: &Client,
group_id: &str,
coordinator_id: &AtomicI32,
request: &TopicNameOffsetFetch,
retry: CoordinatorRetryPolicy,
) -> Result<OffsetFetchResponse, ConsumerError> {
let start = tokio::time::Instant::now();
let mut backoff = retry.initial_backoff;
loop {
let response = client
.broker(coordinator_id.load(Ordering::Relaxed))
.send(request.clone())
.await?;
let deadline_elapsed = retry_deadline_elapsed(start, retry.timeout);
match classify_offset_fetch(&response) {
OffsetFetchAction::Complete => return Ok(response),
OffsetFetchAction::RetryUnknownTopic(code) => {
if deadline_elapsed {
tracing::warn!(
error_code = code,
"offset fetch still names a topic the coordinator does not know; \
using the reset policy for those partitions"
);
return Ok(response);
}
tracing::debug!(
error_code = code,
"offset fetch names an unknown topic; retrying"
);
}
OffsetFetchAction::Retry(code) => {
if deadline_elapsed {
return Err(ConsumerError::Server(code));
}
tracing::debug!(error_code = code, "offset fetch failed; retrying");
}
OffsetFetchAction::FindCoordinator(code) => {
if deadline_elapsed {
return Err(ConsumerError::Server(code));
}
tracing::debug!(
error_code = code,
"offset fetch reached no coordinator; finding the coordinator again"
);
match find_coordinator(client, group_id, retry).await {
Ok(id) => coordinator_id.store(id, Ordering::Relaxed),
Err(error) => tracing::warn!(
error = %error,
"coordinator re-discovery failed; retrying with last-known id"
),
}
}
OffsetFetchAction::GroupAuthorizationFailed => {
return Err(ConsumerError::GroupAuthorizationFailed(
group_id.to_string(),
));
}
OffsetFetchAction::TopicAuthorizationFailed(topics) => {
return Err(ConsumerError::TopicAuthorizationFailed(topics));
}
OffsetFetchAction::Fatal(code) => return Err(ConsumerError::OffsetFetchFailed(code)),
}
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff, retry.max_backoff);
}
}
fn should_prime_missing_partition(seen: bool) -> bool {
!seen
}
#[cfg(test)]
mod retry_tests {
use std::{
collections::BTreeSet,
io,
net::SocketAddr,
sync::atomic::{AtomicUsize, Ordering},
};
use assert2::check;
use bytes::Buf;
use krabka_client_core::MockBroker;
use krabka_protocol::{
Decode, Encode, UnknownTaggedFields,
owned::{
api_versions_request,
api_versions_response::{ApiVersion, ApiVersionsResponse},
find_coordinator_request, heartbeat_request,
heartbeat_response::HeartbeatResponse,
join_group_request, leave_group_request, metadata_request,
metadata_response::{MetadataResponse, MetadataResponseBroker},
offset_fetch_request::{
self, OffsetFetchRequest, OffsetFetchRequestGroup, OffsetFetchRequestTopic,
OffsetFetchRequestTopics,
},
offset_fetch_response::{
OffsetFetchResponseGroup, OffsetFetchResponsePartition,
OffsetFetchResponsePartitions, OffsetFetchResponseTopic, OffsetFetchResponseTopics,
},
sync_group_request,
},
};
use krabka_units::{millis, minutes, secs};
use super::*;
const ORDERS: &str = "orders";
const PAYMENTS: &str = "payments";
fn api_versions_for_offset_fetch(offset_fetch_range: (i16, i16)) -> Vec<u8> {
let response = ApiVersionsResponse {
error_code: 0,
api_keys: vec![
ApiVersion {
api_key: api_versions_request::API_KEY,
min_version: 0,
max_version: 3,
..Default::default()
},
ApiVersion {
api_key: offset_fetch_request::API_KEY,
min_version: offset_fetch_range.0,
max_version: offset_fetch_range.1,
..Default::default()
},
ApiVersion {
api_key: find_coordinator_request::API_KEY,
min_version: 0,
max_version: 0,
..Default::default()
},
ApiVersion {
api_key: metadata_request::API_KEY,
min_version: 0,
max_version: 8,
..Default::default()
},
],
..Default::default()
};
let mut buffer = bytes::BytesMut::new();
response
.encode(&mut buffer, 0)
.expect("encode API versions");
buffer.to_vec()
}
struct Answer {
group_error: i16,
rows: Vec<(&'static str, i16, i64)>,
}
fn answer(group_error: i16, rows: &[(&'static str, i16, i64)]) -> Answer {
Answer {
group_error,
rows: rows.to_vec(),
}
}
fn offset_fetch_response(answer: &Answer, version: i16) -> Vec<u8> {
let partition = |error_code: i16, committed_offset: i64| OffsetFetchResponsePartitions {
partition_index: 0,
committed_offset,
committed_leader_epoch: -1,
error_code,
..Default::default()
};
let response = if version < 8 {
OffsetFetchResponse {
error_code: answer.group_error,
topics: answer
.rows
.iter()
.map(
|(topic, error_code, committed_offset)| OffsetFetchResponseTopic {
name: (*topic).into(),
partitions: vec![OffsetFetchResponsePartition {
partition_index: 0,
committed_offset: *committed_offset,
committed_leader_epoch: -1,
error_code: *error_code,
..Default::default()
}],
..Default::default()
},
)
.collect(),
..Default::default()
}
} else {
OffsetFetchResponse {
groups: vec![OffsetFetchResponseGroup {
group_id: "group-a".into(),
error_code: answer.group_error,
topics: answer
.rows
.iter()
.map(
|(topic, error_code, committed_offset)| OffsetFetchResponseTopics {
name: (*topic).into(),
partitions: vec![partition(*error_code, *committed_offset)],
..Default::default()
},
)
.collect(),
..Default::default()
}],
..Default::default()
}
};
let mut buffer = bytes::BytesMut::new();
if version >= offset_fetch_request::FLEXIBLE_MIN {
buffer.extend_from_slice(&[0]);
}
response
.encode(&mut buffer, version)
.expect("encode offset fetch");
buffer.to_vec()
}
#[derive(Debug, PartialEq, Eq)]
enum Outcome {
Offsets(Vec<(String, i32, i64, i32)>),
Server(i16),
GroupAuthorizationFailed(String),
TopicAuthorizationFailed(BTreeSet<String>),
OffsetFetchFailed(i16),
IncompatibleVersion((i16, i16), (i16, i16)),
}
fn outcome(result: Result<OffsetFetchResponse, ConsumerError>, name: &str) -> Outcome {
match result {
Ok(response) => Outcome::Offsets(parse_offset_fetch(&response)),
Err(ConsumerError::Server(code)) => Outcome::Server(code),
Err(ConsumerError::GroupAuthorizationFailed(group)) => {
Outcome::GroupAuthorizationFailed(group)
}
Err(ConsumerError::TopicAuthorizationFailed(topics)) => {
Outcome::TopicAuthorizationFailed(topics)
}
Err(ConsumerError::OffsetFetchFailed(code)) => Outcome::OffsetFetchFailed(code),
Err(ConsumerError::Client(krabka_client_core::ClientError::IncompatibleVersion {
api_key: offset_fetch_request::API_KEY,
broker_min,
broker_max,
client_min,
client_max,
})) => Outcome::IncompatibleVersion((broker_min, broker_max), (client_min, client_max)),
Err(error) => panic!("case {name}: unexpected error {error:?}"),
}
}
#[tokio::test]
async fn offset_fetch_error_codes_map_to_kafka_consumer_actions() {
for (name, answers, timeout, expected) in offset_fetch_cases() {
let (outcome, requests, find_coordinators) =
run_offset_fetch((1, 10), &["orders", "payments"], answers, timeout, name).await;
check!(
(outcome, requests.len(), find_coordinators) == expected,
"case {name}"
);
}
}
#[tokio::test]
async fn offset_fetch_names_topics_at_v9_or_lower_and_requires_stable_offsets() {
let by_name_before_v8 = |require_stable| OffsetFetchRequest {
group_id: "group-a".into(),
topics: Some(vec![OffsetFetchRequestTopic {
name: "orders".into(),
partition_indexes: vec![0],
..Default::default()
}]),
require_stable,
..Default::default()
};
let grouped = || OffsetFetchRequest {
groups: vec![OffsetFetchRequestGroup {
group_id: "group-a".into(),
member_id: None,
member_epoch: -1,
topics: Some(vec![OffsetFetchRequestTopics {
name: "orders".into(),
topic_id: WireUuid::ZERO,
partition_indexes: vec![0],
..Default::default()
}]),
..Default::default()
}],
require_stable: true,
..Default::default()
};
let committed = || Outcome::Offsets(vec![("orders".to_string(), 0, 42, -1)]);
let ok = || answer(0, &[(ORDERS, 0, 42)]);
for (name, offset_fetch_range, answers, expected) in [
(
"v6 has no require_stable field",
(1, 6),
vec![ok()],
(committed(), vec![(6, by_name_before_v8(false))]),
),
(
"v7 requires stable offsets",
(1, 7),
vec![ok()],
(committed(), vec![(7, by_name_before_v8(true))]),
),
(
"v9 names the topic",
(1, 9),
vec![ok()],
(committed(), vec![(9, grouped())]),
),
(
"a coordinator with v10 gets v9",
(1, 10),
vec![ok()],
(committed(), vec![(9, grouped())]),
),
(
"a coordinator with only v10 gets no request",
(10, 10),
vec![ok()],
(Outcome::IncompatibleVersion((10, 10), (1, 9)), Vec::new()),
),
(
"a pending transaction answers 88 and the consumer asks again",
(1, 10),
vec![answer(0, &[(ORDERS, 88, -1)]), ok()],
(committed(), vec![(9, grouped()), (9, grouped())]),
),
] {
let (outcome, requests, find_coordinators) = run_offset_fetch(
offset_fetch_range,
&["orders"],
answers,
Duration::from_secs(5),
name,
)
.await;
check!(
((outcome, requests), find_coordinators) == (expected, 0),
"case {name}"
);
}
}
type OffsetFetchCase = (&'static str, Vec<Answer>, Duration, (Outcome, usize, usize));
fn offset_fetch_cases() -> Vec<OffsetFetchCase> {
const LONG: Duration = Duration::from_secs(5);
const NOW: Duration = Duration::ZERO;
let committed = |orders: i64, payments: i64| {
Outcome::Offsets(vec![
("orders".to_string(), 0, orders, -1),
("payments".to_string(), 0, payments, -1),
])
};
let ok = || answer(0, &[(ORDERS, 0, 42), (PAYMENTS, 0, 7)]);
let partition_error = |orders: i16, payments: i16| {
answer(0, &[(ORDERS, orders, -1), (PAYMENTS, payments, -1)])
};
let topics = |names: &[&str]| {
Outcome::TopicAuthorizationFailed(names.iter().map(|n| (*n).to_string()).collect())
};
vec![
("no error", vec![ok()], LONG, (committed(42, 7), 1, 0)),
(
"coordinator load in progress retries on the same coordinator",
vec![answer(14, &[]), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"coordinator not available finds the coordinator and retries",
vec![answer(15, &[]), ok()],
LONG,
(committed(42, 7), 2, 1),
),
(
"not coordinator finds the coordinator and retries",
vec![answer(16, &[]), ok()],
LONG,
(committed(42, 7), 2, 1),
),
(
"another retriable group code retries",
vec![answer(7, &[]), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"a retriable group code past the deadline fails",
vec![answer(14, &[])],
NOW,
(Outcome::Server(14), 1, 0),
),
(
"not coordinator past the deadline fails",
vec![answer(16, &[])],
NOW,
(Outcome::Server(16), 1, 0),
),
(
"group authorization failed is fatal",
vec![answer(30, &[])],
LONG,
(
Outcome::GroupAuthorizationFailed("group-a".to_string()),
1,
0,
),
),
(
"unknown member id is fatal",
vec![answer(25, &[])],
LONG,
(Outcome::OffsetFetchFailed(25), 1, 0),
),
(
"stale member epoch is fatal without a member epoch",
vec![answer(113, &[])],
LONG,
(Outcome::OffsetFetchFailed(113), 1, 0),
),
(
"another group code is fatal",
vec![answer(69, &[])],
LONG,
(Outcome::OffsetFetchFailed(69), 1, 0),
),
(
"a group code hides the partition codes",
vec![answer(14, &[(ORDERS, 29, -1)]), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"unknown topic or partition then committed offsets",
vec![partition_error(3, 0), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"unknown topic id then committed offsets",
vec![partition_error(0, 100), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"unknown topic id past the deadline gives no committed offset",
vec![partition_error(100, 0)],
NOW,
(committed(-1, -1), 1, 0),
),
(
"topic authorization failed names every unauthorized topic",
vec![partition_error(29, 29)],
LONG,
(topics(&["orders", "payments"]), 1, 0),
),
(
"unstable offset commit retries",
vec![partition_error(88, 0), ok()],
LONG,
(committed(42, 7), 2, 0),
),
(
"unstable offset commit past the deadline fails",
vec![partition_error(88, 0)],
NOW,
(Outcome::Server(88), 1, 0),
),
(
"not leader or follower is fatal",
vec![partition_error(6, 0)],
LONG,
(Outcome::OffsetFetchFailed(6), 1, 0),
),
(
"topic authorization comes before unstable offsets",
vec![partition_error(88, 29)],
LONG,
(topics(&["payments"]), 1, 0),
),
(
"unstable offsets come before unknown topics",
vec![partition_error(100, 88)],
NOW,
(Outcome::Server(88), 1, 0),
),
(
"an unexpected partition code comes before unknown topics",
vec![partition_error(100, 6)],
LONG,
(Outcome::OffsetFetchFailed(6), 1, 0),
),
]
}
type SentOffsetFetches = Vec<(i16, OffsetFetchRequest)>;
async fn run_offset_fetch(
offset_fetch_range: (i16, i16),
topics: &[&str],
answers: Vec<Answer>,
timeout: Duration,
name: &str,
) -> (Outcome, SentOffsetFetches, usize) {
let offset_fetches = Arc::new(std::sync::Mutex::new(Vec::new()));
let find_coordinators = Arc::new(AtomicUsize::new(0));
let offset_fetches_in_mock = Arc::clone(&offset_fetches);
let find_coordinators_in_mock = Arc::clone(&find_coordinators);
let mock = MockBroker::start(move |api_key, version, _corr_id, mut body| {
let mut buffer = bytes::BytesMut::new();
match api_key {
api_versions_request::API_KEY => {
Some(api_versions_for_offset_fetch(offset_fetch_range))
}
offset_fetch_request::API_KEY => {
let client_id_len = body.get_i16();
body.advance(usize::try_from(client_id_len).expect("client id length"));
if version >= offset_fetch_request::FLEXIBLE_MIN {
body.advance(1);
}
let request = OffsetFetchRequest::decode(&mut body, version)
.expect("offset fetch request decodes");
let mut sent = offset_fetches_in_mock.lock().expect("requests lock");
let attempt = sent.len();
sent.push((version, request));
Some(offset_fetch_response(
&answers[attempt.min(answers.len() - 1)],
version,
))
}
find_coordinator_request::API_KEY => {
find_coordinators_in_mock.fetch_add(1, Ordering::SeqCst);
FindCoordinatorResponse::default()
.encode(&mut buffer, version)
.expect("encode find coordinator");
Some(buffer.to_vec())
}
metadata_request::API_KEY => {
MetadataResponse::default()
.encode(&mut buffer, version)
.expect("encode metadata");
Some(buffer.to_vec())
}
_ => None,
}
})
.await;
let client = Client::builder()
.bootstrap(mock.addr.to_string())
.build()
.await
.expect("client");
let request = build_offset_fetch(
"group-a",
&topics
.iter()
.map(|topic| ((*topic).to_string(), vec![0]))
.collect(),
);
let coordinator_id = AtomicI32::new(0);
let result = tokio::time::timeout(
Duration::from_secs(5),
send_offset_fetch(
&client,
"group-a",
&coordinator_id,
&request,
CoordinatorRetryPolicy {
timeout,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(1),
},
),
)
.await
.unwrap_or_else(|_| panic!("case {name}: offset fetch never finished"));
mock.stop();
let sent = offset_fetches.lock().expect("requests lock").clone();
(
outcome(result, name),
sent,
find_coordinators.load(Ordering::SeqCst),
)
}
#[test]
fn an_enforced_rebalance_is_due_after_a_poll_that_came_before_the_task_saw_it() {
let (pending, _pending_rx) = tokio::sync::watch::channel(false);
let (polls, polls_rx) = tokio::sync::watch::channel(5_u64);
let mut request = RejoinRequest::None;
polls.send_replace(6);
request.request_after_poll(5, &pending);
assert2::assert!((request.due(&polls_rx), *pending.borrow()) == (true, true));
}
#[test]
fn rejoin_errors_reach_poll_only_when_fatal() {
for (name, error, expected) in [
(
"group authorization failed",
ConsumerError::GroupAuthorizationFailed("group-a".into()),
true,
),
(
"topic authorization failed",
ConsumerError::TopicAuthorizationFailed(BTreeSet::from(["orders".to_string()])),
true,
),
(
"offset fetch failed",
ConsumerError::OffsetFetchFailed(6),
true,
),
(
"retriable code past the deadline",
ConsumerError::Server(14),
false,
),
(
"coordinator unavailable",
ConsumerError::CoordinatorUnavailable,
false,
),
] {
let slot = PollErrorSlot::default();
report_rejoin_error(&slot, error);
let first = take_poll_error(&slot).is_some();
let second = take_poll_error(&slot).is_some();
check!((first, second) == (expected, false), "case {name}");
}
}
fn retry(timeout: Duration) -> CoordinatorRetryPolicy {
CoordinatorRetryPolicy {
timeout,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(1),
}
}
struct Resp {
error_code: i16,
}
fn refused_connect_error() -> ConsumerError {
ConsumerError::Client(krabka_client_core::ClientError::Connect {
addr: SocketAddr::from(([127, 0, 0, 1], 9092)),
source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused"),
})
}
fn api_versions_for_leave_group() -> Vec<u8> {
let response = ApiVersionsResponse {
error_code: 0,
api_keys: vec![
ApiVersion {
api_key: api_versions_request::API_KEY,
min_version: 0,
max_version: 3,
..Default::default()
},
ApiVersion {
api_key: leave_group_request::API_KEY,
min_version: 0,
max_version: leave_group_request::MAX_VERSION,
..Default::default()
},
],
..Default::default()
};
let mut buffer = bytes::BytesMut::new();
response
.encode(&mut buffer, 0)
.expect("encode API versions");
buffer.to_vec()
}
#[tokio::test(start_paused = true)]
async fn subscription_metadata_refresh_due_uses_configured_inclusive_boundary() {
let last_check = tokio::time::Instant::now();
let interval = millis(37);
tokio::time::advance(Duration::from_millis(36)).await;
assert2::assert!(!subscription_metadata_refresh_due(last_check, interval));
tokio::time::advance(Duration::from_millis(1)).await;
assert2::assert!(subscription_metadata_refresh_due(last_check, interval));
}
fn test_state(client: Client) -> CoordinatorState {
CoordinatorState {
client,
group_id: "group-a".into(),
coordinator_id: Arc::new(AtomicI32::new(0)),
member_id: "member-a".into(),
published_member_id: tokio::sync::watch::Sender::new("member-a".to_owned()),
commit_identity: Arc::new(Mutex::new(CommitIdentity {
generation: 1,
member_id: "member-a".into(),
ownership_ids: HashMap::new(),
rejoin_on_poll: false,
})),
group_instance_id: None,
generation_id: 1,
current_generation: Arc::new(AtomicI32::new(1)),
assignors: vec![Assignor::Range],
rebalance_protocol: RebalanceProtocol::Eager,
subscription: crate::subscription::shared(vec!["topic".into()], None, true),
subscription_changes: crate::subscription::shared(Vec::new(), None, true).subscribe(),
joined_topics: Vec::new(),
seen_unsubscribes: 0,
group_protocol: crate::GroupProtocol::Classic,
server_assignor: None,
member_epoch: 0,
sent_heartbeat_fields: crate::consumer_group::SentFields::default(),
assigned: Arc::new(Mutex::new(Vec::new())),
assignment_changed: Arc::new(Notify::new()),
next_ownership_id: 1,
next_offsets: Arc::new(Mutex::new(HashMap::new())),
end_offsets: Arc::new(Mutex::new(HashMap::new())),
positions: Arc::new(Mutex::new(HashMap::new())),
topic_ids: Arc::new(Mutex::new(HashMap::new())),
session_timeout: secs(45),
max_poll_interval: minutes(1),
heartbeat_interval: secs(3),
subscription_metadata_refresh_interval: millis(37),
leave_group_timeout: millis(37),
auto_offset_reset: AutoOffsetReset::Latest,
client_rack: None,
initial_subscribed_counts: HashMap::new(),
retry_policy: retry(Duration::from_secs(30)),
poll_error: PollErrorSlot::default(),
auto_commit: None,
commit_serialization: Arc::new(Mutex::new(())),
join_prepared: false,
polls: PollSignal::default().subscribe(),
rebalance_pending: tokio::sync::watch::Sender::new(false),
close_operation: tokio::sync::watch::channel(crate::control::CloseRequest::default()).1,
rejoin_reason: String::new(),
listener_calls: None,
enforced_rebalances: tokio::sync::mpsc::unbounded_channel().1,
lost_partitions: Vec::new(),
assigned_callback_pending: Arc::default(),
poll_timer: PollTimer::new(secs(300)),
poll_timeout_in_callback: false,
}
}
#[tokio::test]
async fn a_subscription_change_rebaselines_the_partition_counts() {
use krabka_protocol::owned::metadata_response::{
MetadataResponsePartition, MetadataResponseTopic,
};
let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
if api_key == api_versions_request::API_KEY {
return Some(api_versions_for_leave_group());
}
if api_key != metadata_request::API_KEY {
return None;
}
let response = MetadataResponse {
topics: vec![MetadataResponseTopic {
name: Some(PAYMENTS.into()),
partitions: (0..2)
.map(|partition_index| MetadataResponsePartition {
partition_index,
..Default::default()
})
.collect(),
..Default::default()
}],
..Default::default()
};
let mut buffer = bytes::BytesMut::new();
response.encode(&mut buffer, version).expect("encode");
Some(buffer.to_vec())
})
.await;
let client = Client::builder()
.bootstrap(mock.addr.to_string())
.metadata_scope(krabka_client_core::MetadataScope::Topics {
allow_auto_topic_creation: false,
})
.build()
.await
.expect("client");
let mut state = test_state(client);
state.member_id = "member-a".into();
state.joined_topics = vec![ORDERS.to_owned()];
state.subscription = crate::subscription::shared(vec![PAYMENTS.to_owned()], None, true);
state.subscription_changes = state.subscription.subscribe();
state
.subscription
.send_modify(|subscription| subscription.version += 1);
let mut rejoin = RejoinRequest::None;
let mut known_counts = HashMap::new();
handle_subscription_change(&mut state, &mut rejoin, &mut known_counts).await;
mock.stop();
assert2::assert!(
(known_counts, rejoin.requested()) == (HashMap::from([(PAYMENTS.to_owned(), 2)]), true)
);
}
#[tokio::test]
async fn the_leader_assigns_with_metadata_for_the_whole_group_subscription() {
use krabka_protocol::owned::{
join_group_response::JoinGroupResponseMember,
metadata_request::MetadataRequest,
metadata_response::{MetadataResponsePartition, MetadataResponseTopic},
};
let requested = Arc::new(std::sync::Mutex::new(Vec::new()));
let requested_in_mock = Arc::clone(&requested);
let mock = MockBroker::start(move |api_key, version, _corr_id, mut body| {
if api_key == api_versions_request::API_KEY {
return Some(api_versions_for_leave_group());
}
if api_key != metadata_request::API_KEY {
return None;
}
let client_id_len = body.get_i16();
body.advance(usize::try_from(client_id_len.max(0)).expect("length"));
let request = MetadataRequest::decode(&mut body, version).expect("decode Metadata");
let names: Vec<String> = request
.topics
.unwrap_or_default()
.into_iter()
.filter_map(|topic| topic.name)
.collect();
let response = MetadataResponse {
topics: names
.iter()
.map(|name| MetadataResponseTopic {
name: Some(name.clone()),
partitions: vec![MetadataResponsePartition {
partition_index: 0,
..Default::default()
}],
..Default::default()
})
.collect(),
..Default::default()
};
requested_in_mock
.lock()
.expect("requested lock")
.push(names);
let mut buffer = bytes::BytesMut::new();
response
.encode(&mut buffer, version)
.expect("encode Metadata");
Some(buffer.to_vec())
})
.await;
let client = Client::builder()
.bootstrap(mock.addr.to_string())
.metadata_scope(krabka_client_core::MetadataScope::Topics {
allow_auto_topic_creation: false,
})
.build()
.await
.expect("client");
let mut state = test_state(client);
state.subscription = crate::subscription::shared(vec![ORDERS.into()], None, true);
let member = |member_id: &str, topic: &str| JoinGroupResponseMember {
member_id: member_id.into(),
metadata: crate::builder::encode_subscription(&[topic.to_owned()], &[], -1, None, None),
..Default::default()
};
let response = JoinGroupResponse {
protocol_name: Some("range".into()),
leader: "member-a".into(),
member_id: "member-a".into(),
members: vec![member("member-a", ORDERS), member("member-b", PAYMENTS)],
..Default::default()
};
let leader = compute_leader_assignment(&state, &response, true)
.await
.expect("leader assignment");
mock.stop();
let mut assignments: Vec<(String, Vec<(String, i32)>)> = leader
.assignments
.iter()
.map(|assignment| {
(
assignment.member_id.clone(),
crate::builder::decode_assignment(&assignment.assignment),
)
})
.collect();
assignments.sort();
let requested = requested.lock().expect("requested lock").clone();
assert2::assert!(
(requested, assignments)
== (
vec![vec![ORDERS.to_owned(), PAYMENTS.to_owned()]],
vec![
("member-a".to_owned(), vec![(ORDERS.to_owned(), 0)]),
("member-b".to_owned(), vec![(PAYMENTS.to_owned(), 0)]),
]
)
);
}
#[tokio::test]
async fn coordinator_leave_group_uses_configured_timeout() {
let saw_leave = Arc::new(std::sync::atomic::AtomicBool::new(false));
let saw_leave_in_mock = Arc::clone(&saw_leave);
let mock = MockBroker::start(move |api_key, _version, _corr_id, _body| {
if api_key == api_versions_request::API_KEY {
Some(api_versions_for_leave_group())
} else if api_key == leave_group_request::API_KEY {
saw_leave_in_mock.store(true, Ordering::SeqCst);
None
} else {
None
}
})
.await;
let client = Client::builder()
.bootstrap(mock.addr.to_string())
.request_timeout(krabka_units::secs(5))
.build()
.await
.expect("client");
let state = test_state(client);
tokio::time::timeout(
Duration::from_secs(1),
leave_group(
&state,
&state.member_id,
GroupMembershipOperation::Default,
CLOSE_LEAVE_REASON,
),
)
.await
.expect("configured leave deadline bounds coordinator shutdown");
mock.stop();
assert2::assert!(saw_leave.load(Ordering::SeqCst));
}
#[test]
fn find_coordinator_request_populates_legacy_and_batched_group_keys() {
let req = build_find_coordinator_request("group-a".into());
assert2::assert!(
req == FindCoordinatorRequest {
key: "group-a".into(),
key_type: 0,
coordinator_keys: vec!["group-a".into()],
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}
);
}
#[test]
fn subscribed_topics_grew_detects_appearance_and_partition_growth() {
let empty: HashMap<String, i32> = HashMap::new();
let one: HashMap<String, i32> = [("logs".to_string(), 1)].into_iter().collect();
let three: HashMap<String, i32> = [("logs".to_string(), 3)].into_iter().collect();
for (_name, known, current, expected) in [
("topic appears", &empty, &one, true),
("partition count grows", &one, &three, true),
("steady state", &one, &one, false),
("partition count shrinks", &three, &one, false),
("topic disappears", &one, &empty, false),
] {
assert2::assert!(subscribed_topics_grew(known, current) == expected);
}
}
#[test]
fn merge_counts_advances_monotonically_and_ignores_transient_under_reports() {
let one: HashMap<String, i32> = [("logs".to_string(), 1)].into_iter().collect();
let three: HashMap<String, i32> = [("logs".to_string(), 3)].into_iter().collect();
let five: HashMap<String, i32> = [("logs".to_string(), 5)].into_iter().collect();
let mut known: HashMap<String, i32> = HashMap::new();
merge_counts(&mut known, &one);
assert2::assert!(known.get("logs") == Some(&1));
merge_counts(&mut known, &three);
assert2::assert!(
(known.get("logs"), subscribed_topics_grew(&known, &three)) == (Some(&3), false)
);
merge_counts(&mut known, &one);
assert2::assert!(
(known.get("logs"), subscribed_topics_grew(&known, &three)) == (Some(&3), false)
);
merge_counts(&mut known, &HashMap::new());
assert2::assert!(known.get("logs") == Some(&3));
merge_counts(&mut known, &five);
assert2::assert!(known.get("logs") == Some(&5));
}
#[tokio::test(start_paused = true)]
async fn retry_deadline_elapsed_uses_elapsed_timeout_boundary() {
let start = tokio::time::Instant::now();
assert2::assert!(!retry_deadline_elapsed(start, Duration::from_millis(1)));
tokio::time::advance(Duration::from_millis(1)).await;
assert2::assert!(retry_deadline_elapsed(start, Duration::from_millis(1)));
}
#[test]
fn next_backoff_doubles_until_cap() {
for (_name, backoff, max_backoff, expected) in [
(
"doubling below cap",
Duration::from_millis(100),
Duration::from_secs(1),
Duration::from_millis(200),
),
(
"doubling reaches cap",
Duration::from_millis(800),
Duration::from_secs(1),
Duration::from_secs(1),
),
(
"already capped",
Duration::from_secs(1),
Duration::from_secs(1),
Duration::from_secs(1),
),
] {
assert2::assert!(next_backoff(backoff, max_backoff) == expected);
}
}
#[test]
fn leave_group_request_populates_legacy_and_batched_member_fields() {
let req = build_leave_group_request(
"group-a".into(),
"member-a".into(),
Some(CLOSE_LEAVE_REASON),
);
assert2::assert!(
req == LeaveGroupRequest {
group_id: "group-a".into(),
member_id: "member-a".into(),
members: vec![MemberIdentity {
member_id: "member-a".into(),
group_instance_id: None,
reason: Some("the consumer is being closed".into()),
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}],
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}
);
}
#[test]
fn leader_runs_the_selected_assignor_over_all_member_topics() {
use krabka_protocol::owned::{
join_group_response::JoinGroupResponseMember,
metadata_response::{MetadataResponsePartition, MetadataResponseTopic},
};
let topic = |name: &str, partitions: i32| MetadataResponseTopic {
name: Some(name.into()),
partitions: (0..partitions)
.map(|partition_index| MetadataResponsePartition {
partition_index,
..Default::default()
})
.collect(),
..Default::default()
};
let metadata = MetadataResponse {
topics: vec![topic("a", 2), topic("b", 2), topic("c", 1)],
..Default::default()
};
let member = |id: &str, topics: &[&str]| JoinGroupResponseMember {
member_id: id.into(),
metadata: encode_subscription(
&topics.iter().map(|t| (*t).to_owned()).collect::<Vec<_>>(),
&[],
-1,
None,
None,
),
..Default::default()
};
let response = |protocol: &str| JoinGroupResponse {
protocol_name: Some(protocol.into()),
members: vec![member("m1", &["a"]), member("m2", &["a", "b"])],
..Default::default()
};
let decoded = |result: Result<Vec<SyncGroupRequestAssignment>, ConsumerError>| {
result
.map(|assignments| {
assignments
.into_iter()
.map(|assignment| {
(
assignment.member_id,
decode_assignment(&assignment.assignment),
)
})
.collect::<Vec<_>>()
})
.map_err(|error| error.to_string())
};
let p = |topic: &str, partition: i32| (topic.to_owned(), partition);
let assignors = [Assignor::Range, Assignor::RoundRobin];
let actual = [
decoded(leader_assignments(
&assignors,
&response("roundrobin"),
&metadata,
)),
decoded(leader_assignments(
&assignors,
&response("range"),
&metadata,
)),
decoded(leader_assignments(
&assignors,
&response("sticky"),
&metadata,
)),
];
assert2::assert!(
actual
== [
Ok(vec![
("m1".to_owned(), vec![p("a", 0)]),
("m2".to_owned(), vec![p("a", 1), p("b", 0), p("b", 1)]),
]),
Ok(vec![
("m1".to_owned(), vec![p("a", 0)]),
("m2".to_owned(), vec![p("a", 1), p("b", 0), p("b", 1)]),
]),
Err(
"illegal state: Coordinator selected invalid assignment protocol: sticky"
.to_owned()
),
]
);
}
#[test]
fn join_group_request_preserves_group_member_timeouts_protocol_and_reason() {
let fields = JoinRequestFields {
group_id: "group-a".into(),
group_instance_id: Some("instance-a".into()),
session_timeout_ms: 10_000,
rebalance_timeout_ms: 30_000,
protocols: vec![("range".into(), vec![1, 2, 3].into())],
reason: "group is already rebalancing".into(),
};
assert2::assert!(
fields.request("member-a".into())
== JoinGroupRequest {
group_id: "group-a".into(),
session_timeout_ms: 10_000,
rebalance_timeout_ms: 30_000,
member_id: "member-a".into(),
group_instance_id: Some("instance-a".into()),
protocol_type: "consumer".into(),
protocols: vec![JoinGroupRequestProtocol {
name: "range".into(),
metadata: vec![1, 2, 3].into(),
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}],
reason: Some("group is already rebalancing".into()),
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}
);
}
#[test]
fn sync_group_request_preserves_member_generation_protocol_and_assignments() {
let assignment = build_sync_group_assignment(
"member-a".into(),
&[("topic-a".to_string(), 0), ("topic-a".to_string(), 1)],
);
assert2::assert!(
(
assignment.member_id.as_str(),
decode_assignment(&assignment.assignment),
) == (
"member-a",
vec![("topic-a".to_string(), 0), ("topic-a".to_string(), 1)],
)
);
let req = build_sync_group_request(
"group-a".into(),
7,
"member-a".into(),
Some("instance-a".into()),
"range".into(),
vec![assignment.clone()],
);
assert2::assert!(
req == SyncGroupRequest {
group_id: "group-a".into(),
generation_id: 7,
member_id: "member-a".into(),
group_instance_id: Some("instance-a".into()),
protocol_type: Some("consumer".into()),
protocol_name: Some("range".into()),
assignments: vec![assignment],
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}
);
}
#[test]
fn heartbeat_request_preserves_group_generation_and_member() {
let req = build_heartbeat_request(
"group-a".into(),
42,
"member-a".into(),
Some("instance-a".into()),
);
assert2::assert!(
req == HeartbeatRequest {
group_id: "group-a".into(),
generation_id: 42,
member_id: "member-a".into(),
group_instance_id: Some("instance-a".into()),
unknown_tagged_fields: UnknownTaggedFields(vec![]),
}
);
}
#[test]
fn prime_offset_helpers_preserve_committed_and_reset_boundaries() {
for (_name, committed, reset, expected) in [
("committed positive", 12, AutoOffsetReset::Earliest, 12),
("committed zero", 0, AutoOffsetReset::Latest, 0),
(
"missing earliest",
-1,
AutoOffsetReset::Earliest,
crate::poll::BEGINNING_SENTINEL,
),
(
"missing latest",
-1,
AutoOffsetReset::Latest,
crate::poll::LATEST_SENTINEL,
),
(
"missing none",
-1,
AutoOffsetReset::None,
crate::poll::NO_OFFSET_SENTINEL,
),
] {
assert2::assert!(starting_offset(committed, reset) == expected);
}
for (_name, reset, expected) in [
(
"earliest",
AutoOffsetReset::Earliest,
crate::poll::BEGINNING_SENTINEL,
),
(
"latest",
AutoOffsetReset::Latest,
crate::poll::LATEST_SENTINEL,
),
(
"none",
AutoOffsetReset::None,
crate::poll::NO_OFFSET_SENTINEL,
),
] {
assert2::assert!(reset_starting_offset(reset) == expected);
}
for (_name, has_position, expected) in [
("missing position", false, true),
("existing position", true, false),
] {
assert2::assert!(should_prime_missing_partition(has_position) == expected);
}
}
#[test]
fn heartbeat_outcome_classifies_success_rejoin_and_transient_errors() {
for (_name, error_code, expected) in [
("success", 0, HeartbeatOutcome::Ok),
(
"rebalance in progress",
27,
HeartbeatOutcome::NeedRejoin(27),
),
("illegal generation", 22, HeartbeatOutcome::NeedRejoin(22)),
("unknown member", 25, HeartbeatOutcome::RejoinFromScratch),
("fenced instance id", 82, HeartbeatOutcome::Fenced),
("loading coordinator", 14, HeartbeatOutcome::Transient),
("unknown transient", 99, HeartbeatOutcome::Transient),
] {
assert2::assert!(heartbeat_outcome(error_code) == expected);
}
}
fn group_response(response: &impl Encode, version: i16, flexible_min: i16) -> Vec<u8> {
let mut buffer = bytes::BytesMut::new();
if version >= flexible_min {
buffer.extend_from_slice(&[0]);
}
response
.encode(&mut buffer, version)
.expect("encode response");
buffer.to_vec()
}
fn api_versions_for_coordinator_task() -> Vec<u8> {
let version = |api_key: i16, version: i16| ApiVersion {
api_key,
min_version: version,
max_version: version,
..Default::default()
};
let response = ApiVersionsResponse {
error_code: 0,
api_keys: vec![
ApiVersion {
api_key: api_versions_request::API_KEY,
min_version: 0,
max_version: 3,
..Default::default()
},
version(metadata_request::API_KEY, metadata_request::MIN_VERSION),
version(
find_coordinator_request::API_KEY,
find_coordinator_request::MIN_VERSION,
),
version(heartbeat_request::API_KEY, heartbeat_request::MIN_VERSION),
version(join_group_request::API_KEY, join_group_request::MIN_VERSION),
version(sync_group_request::API_KEY, sync_group_request::MIN_VERSION),
version(
leave_group_request::API_KEY,
leave_group_request::MIN_VERSION,
),
],
..Default::default()
};
let mut buffer = bytes::BytesMut::new();
response
.encode(&mut buffer, 0)
.expect("encode API versions");
buffer.to_vec()
}
#[derive(Clone, Copy)]
struct GroupAnswers {
polls: bool,
heartbeat: i16,
join_group: Option<i16>,
sync_group: Option<i16>,
}
#[derive(Debug, PartialEq, Eq)]
struct TaskObservation {
task_exited: bool,
shutdown_cancelled: bool,
poll_error: Option<String>,
assigned: Vec<(String, i32)>,
generation: i32,
commit_member_id: String,
rejoin_on_poll: bool,
group_requests: BTreeSet<&'static str>,
}
fn group_request_name(api_key: i16) -> Option<&'static str> {
match api_key {
find_coordinator_request::API_KEY => Some("FindCoordinator"),
heartbeat_request::API_KEY => Some("Heartbeat"),
join_group_request::API_KEY => Some("JoinGroup"),
sync_group_request::API_KEY => Some("SyncGroup"),
leave_group_request::API_KEY => Some("LeaveGroup"),
_ => None,
}
}
async fn run_coordinator_task(
answers: GroupAnswers,
expected: &TaskObservation,
) -> TaskObservation {
let port = Arc::new(std::sync::atomic::AtomicU16::new(0));
let port_in_mock = Arc::clone(&port);
let group_requests = Arc::new(std::sync::Mutex::new(BTreeSet::new()));
let group_requests_in_mock = Arc::clone(&group_requests);
let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
if let Some(name) = group_request_name(api_key) {
group_requests_in_mock
.lock()
.expect("requests lock")
.insert(name);
}
let port = i32::from(port_in_mock.load(Ordering::SeqCst));
match api_key {
api_versions_request::API_KEY => Some(api_versions_for_coordinator_task()),
metadata_request::API_KEY => Some(group_response(
&MetadataResponse {
brokers: vec![MetadataResponseBroker {
node_id: 0,
host: "127.0.0.1".into(),
port,
..Default::default()
}],
..Default::default()
},
version,
metadata_request::FLEXIBLE_MIN,
)),
find_coordinator_request::API_KEY => Some(group_response(
&FindCoordinatorResponse {
node_id: 0,
host: "127.0.0.1".into(),
port,
..Default::default()
},
version,
find_coordinator_request::FLEXIBLE_MIN,
)),
heartbeat_request::API_KEY => Some(group_response(
&HeartbeatResponse {
error_code: answers.heartbeat,
..Default::default()
},
version,
heartbeat_request::FLEXIBLE_MIN,
)),
join_group_request::API_KEY => answers.join_group.map(|error_code| {
group_response(
&JoinGroupResponse {
error_code,
generation_id: 2,
protocol_name: Some("range".into()),
leader: "member-b".into(),
member_id: "member-a".into(),
..Default::default()
},
version,
join_group_request::FLEXIBLE_MIN,
)
}),
sync_group_request::API_KEY => answers.sync_group.map(|error_code| {
group_response(
&SyncGroupResponse {
error_code,
..Default::default()
},
version,
sync_group_request::FLEXIBLE_MIN,
)
}),
_ => None,
}
})
.await;
port.store(mock.addr.port(), Ordering::SeqCst);
let client = Client::builder()
.bootstrap(mock.addr.to_string())
.request_timeout(secs(5))
.build()
.await
.expect("client");
let orders_0 = (ORDERS.to_string(), 0);
let poll_signal = PollSignal::default();
let state = CoordinatorState {
client,
group_id: "group-a".into(),
coordinator_id: Arc::new(AtomicI32::new(0)),
member_id: "member-a".into(),
published_member_id: tokio::sync::watch::Sender::new("member-a".to_owned()),
commit_identity: Arc::new(Mutex::new(CommitIdentity {
generation: 1,
member_id: "member-a".into(),
ownership_ids: HashMap::from([(orders_0.clone(), 1)]),
rejoin_on_poll: false,
})),
group_instance_id: Some("instance-a".into()),
generation_id: 1,
current_generation: Arc::new(AtomicI32::new(1)),
assignors: vec![Assignor::Range],
rebalance_protocol: RebalanceProtocol::Eager,
subscription: crate::subscription::shared(vec![ORDERS.into()], None, true),
subscription_changes: crate::subscription::shared(Vec::new(), None, true).subscribe(),
joined_topics: Vec::new(),
seen_unsubscribes: 0,
group_protocol: crate::GroupProtocol::Classic,
server_assignor: None,
member_epoch: 0,
sent_heartbeat_fields: crate::consumer_group::SentFields::default(),
assigned: Arc::new(Mutex::new(vec![orders_0.clone()])),
assignment_changed: Arc::new(Notify::new()),
next_ownership_id: 2,
next_offsets: Arc::new(Mutex::new(HashMap::from([(orders_0, 5)]))),
end_offsets: Arc::new(Mutex::new(HashMap::new())),
positions: Arc::new(Mutex::new(HashMap::new())),
topic_ids: Arc::new(Mutex::new(HashMap::new())),
session_timeout: secs(45),
max_poll_interval: minutes(1),
heartbeat_interval: millis(20),
subscription_metadata_refresh_interval: minutes(10),
leave_group_timeout: millis(200),
auto_offset_reset: AutoOffsetReset::Latest,
client_rack: None,
initial_subscribed_counts: HashMap::new(),
retry_policy: CoordinatorRetryPolicy {
timeout: Duration::from_secs(1),
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(1),
},
poll_error: PollErrorSlot::default(),
auto_commit: None,
commit_serialization: Arc::new(Mutex::new(())),
join_prepared: false,
polls: poll_signal.subscribe(),
rebalance_pending: tokio::sync::watch::Sender::new(false),
close_operation: tokio::sync::watch::channel(crate::control::CloseRequest::default()).1,
rejoin_reason: String::new(),
listener_calls: None,
enforced_rebalances: tokio::sync::mpsc::unbounded_channel().1,
lost_partitions: Vec::new(),
assigned_callback_pending: Arc::default(),
poll_timer: PollTimer::new(secs(300)),
poll_timeout_in_callback: false,
};
let poll_error = Arc::clone(&state.poll_error);
let assigned = Arc::clone(&state.assigned);
let generation = Arc::clone(&state.current_generation);
let commit_identity = Arc::clone(&state.commit_identity);
let shutdown = CancellationToken::new();
let task = tokio::spawn(run(state, shutdown.clone()));
let poller = answers.polls.then(|| {
let poll_signal = poll_signal.clone();
tokio::spawn(async move {
loop {
note_poll(&poll_signal);
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut settle_until = None;
let observation = loop {
let poll_error = poll_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(ToString::to_string);
let identity = commit_identity.lock().await.clone();
let observation = TaskObservation {
task_exited: task.is_finished(),
shutdown_cancelled: shutdown.is_cancelled(),
poll_error,
assigned: assigned.lock().await.clone(),
generation: generation.load(Ordering::SeqCst),
commit_member_id: identity.member_id,
rejoin_on_poll: identity.rejoin_on_poll,
group_requests: group_requests.lock().expect("requests lock").clone(),
};
let now = tokio::time::Instant::now();
if now >= deadline || settle_until.is_some_and(|until| now >= until) {
break observation;
}
if observation == *expected {
settle_until.get_or_insert(now + Duration::from_millis(200));
} else if settle_until.is_some() {
break observation;
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
if let Some(poller) = poller {
poller.abort();
}
shutdown.cancel();
tokio::time::timeout(Duration::from_secs(2), task)
.await
.expect("coordinator task stops on shutdown")
.expect("coordinator task does not panic");
drop(poll_signal);
mock.stop();
observation
}
#[tokio::test]
async fn coordinator_task_removes_a_fenced_static_member_until_the_next_poll() {
let fenced = "fenced group.instance.id instance-a: another consumer with the same group.instance.id joined the group";
let owned = vec![(ORDERS.to_string(), 0)];
let requests = |names: &[&'static str]| names.iter().copied().collect::<BTreeSet<_>>();
for (name, answers, expected) in [
(
"success",
GroupAnswers {
polls: false,
heartbeat: 0,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: None,
assigned: owned.clone(),
generation: 1,
commit_member_id: "member-a".into(),
rejoin_on_poll: false,
group_requests: requests(&["Heartbeat"]),
},
),
(
"heartbeat fenced instance id",
GroupAnswers {
polls: true,
heartbeat: 82,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: Some(fenced.into()),
assigned: Vec::new(),
generation: -1,
commit_member_id: String::new(),
rejoin_on_poll: true,
group_requests: requests(&["Heartbeat"]),
},
),
(
"rebalance in progress",
GroupAnswers {
polls: false,
heartbeat: 27,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: None,
assigned: owned.clone(),
generation: 1,
commit_member_id: "member-a".into(),
rejoin_on_poll: false,
group_requests: requests(&["Heartbeat"]),
},
),
(
"rebalance in progress and the application polls",
GroupAnswers {
polls: true,
heartbeat: 27,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: None,
assigned: owned.clone(),
generation: 1,
commit_member_id: "member-a".into(),
rejoin_on_poll: false,
group_requests: requests(&["Heartbeat", "JoinGroup"]),
},
),
(
"unknown member id",
GroupAnswers {
polls: false,
heartbeat: 25,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: None,
assigned: Vec::new(),
generation: -1,
commit_member_id: String::new(),
rejoin_on_poll: false,
group_requests: requests(&["Heartbeat"]),
},
),
(
"coordinator not available",
GroupAnswers {
polls: false,
heartbeat: 15,
join_group: None,
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: None,
assigned: owned.clone(),
generation: 1,
commit_member_id: "member-a".into(),
rejoin_on_poll: false,
group_requests: requests(&["FindCoordinator", "Heartbeat"]),
},
),
(
"join group fenced instance id",
GroupAnswers {
polls: true,
heartbeat: 27,
join_group: Some(82),
sync_group: None,
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: Some(fenced.into()),
assigned: Vec::new(),
generation: -1,
commit_member_id: String::new(),
rejoin_on_poll: true,
group_requests: requests(&["Heartbeat", "JoinGroup"]),
},
),
(
"sync group fenced instance id",
GroupAnswers {
polls: true,
heartbeat: 27,
join_group: Some(0),
sync_group: Some(82),
},
TaskObservation {
task_exited: false,
shutdown_cancelled: false,
poll_error: Some(fenced.into()),
assigned: Vec::new(),
generation: -1,
commit_member_id: String::new(),
rejoin_on_poll: true,
group_requests: requests(&["Heartbeat", "JoinGroup", "SyncGroup"]),
},
),
] {
let observation = run_coordinator_task(answers, &expected).await;
check!(observation == expected, "case {name}");
}
}
#[tokio::test(start_paused = true)]
async fn retries_until_coordinator_finishes_loading() {
let calls = AtomicUsize::new(0);
let r = with_coordinator_retry(
retry(Duration::from_secs(30)),
|r: &Resp| r.error_code,
|| {
let n = calls.fetch_add(1, Ordering::SeqCst);
async move {
Ok::<_, ConsumerError>(Resp {
error_code: if n < 3 { 14 } else { 0 },
})
}
},
)
.await
.unwrap();
assert2::assert!(r.error_code == 0);
assert2::assert!(calls.load(Ordering::SeqCst) == 4);
}
#[tokio::test(start_paused = true)]
async fn configured_coordinator_retry_policy_controls_backoff_and_timeout() {
let calls = AtomicUsize::new(0);
let retry = CoordinatorRetryPolicy {
timeout: Duration::from_millis(35),
initial_backoff: Duration::from_millis(5),
max_backoff: Duration::from_millis(10),
};
let r = with_coordinator_retry(
retry,
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok::<_, ConsumerError>(Resp { error_code: 15 }) }
},
)
.await
.unwrap();
assert2::assert!(r.error_code == 15);
assert2::assert!(calls.load(Ordering::SeqCst) == 5);
}
#[tokio::test(start_paused = true)]
async fn surfaces_last_response_after_deadline() {
let r = with_coordinator_retry(
retry(Duration::from_secs(1)),
|r: &Resp| r.error_code,
|| async { Ok::<_, ConsumerError>(Resp { error_code: 15 }) },
)
.await
.unwrap();
assert2::assert!(r.error_code == 15);
}
#[tokio::test(start_paused = true)]
async fn non_retriable_code_returns_immediately() {
let calls = AtomicUsize::new(0);
let r = with_coordinator_retry(
retry(Duration::from_secs(30)),
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async move { Ok::<_, ConsumerError>(Resp { error_code: 25 }) } },
)
.await
.unwrap();
assert2::assert!(r.error_code == 25);
assert2::assert!(calls.load(Ordering::SeqCst) == 1);
}
#[tokio::test(start_paused = true)]
async fn disconnect_past_deadline_surfaces_coordinator_unavailable() {
let r = with_coordinator_retry(
retry(Duration::from_secs(1)),
|r: &Resp| r.error_code,
|| async {
Err::<Resp, _>(ConsumerError::Client(
krabka_client_core::ClientError::Disconnected,
))
},
)
.await;
assert2::assert!(matches!(r, Err(ConsumerError::CoordinatorUnavailable)));
}
#[tokio::test(start_paused = true)]
async fn connect_past_deadline_surfaces_coordinator_unavailable() {
let calls = AtomicUsize::new(0);
let r = with_coordinator_retry(
retry(Duration::from_millis(1)),
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Err::<Resp, _>(refused_connect_error()) }
},
)
.await;
assert2::assert!(matches!(r, Err(ConsumerError::CoordinatorUnavailable)));
assert2::assert!(calls.load(Ordering::SeqCst) > 1);
}
}
#[cfg(test)]
mod find_coordinator_parse_tests {
use krabka_protocol::owned::find_coordinator_response::Coordinator;
use super::*;
#[test]
fn parses_legacy_and_batched_coordinator_shapes() {
for (_name, resp, expected) in [
(
"batched success",
FindCoordinatorResponse {
node_id: -1,
error_code: 99,
coordinators: vec![Coordinator {
key: "g".into(),
node_id: 7,
host: "h".into(),
port: 9092,
error_code: 0,
..Default::default()
}],
..Default::default()
},
(7, 0, false),
),
(
"legacy success",
FindCoordinatorResponse {
node_id: 3,
error_code: 0,
coordinators: vec![],
..Default::default()
},
(3, 0, false),
),
(
"batched not coordinator",
FindCoordinatorResponse {
node_id: 1,
error_code: 0,
coordinators: vec![Coordinator {
key: "g".into(),
node_id: -1,
error_code: NOT_COORDINATOR,
..Default::default()
}],
..Default::default()
},
(-1, NOT_COORDINATOR, true),
),
] {
let code = coordinator_error_code(&resp);
assert2::assert!(
(
coordinator_node_id(&resp),
code,
is_retriable_coordinator_code(code)
) == expected
);
}
}
}
#[cfg(test)]
mod refind_tests {
use std::sync::atomic::AtomicUsize;
use assert2::check;
use super::*;
fn retry(timeout: Duration) -> CoordinatorRetryPolicy {
CoordinatorRetryPolicy {
timeout,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(1),
}
}
struct Resp {
error_code: i16,
}
#[tokio::test(start_paused = true)]
async fn returns_immediately_on_success_without_refind() {
let client = Client::builder()
.bootstrap("127.0.0.1:1")
.build()
.await
.unwrap();
let coord = AtomicI32::new(5);
let calls = AtomicUsize::new(0);
let r = with_coordinator_refind(
&client,
"g",
&coord,
retry(Duration::from_secs(30)),
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok::<_, ConsumerError>(Resp { error_code: 0 }) }
},
)
.await
.unwrap();
check!(r.error_code == 0);
check!(calls.load(Ordering::SeqCst) == 1);
check!(coord.load(Ordering::Relaxed) == 5);
}
#[tokio::test(start_paused = true)]
async fn non_retriable_code_returns_without_refind() {
let client = Client::builder()
.bootstrap("127.0.0.1:1")
.build()
.await
.unwrap();
let coord = AtomicI32::new(2);
let calls = AtomicUsize::new(0);
let r = with_coordinator_refind(
&client,
"g",
&coord,
retry(Duration::from_secs(30)),
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok::<_, ConsumerError>(Resp { error_code: 25 }) }
},
)
.await
.unwrap();
check!(r.error_code == 25);
check!(calls.load(Ordering::SeqCst) == 1);
check!(coord.load(Ordering::Relaxed) == 2);
}
#[tokio::test(start_paused = true)]
async fn connect_error_refinds_until_deadline() {
let client = Client::builder()
.bootstrap("127.0.0.1:1")
.socket_connection_setup_timeout(krabka_units::millis(10))
.request_timeout(krabka_units::millis(10))
.build()
.await
.unwrap();
let coord = AtomicI32::new(5);
let calls = AtomicUsize::new(0);
let r = with_coordinator_refind(
&client,
"g",
&coord,
retry(Duration::from_millis(1)),
|r: &Resp| r.error_code,
|| {
calls.fetch_add(1, Ordering::SeqCst);
async {
Err::<Resp, _>(ConsumerError::Client(
krabka_client_core::ClientError::Connect {
addr: "127.0.0.1:9092".parse().unwrap(),
source: std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"refused",
),
},
))
}
},
)
.await;
assert2::assert!(matches!(r, Err(ConsumerError::CoordinatorUnavailable)));
assert2::assert!(calls.load(Ordering::SeqCst) > 1);
}
#[test]
fn cooperative_ownership_preserves_retained_and_rotates_reassigned_partitions() {
let p0 = ("topic".to_string(), 0);
let p1 = ("topic".to_string(), 1);
let mut ownership = HashMap::from([(p0.clone(), 7), (p1.clone(), 8)]);
let mut next_id = 9;
update_ownership(
&mut ownership,
std::slice::from_ref(&p0),
true,
&mut next_id,
);
assert2::assert!(ownership == HashMap::from([(p0.clone(), 7)]));
update_ownership(
&mut ownership,
&[p0.clone(), p1.clone()],
true,
&mut next_id,
);
assert2::assert!(ownership == HashMap::from([(p0, 7), (p1, 9)]));
}
#[test]
fn eager_assignment_rotates_even_retained_partition_ownership() {
let p0 = ("topic".to_string(), 0);
let mut ownership = HashMap::from([(p0.clone(), 7)]);
let mut next_id = 8;
update_ownership(
&mut ownership,
std::slice::from_ref(&p0),
false,
&mut next_id,
);
assert2::assert!(ownership == HashMap::from([(p0, 8)]));
}
}