1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! KIP-320 consumer position validation. It has two responsibilities:
//! 1. Refresh the per-partition leader id and leader epoch from `Metadata`,
//! and flag a partition `awaiting_validation` when its leader epoch
//! advances.
//! 2. For a flagged partition, issue `OffsetForLeaderEpoch` and use
//! `position::classify` to decide whether to resume or to reset for
//! truncation.
use std::collections::HashMap;
use krabka_ids::LeaderEpoch;
use krabka_protocol::{
owned::metadata_response::MetadataResponse, primitives::uuid::Uuid as WireUuid,
};
use crate::{
consumer::Consumer,
error::ConsumerError,
position::{PartitionPosition, ValidationOutcome, classify},
};
fn update_leader_epoch(pos: &mut PartitionPosition, leader_epoch: LeaderEpoch) {
if leader_epoch > pos.leader_epoch {
pos.leader_epoch = leader_epoch;
if should_await_validation(leader_epoch, pos.offset_epoch) {
pos.awaiting_validation = true;
}
}
}
pub(crate) fn should_await_validation(
leader_epoch: LeaderEpoch,
offset_epoch: LeaderEpoch,
) -> bool {
leader_epoch > offset_epoch && offset_epoch.is_known()
}
fn clear_unvalidatable_position(pos: &mut PartitionPosition) {
if pos.awaiting_validation && !pos.offset_epoch.is_known() {
pos.awaiting_validation = false;
}
}
fn should_validate_position(pos: &PartitionPosition) -> bool {
pos.awaiting_validation && pos.offset_epoch.is_known()
}
fn should_route_to_leader(leader_id: i32, knows_leader: bool) -> bool {
leader_id >= 0 && knows_leader
}
fn response_still_current(pos: &PartitionPosition, leader_epoch: LeaderEpoch) -> bool {
pos.leader_epoch == leader_epoch
}
fn should_skip_stale_response(pos: &PartitionPosition, leader_epoch: LeaderEpoch) -> bool {
!response_still_current(pos, leader_epoch)
}
fn response_has_error(error_code: i16) -> bool {
error_code != 0
}
/// Store the topic id that `metadata` reports for each topic.
///
/// A topic that was deleted and created again keeps its name and gets a new
/// id. Fetch v13 and later, and `OffsetCommit` and `OffsetFetch` v10, carry
/// only the id, so a stale id makes the broker answer `UNKNOWN_TOPIC_ID` until
/// the consumer stores the new one. A topic that a subscription change or a
/// pattern adds needs its id too. Kafka's `Metadata.update` stores the topic
/// ids of each metadata response in the same way. The metadata requests name
/// the topics of the consumer, so the response holds no other topics. This
/// function skips a topic row with an error or with the zero id.
pub(crate) fn refresh_tracked_topic_ids(
topic_ids: &mut HashMap<String, WireUuid>,
metadata: &MetadataResponse,
) {
for topic in &metadata.topics {
let Some(name) = &topic.name else { continue };
if response_has_error(topic.error_code) || topic.topic_id == WireUuid::ZERO {
continue;
}
topic_ids.insert(name.clone(), topic.topic_id);
}
}
fn mark_validation_error(pos: &mut PartitionPosition) {
pos.leader_epoch = LeaderEpoch(-1);
pos.awaiting_validation = true;
}
impl Consumer {
/// Refresh the leader id and leader epoch for every partition that
/// `Metadata` reports.
///
/// This method flags a partition `awaiting_validation` when its metadata
/// leader epoch is greater than the last consumed epoch in `offset_epoch`.
#[cfg_attr(test, mutants::skip)] // cargo-mutants: metadata-refresh I/O, exercised by integration tests
#[tracing::instrument(
name = "consumer.refresh_leader_epochs",
level = "debug",
skip_all,
fields(group_id = %self.group_id),
err(level = "debug")
)]
pub(crate) async fn refresh_leader_epochs(&self) -> Result<(), ConsumerError> {
// `refresh_metadata` (not a bare `send(MetadataRequest)`) so the main
// client's BrokerPool learns each broker's (id → addr) mapping — this
// is what lets `poll`/`validate` route Fetch and OffsetForLeaderEpoch
// to the partition *leader* via `Client::broker(id)` instead of always
// hitting the bootstrap connection.
let md = self.client.refresh_metadata().await?;
let mut positions = self.positions.lock().await;
for t in &md.topics {
let Some(name) = &t.name else { continue };
for p in &t.partitions {
let key = (name.clone(), p.partition_index);
let entry = positions.entry(key).or_default();
entry.leader_id = p.leader_id;
// Wrap the raw wire `int32` leader epoch at the Metadata decode
// boundary.
update_leader_epoch(entry, LeaderEpoch(p.leader_epoch));
}
}
drop(positions);
refresh_tracked_topic_ids(&mut *self.topic_ids.lock().await, &md);
Ok(())
}
/// Validate every `awaiting_validation` partition with
/// `OffsetForLeaderEpoch`.
///
/// This method returns the set of partitions that truncated, mapped to the
/// safe offset that the caller must reset `next_offsets` to. It clears the
/// validation flag for every partition it confirms as consistent.
#[cfg_attr(test, mutants::skip)] // cargo-mutants: OffsetForLeaderEpoch RPC orchestration, exercised by integration tests
#[tracing::instrument(
name = "consumer.validate_positions",
level = "debug",
skip_all,
fields(
group_id = %self.group_id,
to_validate = tracing::field::Empty,
truncated = tracing::field::Empty,
),
err(level = "debug")
)]
pub(crate) async fn validate_positions(
&self,
) -> Result<HashMap<(String, i32), i64>, ConsumerError> {
// Snapshot the work to do under the lock, then issue RPCs unlocked.
// Lock order: next_offsets first, then positions — matching the
// coordinator's established order, so we never deadlock against poll.
// (topic, partition, offset, offset_epoch, leader_epoch, leader_id).
let to_validate: Vec<(String, i32, i64, LeaderEpoch, LeaderEpoch, i32)> = {
let offsets = self.next_offsets.lock().await;
let mut positions = self.positions.lock().await;
// Defensive: clear awaiting_validation for any partition whose
// offset_epoch < 0 (never consumed). There is nothing to validate
// below an offset never consumed, and leaving the flag set would
// wedge the partition — validate_positions skips it but the fetch
// builder also skips it, causing a permanent stall.
for p in positions.values_mut() {
clear_unvalidatable_position(p);
}
positions
.iter()
.filter(|(_, p)| should_validate_position(p))
.filter_map(|((t, part), p)| {
let off = *offsets.get(&(t.clone(), *part))?;
Some((
t.clone(),
*part,
off,
p.offset_epoch,
p.leader_epoch,
p.leader_id,
))
})
.collect()
};
tracing::Span::current().record("to_validate", to_validate.len());
let mut truncated: HashMap<(String, i32), i64> = HashMap::new();
for (topic, partition, offset, offset_epoch, leader_epoch, leader_id) in to_validate {
// RPC issued with no lock held. KIP-320 requires the
// OffsetForLeaderEpoch reach the partition *leader* — it is the
// only replica with the authoritative epoch→end-offset history.
// Route to the leader when its id is known and the pool has a
// dialable address for it (registry populated by
// `refresh_leader_epochs` → `refresh_metadata`); fall back to the
// bootstrap connection otherwise (e.g. single-broker test brokers
// advertising port 0, where bootstrap is the leader anyway).
// Unwrap the leader epochs to raw `int32` at the client-core /
// wire boundary (`current_leader_epoch` = `leader_epoch`,
// `leader_epoch` arg = `offset_epoch`).
let answer = if should_route_to_leader(leader_id, self.client.knows_broker(leader_id)) {
self.client
.offset_for_leader_epoch_on(
leader_id,
&topic,
partition,
leader_epoch.get(),
offset_epoch.get(),
)
.await?
} else {
self.client
.offset_for_leader_epoch(
&topic,
partition,
leader_epoch.get(),
offset_epoch.get(),
)
.await?
};
// Re-check the partition is still assigned + epoch unchanged before
// applying — a rebalance may have moved it.
let mut positions = self.positions.lock().await;
let Some(pos) = positions.get_mut(&(topic.clone(), partition)) else {
continue;
};
if should_skip_stale_response(pos, leader_epoch) {
continue; // metadata moved under us; revalidate next poll
}
// CRITICAL: inspect the per-partition error_code BEFORE classify.
// On a non-zero code (FENCED_LEADER_EPOCH=74, UNKNOWN_LEADER_EPOCH=75,
// NOT_LEADER_OR_FOLLOWER=6, UNKNOWN_TOPIC_OR_PARTITION=3, ...) the
// broker returns end_offset = -1. Feeding that into `classify` would
// look like truncation and wrongly reset to 0. Instead leave the
// partition flagged for re-validation (and force a metadata refresh
// next poll) and skip it.
if response_has_error(answer.error_code) {
// Reset leader_epoch so refresh_leader_epochs re-flags + the next
// pass re-issues this RPC against fresher metadata.
mark_validation_error(pos);
continue;
}
// Wrap the leader's answer epoch (raw wire `int32`) at the
// OffsetForLeaderEpoch decode boundary.
match classify(
offset,
offset_epoch,
LeaderEpoch(answer.leader_epoch),
answer.end_offset,
) {
ValidationOutcome::Valid { leader_epoch: le } => {
pos.offset_epoch = le;
pos.awaiting_validation = false;
}
ValidationOutcome::Truncated { safe_offset } => {
pos.awaiting_validation = false;
truncated.insert((topic, partition), safe_offset);
}
}
}
tracing::Span::current().record("truncated", truncated.len());
Ok(truncated)
}
}
#[cfg(test)]
mod tests {
use assert2::check;
use krabka_protocol::owned::metadata_response::MetadataResponseTopic;
use super::*;
fn pos(offset_epoch: i32, leader_epoch: i32, awaiting_validation: bool) -> PartitionPosition {
PartitionPosition {
offset_epoch: LeaderEpoch(offset_epoch),
leader_epoch: LeaderEpoch(leader_epoch),
awaiting_validation,
..Default::default()
}
}
#[test]
fn leader_epoch_update_marks_only_real_advances_with_consumed_epoch() {
let mut p = pos(2, 2, false);
update_leader_epoch(&mut p, LeaderEpoch(2));
assert2::assert!((p.leader_epoch, p.awaiting_validation) == (LeaderEpoch(2), false));
update_leader_epoch(&mut p, LeaderEpoch(3));
assert2::assert!((p.leader_epoch, p.awaiting_validation) == (LeaderEpoch(3), true));
let mut never_consumed = pos(-1, -1, false);
update_leader_epoch(&mut never_consumed, LeaderEpoch(0));
assert2::assert!(
(
never_consumed.leader_epoch,
never_consumed.awaiting_validation
) == (LeaderEpoch(0), false)
);
let mut equal_but_consumed = pos(2, 3, false);
update_leader_epoch(&mut equal_but_consumed, LeaderEpoch(3));
assert2::assert!(!equal_but_consumed.awaiting_validation);
assert2::assert!(!should_await_validation(LeaderEpoch(2), LeaderEpoch(2)));
}
#[test]
fn validation_eligibility_clears_never_consumed_partitions() {
for (_name, offset, awaiting_validation, clear, expected) in [
("never consumed", -1, true, true, (false, false)),
("consumed", 2, true, false, (true, true)),
("consumed at zero", 0, true, true, (true, true)),
("already clear", 2, false, false, (false, false)),
] {
let mut position = pos(offset, 4, awaiting_validation);
if clear {
clear_unvalidatable_position(&mut position);
}
assert2::assert!(
(
position.awaiting_validation,
should_validate_position(&position)
) == expected
);
}
}
#[test]
fn validation_routing_requires_known_non_negative_leader() {
let cases = [
("known leader", 3, true, true),
("negative leader", -1, true, false),
("unknown broker", 3, false, false),
];
for (_name, leader_id, knows_leader, expected) in cases {
assert2::assert!(should_route_to_leader(leader_id, knows_leader) == expected);
}
}
#[test]
fn response_epoch_must_still_match_position_epoch() {
let p = pos(2, 7, true);
for (name, epoch, expected) in [
("current", LeaderEpoch(7), (true, false)),
("stale", LeaderEpoch(6), (false, true)),
] {
check!(
(
response_still_current(&p, epoch),
should_skip_stale_response(&p, epoch)
) == expected,
"case {name}"
);
}
}
#[test]
fn zero_error_code_is_success_and_nonzero_is_error() {
for (_name, code, expected) in [("success", 0, false), ("error", 74, true)] {
assert2::assert!(response_has_error(code) == expected);
}
}
#[test]
fn metadata_refresh_stores_the_topic_ids_of_the_response() {
let old_id = WireUuid([1; 16]);
let new_id = WireUuid([2; 16]);
let other_id = WireUuid([3; 16]);
let topic = |name: &str, error_code: i16, topic_id: WireUuid| MetadataResponseTopic {
error_code,
name: Some(name.into()),
topic_id,
..Default::default()
};
for (name, row, expected) in [
(
"tracked topic gets the new id",
topic("orders", 0, new_id),
maplit::hashmap! {"orders".to_string() => new_id},
),
(
"row with an error keeps the old id",
topic("orders", 3, new_id),
maplit::hashmap! {"orders".to_string() => old_id},
),
(
"row with the zero id keeps the old id",
topic("orders", 0, WireUuid::ZERO),
maplit::hashmap! {"orders".to_string() => old_id},
),
(
"a new topic is added",
topic("payments", 0, other_id),
maplit::hashmap! {"orders".to_string() => old_id, "payments".to_string() => other_id},
),
] {
let mut topic_ids = maplit::hashmap! {"orders".to_string() => old_id};
let metadata = MetadataResponse {
topics: vec![row],
..Default::default()
};
refresh_tracked_topic_ids(&mut topic_ids, &metadata);
check!(topic_ids == expected, "case {name}");
}
}
#[test]
fn validation_error_resets_epoch_and_keeps_partition_flagged() {
let mut p = pos(2, 7, false);
mark_validation_error(&mut p);
assert2::assert!((p.leader_epoch, p.awaiting_validation) == (LeaderEpoch(-1), true));
}
}