Skip to main content

krabka_log/log/
open.rs

1//! Opening a log directory and rebuilding the producer, transaction, and
2//! stamp state that no sidecar file holds.
3//!
4//! Recovery restores the newest valid producer snapshot and then replays
5//! the log tail that snapshot does not cover, so a reopened log reaches
6//! exactly the state the append path would have left.
7
8#[cfg(not(target_os = "wasi"))]
9use std::collections::VecDeque;
10use {
11    super::{
12        Log,
13        control::{
14            ControlBatchKind, TransactionMarkerFlags, batch_control_marker_coordinator_epoch,
15            control_batch_kind, transaction_marker_flags,
16        },
17        pending_transactions::PendingTransactions,
18    },
19    crate::{
20        config::LogConfig,
21        error::LogError,
22        io::FileIo,
23        leader_epoch_checkpoint::LeaderEpochCheckpoint,
24        log_start_offset_checkpoint, name,
25        producer_snapshot::{self, LoadedSnapshot, NUM_BATCHES_TO_RETAIN, ProducerSnapshotEntry},
26        recovery::{deleted_orphan_recover, swap_orphan_recover},
27        segment::Segment,
28        stamp_index::OffsetRange,
29        txn_index::TxnIndex,
30    },
31    krabka_ids::{Offset, ProducerId},
32    krabka_protocol::records::RecordBatch,
33    krabka_verified::{ProducerReloadRange, increment_sequence},
34    std::{
35        collections::{BTreeMap, BTreeSet, HashMap},
36        fs,
37        path::Path,
38        sync::{Arc, PoisonError, RwLock, atomic::AtomicU64},
39        time::Instant,
40    },
41    tracing::{field, instrument},
42};
43
44/// The last offset of a replayed batch and the offset the replay cursor
45/// advances to after it.
46#[derive(Debug, PartialEq, Eq)]
47pub(super) struct RecoveredBatchOffsets {
48    pub(super) last: Offset,
49    pub(super) advanced_to: Offset,
50}
51
52/// Work performed while reconstructing the in-memory ranges of transactions
53/// that survived producer-state recovery as open.
54#[derive(Debug, Default, PartialEq, Eq)]
55pub(super) struct PendingStampRecoveryStats {
56    #[cfg(test)]
57    pub(super) batches: usize,
58    #[cfg(test)]
59    pub(super) bytes: usize,
60}
61
62impl PendingStampRecoveryStats {
63    fn record(&mut self, batch: &RecordBatch) {
64        #[cfg(test)]
65        {
66            self.batches = self.batches.saturating_add(1);
67            self.bytes = self.bytes.saturating_add(batch.encoded_len());
68        }
69        // Only a test build counts; the others take the arguments and drop
70        // them.
71        let _ = (self, batch);
72    }
73}
74
75/// The sequence and offset a producer's entry ends at after one data batch.
76#[derive(Debug, PartialEq, Eq)]
77pub(super) struct ProducerTail {
78    pub(super) last_sequence: i32,
79    pub(super) last_offset: Offset,
80}
81
82/// One data batch a producer appended, as the producer state records it.
83#[derive(Debug, Clone, Copy)]
84pub(super) struct DataProducerAppend {
85    pub(super) producer_id: ProducerId,
86    pub(super) producer_epoch: i16,
87    pub(super) base_sequence: i32,
88    pub(super) last_offset_delta: i32,
89    pub(super) base_offset: Offset,
90    pub(super) timestamp: i64,
91    pub(super) is_transactional: bool,
92}
93
94impl Log {
95    /// Open or create a `Log` at `dir`.
96    ///
97    /// This method finds existing segments by `.log` filename and marks all
98    /// but the latest as sealed. If the directory is empty, it creates a
99    /// fresh active segment at offset 0.
100    #[instrument(
101        level = "info",
102        skip_all,
103        fields(
104            dir = %dir.as_ref().display(),
105            segments = field::Empty,
106            log_end = field::Empty,
107        ),
108        err,
109    )]
110    // cargo-mutants: the only mutant here is the `segments.len() + 1` in the `span.record`
111    // call, a tracing-span diagnostic field with no behavioral effect. The
112    // sibling `seal_at(next_base - 1)` recovery arithmetic is separately pinned
113    // by `reopen_seals_recovered_segments_at_next_base_minus_one`.
114    #[cfg_attr(test, mutants::skip)]
115    /// # Errors
116    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
117    pub fn open(dir: impl AsRef<Path>, config: LogConfig) -> Result<Self, LogError> {
118        let dir = dir.as_ref().to_path_buf();
119        fs::create_dir_all(&dir)?;
120
121        // Heal any orphaned compaction `.swap` files before
122        // we scan the directory for segments.
123        swap_orphan_recover(&dir)?;
124        // …and reclaim what an interrupted segment deletion left: `.deleted`
125        // tombstones, and sidecars whose `.log` the scan below can no longer
126        // see. Both are invisible to every later pass otherwise.
127        deleted_orphan_recover(&FileIo, &dir)?;
128
129        let mut base_offsets: Vec<i64> = Vec::new();
130        for entry in fs::read_dir(&dir)? {
131            let entry = entry?;
132            let Ok(file_name) = entry.file_name().into_string() else {
133                continue; // non-UTF-8 names: ignore (unlikely)
134            };
135            if let Ok(base) = name::parse_log_filename(&file_name) {
136                base_offsets.push(base);
137            }
138        }
139        base_offsets.sort_unstable();
140        base_offsets.dedup();
141        if !krabka_verified::local_recovery_segment_chain(&base_offsets) {
142            return Err(LogError::Corrupt(
143                "log segment bases are not a nonnegative ordered chain".into(),
144            ));
145        }
146
147        let mut segments: Vec<Segment> = Vec::with_capacity(base_offsets.len());
148        let mut active: Option<Segment> = None;
149        for (i, base) in base_offsets.iter().enumerate() {
150            if let Some(&next_base) = base_offsets.get(i + 1) {
151                let mut seg = Segment::open_unscanned(&dir, Offset(*base))?;
152                // The no-scan loader leaves
153                // `last_offset = base - 1`. A sealed segment's true last offset
154                // is one below the next segment's base; set it so `read_raw`
155                // (which skips a segment whose `last_offset() < fetch_offset`)
156                // doesn't skip this recovered segment and serve a later base
157                // offset — which after a restart manufactures an offset gap that
158                // strands a follower fetching from a low offset.
159                let last = krabka_verified::local_recovery_sealed_last(*base, next_base)
160                    .ok_or_else(|| LogError::Corrupt("invalid sealed segment boundary".into()))?;
161                seg.seal_at(Offset(last));
162                // The no-scan loader also leaves `max_timestamp` unknown. Without
163                // this restore, retention would age every reopened segment by
164                // its file's modification time -- Kafka's `largestTimestamp()`
165                // fallback for a segment with no record timestamp -- instead
166                // of by its newest record.
167                seg.restore_max_timestamp()?;
168                segments.push(seg);
169            } else {
170                active = Some(Segment::open_active_with_index_interval(
171                    &dir,
172                    Offset(*base),
173                    config.validate_on_open,
174                    config.index_interval,
175                )?);
176            }
177        }
178
179        let (active, dir_sync_needed) = match active {
180            // We cannot know whether the process that created this segment
181            // fsynced the parent directory before crashing. Conservatively
182            // require one directory fsync on the next explicit `sync()` so a
183            // diskless WAL ack never relies only on file data durability.
184            Some(s) => (s, true),
185            None => (Segment::create(&dir, Offset(0))?, true),
186        };
187
188        let sealed_txn_indexes = segments
189            .iter()
190            .map(|segment| {
191                Ok((
192                    segment.base_offset(),
193                    TxnIndex::open(segment.txn_index_path())?,
194                ))
195            })
196            .collect::<Result<BTreeMap<_, _>, LogError>>()?;
197        let active_txn_index = TxnIndex::open(active.txn_index_path())?;
198        let mut epoch_checkpoint =
199            LeaderEpochCheckpoint::open(active.leader_epoch_checkpoint_path())?;
200        // LSO starts at log_end_offset(); computed before moving `active`.
201        let lso = active.last_offset() + 1;
202        epoch_checkpoint.truncate_from_end(lso)?;
203
204        let remote_storage_enable = config.remote_storage_enable;
205        let config = Arc::new(RwLock::new(config));
206
207        let span = tracing::Span::current();
208        span.record("segments", segments.len() + 1);
209        span.record("log_end", lso.0);
210
211        // The segment names witness only where the surviving files begin. The
212        // checkpoint below carries the rest, and is what turns this into a
213        // floor somebody deleted up to.
214        let start_offset = segments
215            .first()
216            .map_or_else(|| active.base_offset(), Segment::base_offset);
217
218        let mut log = Self {
219            dir,
220            config,
221            io: Arc::new(FileIo),
222            segments,
223            active: Some(active),
224            dir_sync_needed,
225            rollover_flusher: super::rollover_flush::Flusher::default(),
226            compaction_gate: Arc::default(),
227            fenced: false,
228            opened_end: Offset(0),
229            unflushed_messages: 0,
230            last_flush: Instant::now(),
231            #[cfg(not(target_os = "wasi"))]
232            writeback_unsynced: 0,
233            #[cfg(not(target_os = "wasi"))]
234            writeback_jobs: VecDeque::new(),
235            #[cfg(not(target_os = "wasi"))]
236            writeback_handed: 0,
237            truncations: Arc::new(AtomicU64::new(0)),
238            roll_jitter: None,
239            pending_deletes: Vec::new(),
240            start_offset,
241            // Derived from the files on disk, not deleted up to by anyone.
242            // The checkpoint restore below is what may set it: see
243            // `Log::established_log_start`.
244            start_offset_established: false,
245            checkpointed_start: None,
246            start_checkpoint_due: None,
247            // Nothing has cleaned this log yet, so every sealed segment is
248            // dirty until the first pass says otherwise.
249            clean_prefix_segments: 0,
250            lso,
251            pending: PendingTransactions::default(),
252            verification_states: HashMap::new(),
253            unreplicated: BTreeMap::new(),
254            pending_stamp_ranges: HashMap::new(),
255            coordinator_epochs: HashMap::new(),
256            producer_state: HashMap::new(),
257            earlier_batches: HashMap::new(),
258            active_txn_index,
259            sealed_txn_indexes,
260            stamp_source: None,
261            stamp_indexes: BTreeMap::new(),
262            epoch_checkpoint,
263            reconciled_frontier: Offset(0),
264            delivery_watermark: Offset(0),
265            delivery_pending_ms: None,
266        };
267        // Restore a log start that the segment names cannot express: a trim
268        // that landed inside a segment left its records on disk, and only the
269        // checkpoint says they are gone.
270        let checkpoint = log_start_offset_checkpoint::read(&log.dir)?;
271        log.checkpointed_start = checkpoint;
272        if let Some(checkpointed) = checkpoint {
273            // Cap at the log end. Above it, the whole surviving log is below a
274            // start that was acknowledged, so every record left is one the
275            // trim removed and the log start is the log end -- a crash between
276            // a trim and the fsync of the records it trimmed past lands there.
277            let capped = checkpointed.min(log.log_end_offset());
278            // Kafka's `LogLoader.load`: without remote storage the start is
279            // never below the first segment, whose base offset is the start
280            // derived above. Retention writes the start it moves on a timer,
281            // so a crash before the timer leaves an older, lower value here.
282            //
283            // A tiered partition (KIP-405) gets no such floor. Its global
284            // start belongs *below* the oldest local segment, in the band the
285            // archive serves, and raising the checkpoint to meet the surviving
286            // files would hide every offset the remote tier still holds.
287            // `Log::local_log_start_offset` is the floor that follows the
288            // files there.
289            let effective = if remote_storage_enable {
290                capped
291            } else {
292                capped.max(log.start_offset)
293            };
294            log.start_offset = effective;
295            // A checkpoint exists, so somebody deleted up to this floor: it is
296            // one a reader may be refused against and remote retention may
297            // delete against.
298            log.start_offset_established = true;
299            if capped != checkpointed {
300                // Persist what was resolved instead of leaving the
301                // out-of-range value on disk. It is inert against this log,
302                // but appends move the log end, and the next open would find
303                // the stale value back in range and hide live records.
304                log.write_start_checkpoint(effective)?;
305            } else if effective != capped {
306                // A start raised to the first segment goes out on the timer
307                // instead: the segment names record it, and every open of this
308                // log raises it again until then.
309                log.defer_start_checkpoint(effective, Instant::now());
310            }
311            // Kafka's `LogLoader.load` → `truncateFromStart(logStartOffsetCheckpoint)`:
312            // a crash can land between a start increment and the epoch-cache
313            // rewrite that followed it, so the reload trims the cache again.
314            log.epoch_checkpoint.truncate_from_start(effective)?;
315        }
316        // Kafka's `LogLoader.load` first drops every snapshot no segment
317        // accounts for, then reloads against the log start it just restored,
318        // so producer state is rebuilt only after the checkpoint is applied.
319        let segment_bases: Vec<i64> = log
320            .segments
321            .iter()
322            .chain(log.active.iter())
323            .map(|segment| segment.base_offset().0)
324            .collect();
325        producer_snapshot::remove_strays(&log.dir, &segment_bases)?;
326        log.rebuild_producer_and_transaction_state()?;
327        // Recovery needs no durable watermark: the schedule is in the records,
328        // so the first advance rebuilds it from the log start.
329        log.delivery_watermark = log.log_start_offset();
330        log.opened_end = log.log_end_offset();
331        Ok(log)
332    }
333
334    /// The range a producer-state reload up to `log_end` runs against.
335    ///
336    /// The log start is the one Kafka's `LogLoader.load` hands
337    /// `truncateAndReload`: on a tiered partition (KIP-405) the checkpointed
338    /// floor, read as 0 when nobody established one, and otherwise the
339    /// greater of that floor and the oldest local segment's base.
340    pub(super) fn producer_reload_range(&self, log_end: Offset) -> ProducerReloadRange {
341        let local_start = self.local_log_start_offset();
342        ProducerReloadRange {
343            log_start: krabka_verified::producer_snapshot_reload_log_start(
344                self.config
345                    .read()
346                    .unwrap_or_else(PoisonError::into_inner)
347                    .remote_storage_enable,
348                self.established_log_start().map(|start| start.0),
349                local_start.0,
350            ),
351            local_start: local_start.0,
352            log_end: log_end.0,
353        }
354    }
355
356    /// Rebuild producer state the way Kafka's
357    /// `UnifiedLog.rebuildProducerState` does: reload the newest snapshot in
358    /// `(log start, log end]`, deleting every snapshot outside that range,
359    /// then replay the log tail the snapshot does not cover.
360    ///
361    /// A producer whose last batch lies below the log start is kept only when
362    /// a surviving snapshot carries it, exactly as in Kafka. Missing boundary
363    /// snapshots are created during the replay so every sealed segment can be
364    /// copied to remote storage with its matching producer state, and a
365    /// snapshot is taken at the log end once the replay is done.
366    pub(super) fn rebuild_producer_and_transaction_state(&mut self) -> Result<(), LogError> {
367        self.clear_producer_and_transaction_state();
368        let end = self.log_end_offset();
369        let range = self.producer_reload_range(end);
370        let snapshot = producer_snapshot::reload(&self.dir, range)?;
371        let snapshot_offset = snapshot.as_ref().map(|loaded| loaded.offset.0);
372        if let Some(LoadedSnapshot { state, .. }) = snapshot {
373            self.producer_state = state;
374            for (&producer_id, entry) in &self.producer_state {
375                if let Some(first_offset) = entry.current_txn_first_offset {
376                    self.pending.insert(producer_id, first_offset);
377                }
378                if entry.coordinator_epoch >= 0 {
379                    self.coordinator_epochs
380                        .insert(producer_id, entry.coordinator_epoch);
381                }
382            }
383        }
384        let mut next = krabka_verified::producer_snapshot_replay_start(range, snapshot_offset)
385            .map(Offset)
386            .ok_or_else(|| LogError::Corrupt("invalid producer snapshot replay frontier".into()))?;
387
388        let mut boundaries: BTreeSet<Offset> = self
389            .segments
390            .iter()
391            .map(Segment::base_offset)
392            .skip(1)
393            .chain(self.active.iter().map(Segment::base_offset))
394            .collect();
395        let mut boundaries = boundaries.split_off(&next);
396        let _ = boundaries.remove(&next);
397        let mut first_read = true;
398        while next < end {
399            let read = self.read(next, krabka_units::mebibytes(1))?;
400            if read.batches.is_empty() {
401                return Err(LogError::Corrupt(format!(
402                    "producer-state recovery made no progress at offset {next}"
403                )));
404            }
405            let mut advanced_to = next;
406            for (index, batch) in read.batches.iter().enumerate() {
407                let cursor = Self::replay_cursor(first_read && index == 0, advanced_to, batch);
408                advanced_to = Self::recovered_batch_offsets(cursor, end, batch)?.advanced_to;
409                self.apply_recovered_batch_state(batch)?;
410                let covered: Vec<_> = boundaries.range(..=advanced_to).copied().collect();
411                for boundary in covered {
412                    producer_snapshot::write(&*self.io, &self.dir, boundary, &self.producer_state)?;
413                    let _ = boundaries.remove(&boundary);
414                }
415            }
416            if advanced_to <= next {
417                return Err(LogError::Corrupt(format!(
418                    "producer-state recovery did not advance past offset {next}"
419                )));
420            }
421            next = advanced_to;
422            first_read = false;
423        }
424        // Kafka's `rebuildProducerState` ends with `updateMapEndOffset(lastOffset)`
425        // and `takeSnapshot()`, which writes a snapshot at the log end unless
426        // the map end is no further than the last snapshot taken. The next
427        // reload then starts from here instead of replaying the tail again.
428        // `write` keeps a snapshot already at `end`, which a replay boundary
429        // or the loaded snapshot put there with this same state.
430        if end.0 > range.log_start {
431            producer_snapshot::write(&*self.io, &self.dir, end, &self.producer_state)?;
432        }
433        let _ = self.rebuild_pending_stamp_ranges()?;
434        self.refresh_lso()?;
435        Ok(())
436    }
437
438    /// The cursor a replayed batch must start at or after.
439    ///
440    /// The replay can start inside a batch, where a trim left the log start.
441    /// Kafka's `LogSegment.read(startOffset, ..)` then begins with the batch
442    /// that holds `startOffset`, so the first batch of a replay may start
443    /// below the cursor. Every later batch must start at or after it.
444    fn replay_cursor(first: bool, cursor: Offset, batch: &RecordBatch) -> Offset {
445        if first {
446            cursor.min(Offset(batch.base_offset))
447        } else {
448            cursor
449        }
450    }
451
452    fn apply_recovered_batch_state(&mut self, batch: &RecordBatch) -> Result<(), LogError> {
453        if control_batch_kind(batch) == Some(ControlBatchKind::Barrier) {
454            // The append path keeps no producer state and no transaction state
455            // for a barrier marker. Recovery reaches the same result.
456            return Ok(());
457        }
458        let producer_id = ProducerId(batch.producer_id);
459        if producer_id.get() < 0 {
460            return Ok(());
461        }
462        self.update_owned_producer_entry(batch)?;
463        if batch.attributes.is_control_batch() {
464            let TransactionMarkerFlags {
465                is_abort,
466                is_commit,
467            } = transaction_marker_flags(batch)?;
468            if krabka_verified::transaction_marker_closes(
469                is_abort,
470                is_commit,
471                self.pending.contains_key(producer_id),
472            ) && let Some(first_offset) = self.pending.remove(producer_id)
473            {
474                self.unreplicated.insert(
475                    first_offset,
476                    Offset(batch.base_offset + i64::from(batch.last_offset_delta)),
477                );
478            }
479            if (is_abort || is_commit)
480                && let Some(epoch) = batch_control_marker_coordinator_epoch(batch)?
481            {
482                self.coordinator_epochs.insert(producer_id, epoch);
483            }
484        } else if batch.attributes.is_transactional() {
485            self.pending
486                .insert_first(producer_id, Offset(batch.base_offset));
487        }
488        Ok(())
489    }
490
491    fn rebuild_pending_stamp_ranges(&mut self) -> Result<PendingStampRecoveryStats, LogError> {
492        self.pending_stamp_ranges.clear();
493        if self.pending.is_empty() {
494            return Ok(PendingStampRecoveryStats::default());
495        }
496        // The snapshot tells us exactly which transactions remain open and
497        // where each began. Nothing before the earliest surviving beginning
498        // can contribute a range to those transactions. Clamp that beginning
499        // to the oldest local segment for tiered retention and truncation.
500        // `read` deliberately returns a batch covering this floor, so a first
501        // offset inside a retained batch still reconstructs the whole range.
502        let local_start = self.local_log_start_offset();
503        let mut next = self
504            .pending
505            .values()
506            .copied()
507            .min()
508            .unwrap_or(local_start)
509            .max(local_start);
510        let end = self.log_end_offset();
511        let mut is_first_read = true;
512        let mut stats = PendingStampRecoveryStats::default();
513        while next < end {
514            let slice = self.read(next, krabka_units::mebibytes(1))?;
515            if slice.batches.is_empty() {
516                let msg = format!("transaction-stamp recovery made no progress at offset {next}");
517                return Err(LogError::Corrupt(msg));
518            }
519            for (index, batch) in slice.batches.iter().enumerate() {
520                let producer_id = ProducerId(batch.producer_id);
521                let cursor = Self::replay_cursor(is_first_read && index == 0, next, batch);
522                let RecoveredBatchOffsets { last, advanced_to } =
523                    Self::recovered_batch_offsets(cursor, end, batch)?;
524                stats.record(batch);
525                match control_batch_kind(batch) {
526                    // A barrier marker closes no transaction, so it clears no
527                    // stamp range. The append path reaches the same result.
528                    Some(ControlBatchKind::Barrier) => {}
529                    Some(ControlBatchKind::Transaction) => {
530                        self.pending_stamp_ranges.remove(&producer_id);
531                    }
532                    None => {
533                        if batch.attributes.is_transactional()
534                            && producer_id.get() >= 0
535                            && self.pending.contains_key(producer_id)
536                        {
537                            self.pending_stamp_ranges
538                                .entry(producer_id)
539                                .or_default()
540                                .push(OffsetRange {
541                                    first: Offset(batch.base_offset),
542                                    last,
543                                });
544                        }
545                    }
546                }
547                next = advanced_to;
548            }
549            is_first_read = false;
550        }
551        Ok(stats)
552    }
553
554    fn recovered_batch_offsets(
555        current: Offset,
556        end: Offset,
557        batch: &RecordBatch,
558    ) -> Result<RecoveredBatchOffsets, LogError> {
559        match krabka_verified::replay_batch_cursor_decision(
560            current.0,
561            end.0,
562            Some((batch.base_offset, batch.last_offset_delta)),
563        ) {
564            krabka_verified::ReplayCursorDecision::Advance(next) => Ok(RecoveredBatchOffsets {
565                last: Offset(next - 1),
566                advanced_to: Offset(next),
567            }),
568            krabka_verified::ReplayCursorDecision::Stop => Err(LogError::Corrupt(format!(
569                "log recovery rejected batch at offset {current} before end {end}"
570            ))),
571        }
572    }
573
574    pub(super) fn update_owned_producer_entry(
575        &mut self,
576        batch: &RecordBatch,
577    ) -> Result<(), LogError> {
578        let producer_id = ProducerId(batch.producer_id);
579        if producer_id.get() < 0 {
580            return Ok(());
581        }
582        if batch.attributes.is_control_batch() {
583            let entry = self
584                .producer_state
585                .entry(producer_id)
586                .or_insert_with(|| ProducerSnapshotEntry::empty(producer_id, batch.producer_epoch));
587            if entry.producer_epoch != batch.producer_epoch {
588                // Kafka clears the retained data-batch metadata when an end
589                // marker advances the producer epoch (transaction version 2).
590                self.earlier_batches.remove(&producer_id);
591                entry.last_sequence = -1;
592                entry.last_offset = Offset(-1);
593                entry.offset_delta = 0;
594            }
595            entry.producer_epoch = batch.producer_epoch;
596            entry.timestamp = batch.max_timestamp;
597            let TransactionMarkerFlags {
598                is_abort,
599                is_commit,
600            } = transaction_marker_flags(batch)?;
601            if is_abort || is_commit {
602                entry.current_txn_first_offset = None;
603            }
604            if (is_abort || is_commit)
605                && let Some(epoch) = batch_control_marker_coordinator_epoch(batch)?
606            {
607                entry.coordinator_epoch = epoch;
608            }
609            return Ok(());
610        }
611        self.update_data_producer_entry(DataProducerAppend {
612            producer_id,
613            producer_epoch: batch.producer_epoch,
614            base_sequence: batch.base_sequence,
615            last_offset_delta: batch.last_offset_delta,
616            base_offset: Offset(batch.base_offset),
617            timestamp: batch.max_timestamp,
618            is_transactional: batch.attributes.is_transactional(),
619        })
620    }
621
622    pub(super) fn update_data_producer_entry(
623        &mut self,
624        append: DataProducerAppend,
625    ) -> Result<(), LogError> {
626        let DataProducerAppend {
627            producer_id,
628            producer_epoch,
629            base_sequence,
630            last_offset_delta,
631            base_offset,
632            timestamp,
633            is_transactional,
634        } = append;
635        let Some(ProducerTail {
636            last_sequence,
637            last_offset,
638        }) = Self::data_producer_tail(producer_id, base_sequence, last_offset_delta, base_offset)?
639        else {
640            return Ok(());
641        };
642        let entry = self
643            .producer_state
644            .entry(producer_id)
645            .or_insert_with(|| ProducerSnapshotEntry::empty(producer_id, producer_epoch));
646        // Kafka's `ProducerStateEntry.addBatch`: a new epoch clears the
647        // retained batches, and the batch that was last joins the earlier
648        // ones, the oldest leaving at capacity.
649        if entry.producer_epoch != producer_epoch {
650            self.earlier_batches.remove(&producer_id);
651        } else if let Some(previous) = entry.last_batch() {
652            let earlier = self.earlier_batches.entry(producer_id).or_default();
653            earlier.push_back(previous);
654            if earlier.len() >= NUM_BATCHES_TO_RETAIN {
655                earlier.pop_front();
656            }
657        }
658        entry.producer_epoch = producer_epoch;
659        entry.last_sequence = last_sequence;
660        entry.last_offset = last_offset;
661        entry.offset_delta = last_offset_delta;
662        entry.timestamp = timestamp;
663        if is_transactional && entry.current_txn_first_offset.is_none() {
664            entry.current_txn_first_offset = Some(base_offset);
665        }
666        Ok(())
667    }
668
669    pub(super) fn data_producer_tail(
670        producer_id: ProducerId,
671        base_sequence: i32,
672        last_offset_delta: i32,
673        base_offset: Offset,
674    ) -> Result<Option<ProducerTail>, LogError> {
675        if producer_id.get() < 0 || base_sequence < 0 {
676            return Ok(None);
677        }
678        if last_offset_delta < 0 {
679            return Err(LogError::InvalidArgument(format!(
680                "negative producer offset delta for producer {producer_id}"
681            )));
682        }
683        let last_sequence = increment_sequence(base_sequence, last_offset_delta);
684        let last_offset = base_offset
685            .0
686            .checked_add(i64::from(last_offset_delta))
687            .map(Offset)
688            .ok_or_else(|| {
689                LogError::InvalidArgument(format!(
690                    "producer offset overflow for producer {producer_id}"
691                ))
692            })?;
693        Ok(Some(ProducerTail {
694            last_sequence,
695            last_offset,
696        }))
697    }
698}
699
700#[cfg(test)]
701mod tests;