krabka_log/log/verification.rs
1//! KIP-890 part 1: the per-producer verification state that ties a
2//! transactional append to a transaction the coordinator knows.
3//!
4//! Before a transactional batch starts a transaction on this partition, the
5//! broker asks the transaction coordinator to verify (or, with transaction
6//! version 2, to add) the partition. The log hands out a
7//! [`VerificationGuard`] when that check starts and keeps it until the next
8//! transactional append or end marker of the producer. An append must present
9//! the same guard. A marker that lands between the check and the append clears
10//! the guard, so a stale check can never start a transaction the coordinator
11//! already ended.
12//!
13//! This is Kafka's `VerificationStateEntry`, `VerificationGuard`,
14//! `UnifiedLog.maybeStartTransactionVerification` and the
15//! `batchMissingRequiredVerification` check in
16//! `UnifiedLog.analyzeAndValidateProducerState`.
17
18use {
19 super::Log,
20 krabka_ids::ProducerId,
21 std::sync::atomic::{AtomicU64, Ordering},
22};
23
24/// A token for one verification of one producer on one partition.
25///
26/// [`VerificationGuard::SENTINEL`] verifies nothing. Kafka's
27/// `VerificationGuard.SENTINEL` has the value 0 and every real guard takes the
28/// next value of a process-wide counter.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub struct VerificationGuard(u64);
31
32impl VerificationGuard {
33 /// The guard that verifies nothing.
34 pub const SENTINEL: Self = Self(0);
35
36 fn next() -> Self {
37 static NEXT: AtomicU64 = AtomicU64::new(1);
38 Self(NEXT.fetch_add(1, Ordering::Relaxed))
39 }
40
41 /// Whether `presented` is this guard and is not the sentinel. Kafka's
42 /// `VerificationGuard.verify`.
43 #[must_use]
44 pub fn verifies(self, presented: Self) -> bool {
45 presented != Self::SENTINEL && presented == self
46 }
47}
48
49/// The verification state of one producer. Kafka's `VerificationStateEntry`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(super) struct VerificationState {
52 pub(super) guard: VerificationGuard,
53 pub(super) created_ms: i64,
54 pub(super) epoch: i16,
55 pub(super) lowest_sequence: i32,
56 pub(super) supports_epoch_bump: bool,
57}
58
59/// Why a transactional produce may not start or continue its append.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TransactionAppendRefusal {
62 /// The batch epoch is below the producer's epoch on this partition. Kafka
63 /// throws `InvalidProducerEpochException`.
64 StaleProducerEpoch,
65 /// A transactional batch has no open transaction and no matching guard, or
66 /// a non-transactional batch comes from a producer with an open
67 /// transaction. Kafka throws `InvalidTxnStateException`.
68 InvalidTransactionState,
69 /// The first sequence of a verified batch does not fit the verification
70 /// state. Kafka throws `OutOfOrderSequenceException` from
71 /// `ProducerAppendInfo.checkSequence`.
72 OutOfOrderSequence,
73}
74
75/// One batch as the transaction checks see it.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct TransactionalBatch {
78 pub producer_id: ProducerId,
79 pub producer_epoch: i16,
80 pub base_sequence: i32,
81 pub is_transactional: bool,
82 pub is_control: bool,
83}
84
85impl Log {
86 /// Whether `producer_id` has an open transaction on this partition at
87 /// `producer_epoch`. Kafka's `UnifiedLog.hasOngoingTransaction`.
88 #[must_use]
89 pub fn has_ongoing_transaction(&self, producer_id: ProducerId, producer_epoch: i16) -> bool {
90 self.producer_state.get(&producer_id).is_some_and(|entry| {
91 entry.current_txn_first_offset.is_some() && entry.producer_epoch == producer_epoch
92 })
93 }
94
95 /// Start a verification for a transactional batch, and return its guard.
96 ///
97 /// A producer with an open transaction at the batch epoch needs no
98 /// verification, and gets [`VerificationGuard::SENTINEL`]. Otherwise the
99 /// producer's guard is created, or kept with its lowest sequence moved as
100 /// Kafka's `maybeUpdateLowestSequenceAndEpoch` moves it. The call also
101 /// drops verification state older than `expiration_ms`, as Kafka's
102 /// `ProducerStateManager.removeExpiredProducers` does.
103 ///
104 /// # Errors
105 ///
106 /// Returns [`TransactionAppendRefusal::StaleProducerEpoch`] when the batch
107 /// epoch is below the producer's epoch on this partition.
108 pub fn maybe_start_transaction_verification(
109 &mut self,
110 batch: TransactionalBatch,
111 supports_epoch_bump: bool,
112 (now_ms, expiration_ms): (i64, i64),
113 ) -> Result<VerificationGuard, TransactionAppendRefusal> {
114 let TransactionalBatch {
115 producer_id,
116 producer_epoch,
117 base_sequence,
118 ..
119 } = batch;
120 if self
121 .producer_state
122 .get(&producer_id)
123 .is_some_and(|entry| producer_epoch < entry.producer_epoch)
124 {
125 return Err(TransactionAppendRefusal::StaleProducerEpoch);
126 }
127 if self.has_ongoing_transaction(producer_id, producer_epoch) {
128 return Ok(VerificationGuard::SENTINEL);
129 }
130 self.verification_states
131 .retain(|_, state| now_ms.saturating_sub(state.created_ms) < expiration_ms);
132 let state = self
133 .verification_states
134 .entry(producer_id)
135 .or_insert_with(|| VerificationState {
136 guard: VerificationGuard::next(),
137 created_ms: now_ms,
138 epoch: producer_epoch,
139 lowest_sequence: base_sequence,
140 supports_epoch_bump,
141 });
142 if producer_epoch == state.epoch && base_sequence < state.lowest_sequence {
143 state.lowest_sequence = base_sequence;
144 }
145 if producer_epoch > state.epoch {
146 state.epoch = producer_epoch;
147 state.lowest_sequence = base_sequence;
148 }
149 Ok(state.guard)
150 }
151
152 /// Check a batch against the producer's transaction state just before it
153 /// appends, under the same lock as the append.
154 ///
155 /// A transactional data batch with no open transaction at its epoch must
156 /// present the producer's current guard. A non-transactional data batch
157 /// from a producer with an open transaction is refused. This is Kafka's
158 /// check in `UnifiedLog.analyzeAndValidateProducerState` and in
159 /// `ProducerAppendInfo.appendDataBatch`.
160 ///
161 /// # Errors
162 ///
163 /// Returns the refusal that Kafka's append throws.
164 pub fn check_transactional_append(
165 &self,
166 batch: TransactionalBatch,
167 presented: VerificationGuard,
168 ) -> Result<(), TransactionAppendRefusal> {
169 if batch.producer_id.get() < 0 || batch.is_control {
170 return Ok(());
171 }
172 let entry = self.producer_state.get(&batch.producer_id);
173 if !batch.is_transactional {
174 return if entry.is_some_and(|entry| entry.current_txn_first_offset.is_some()) {
175 Err(TransactionAppendRefusal::InvalidTransactionState)
176 } else {
177 Ok(())
178 };
179 }
180 if self.has_ongoing_transaction(batch.producer_id, batch.producer_epoch) {
181 return Ok(());
182 }
183 if entry.is_some_and(|entry| batch.producer_epoch < entry.producer_epoch) {
184 return Err(TransactionAppendRefusal::StaleProducerEpoch);
185 }
186 let Some(state) = self
187 .verification_states
188 .get(&batch.producer_id)
189 .filter(|state| state.guard.verifies(presented))
190 else {
191 return Err(TransactionAppendRefusal::InvalidTransactionState);
192 };
193 // Kafka's `ProducerAppendInfo.checkSequence` with a verification
194 // state: a transaction version 2 producer with no retained batch on
195 // the partition starts at 0, and no batch may start above the lowest
196 // sequence a verification saw.
197 let has_batch = entry.is_some_and(|entry| entry.last_offset.0 >= 0);
198 if (state.supports_epoch_bump && batch.base_sequence != 0 && !has_batch)
199 || batch.base_sequence > state.lowest_sequence
200 {
201 return Err(TransactionAppendRefusal::OutOfOrderSequence);
202 }
203 Ok(())
204 }
205
206 /// Drop the verification state after a transactional append, as Kafka's
207 /// `UnifiedLog.updateProducers` does. A data batch now has an open
208 /// transaction, and a marker ended one. The one exception is a transaction
209 /// version 2 marker at the verified epoch: the producer already started
210 /// its next transaction at that epoch, so its guard stays.
211 pub(super) fn clear_verification_after_append(
212 &mut self,
213 producer_id: ProducerId,
214 producer_epoch: i16,
215 is_control: bool,
216 ) {
217 let next_transaction_started =
218 self.verification_states
219 .get(&producer_id)
220 .is_some_and(|state| {
221 state.supports_epoch_bump && is_control && producer_epoch == state.epoch
222 });
223 if !next_transaction_started {
224 self.verification_states.remove(&producer_id);
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests;