krabka_log/log/state.rs
1//! Log-level accessors and the whole-log reset.
2//!
3//! These are the coordinates the broker reads off a partition -- the log
4//! start and end, the size, the last stable offset, the producer and
5//! transaction state -- together with the config swap and the hard reset
6//! that empties the log at a new base offset.
7
8#[cfg(any(test, feature = "test-helpers"))]
9use crate::io::LogIo;
10use {
11 super::Log,
12 crate::{
13 DeliveryPolicy, RecoveredProducer, SegmentAllocation, VerbatimBatch,
14 config::LogConfig,
15 error::LogError,
16 leader_epoch_checkpoint::LeaderEpochCheckpoint,
17 log_start_offset_checkpoint,
18 producer_snapshot::{self, ProducerSnapshotEntry},
19 segment::Segment,
20 txn_index::{AbortedTxn, TxnIndex},
21 },
22 krabka_ids::{Offset, ProducerId},
23 krabka_protocol::records::{Attributes, CRC_COVERAGE_START, HEADER_LEN, TimestampType},
24 krabka_units::prelude::{ByteSize, ByteSizeExt},
25 std::{
26 iter,
27 path::Path,
28 sync::{Arc, PoisonError, atomic::Ordering},
29 time::{Instant, SystemTime},
30 },
31 tracing::instrument,
32};
33
34/// When [`Log::advance_log_start_offset`] writes `log-start-offset-checkpoint`.
35#[derive(Clone, Copy)]
36enum StartCheckpoint {
37 /// Before it returns.
38 Now,
39 /// After [`LogConfig::start_offset_checkpoint_interval`], counted
40 /// from this instant.
41 Deferred(Instant),
42}
43
44impl Log {
45 /// Empty the volatile state before a hard reset or a durable producer-state reload.
46 pub(super) fn clear_producer_and_transaction_state(&mut self) {
47 self.pending.clear();
48 self.verification_states.clear();
49 self.unreplicated.clear();
50 self.pending_stamp_ranges.clear();
51 self.coordinator_epochs.clear();
52 self.producer_state.clear();
53 self.earlier_batches.clear();
54 }
55
56 /// Directory this log was opened against. The broker's intra-broker
57 /// log-dir reassignment (KIP-113) reads this to find the current owning
58 /// `log.dir` of a partition. The broker does not have to repeat the
59 /// directory-layout convention.
60 #[must_use]
61 pub fn dir(&self) -> &Path { &self.dir }
62
63 /// First absolute offset any reader may ask for, wherever the records for
64 /// it live: Kafka's global `logStartOffset`.
65 ///
66 /// On a tiered topic (KIP-405) this can sit below
67 /// [`Log::local_log_start_offset`], and the offsets between the two are
68 /// the ones the remote tier serves. A fetch below *this* floor is
69 /// `OFFSET_OUT_OF_RANGE` and no tier answers it.
70 #[must_use]
71 pub fn log_start_offset(&self) -> Offset { self.start_offset }
72
73 /// The global floor, when this process is the one that moved it.
74 ///
75 /// `None` on a log whose floor was only inferred from the segments present
76 /// at [`Log::open`]. A caller that would *refuse* or *delete* on the
77 /// strength of the floor must use this rather than
78 /// [`Log::log_start_offset`]: on a tiered partition whose local segments
79 /// were evicted, the inferred floor sits above everything the archive
80 /// holds, so refusing below it hides readable records and deleting below
81 /// it destroys them. The log-start checkpoint carries an established floor
82 /// across a restart, so a reopened log answers `None` only when nobody has
83 /// moved it.
84 #[must_use]
85 pub fn established_log_start(&self) -> Option<Offset> {
86 self.start_offset_established.then_some(self.start_offset)
87 }
88
89 /// The global floor as a leader reports it to the replicas that follow
90 /// it: Kafka's `UnifiedLog.logStartOffset` in a `Fetch` response.
91 ///
92 /// This is [`Log::log_start_offset`] except on a remote-storage-enabled
93 /// log whose floor nobody established. Such a log reopened over the
94 /// segments local retention left, so its inferred start sits above
95 /// everything the archive holds. Kafka's `LogLoader.load` never infers a
96 /// remote-storage-enabled log's start from its segments: it takes the
97 /// log-start checkpoint, which is 0 when nobody moved it. A follower
98 /// raises its own start to what its leader reports, and a follower's start
99 /// is a floor it refuses reads below and deletes the archive below once it
100 /// leads, so reporting the inferred start would hide and then destroy
101 /// every archived record under it.
102 #[must_use]
103 pub fn reported_log_start(&self) -> Offset {
104 match self.established_log_start() {
105 Some(start) => start,
106 None if self
107 .config
108 .read()
109 .unwrap_or_else(PoisonError::into_inner)
110 .remote_storage_enable =>
111 {
112 Offset(0)
113 }
114 None => self.start_offset,
115 }
116 }
117
118 /// The epoch of the earliest leader-epoch entry, when a log start that
119 /// somebody established truncated the cache to it: Kafka's
120 /// `leaderEpochCache.earliestEntry` after `truncateFromStart(logStartOffset)`.
121 ///
122 /// Only then does the entry say something about the remote tier. Kafka's
123 /// `RemoteLogManager` deletes a remote segment whose epochs all lie below
124 /// it (`deleteLogSegmentsDueToLeaderEpochCacheTruncation`), which is right
125 /// for the epoch that owns the log start, because every offset from there
126 /// on has that epoch or a later one. An earliest entry that no advance of
127 /// the log start put there (a cache that never held the older epochs, or
128 /// one cleared by [`Log::reset_to`] and refilled from the fetched
129 /// records) says nothing about them, so this answers `None`. The epoch
130 /// cache is cut from the start only by [`Log::set_log_start_offset`] and
131 /// by the log-start checkpoint [`Log::open`] restores, never by the
132 /// segments a tiered partition still holds locally, so a restart does not
133 /// move it.
134 #[must_use]
135 pub fn log_start_epoch(&self) -> Option<krabka_ids::LeaderEpoch> {
136 let start = self.established_log_start()?;
137 self.epoch_checkpoint
138 .entries()
139 .first()
140 .filter(|entry| entry.start_offset == start)
141 .map(|entry| entry.epoch)
142 }
143
144 /// The base offset of the log's first segment on disk, sealed or active,
145 /// or `None` when it holds none: Kafka's `logSegments().get(0).baseOffset()`.
146 #[must_use]
147 pub fn first_segment_base(&self) -> Option<Offset> {
148 self.segments
149 .first()
150 .or(self.active.as_ref())
151 .map(Segment::base_offset)
152 }
153
154 /// The first offset the segments on disk begin at, before the global
155 /// floor is applied.
156 pub(super) fn first_local_offset(&self) -> Offset {
157 self.segments
158 .first()
159 .or(self.active.as_ref())
160 .map_or(Offset(0), Segment::base_offset)
161 }
162
163 /// Move the global log start up to `new_start`.
164 ///
165 /// The pointer only ever moves forward, the way Kafka's
166 /// `maybeIncrementLogStartOffset` does: a request that names an offset at
167 /// or below the current floor is a no-op. `trim_to_offset` uses this
168 /// method for the active-segment case, the broker's `DeleteRecords`
169 /// handler uses it, and the `RemoteLogManager` uses it after a remote
170 /// segment is deleted. This method does NOT truncate on-disk segments. It
171 /// only moves the start pointer.
172 ///
173 /// The new value is checkpointed to `log-start-offset-checkpoint` before
174 /// this method returns, so a start no segment name witnesses -- a trim
175 /// that landed inside a segment, or a tiered floor below every segment
176 /// still on disk -- survives a reopen. Without that, [`Log::open`] derives
177 /// the start from the first surviving base offset and serves records that
178 /// a `DeleteRecords` already deleted. Retention moves the start to a
179 /// segment boundary instead and defers the write; see
180 /// [`Log::checkpoint_log_start_if_due`].
181 ///
182 /// When the start does move up, the rest of the log follows it the way
183 /// Kafka's `maybeIncrementLogStartOffset` makes it follow:
184 ///
185 /// - the leader-epoch cache drops what lies below the new start
186 /// (`leaderEpochCache.truncateFromStart`, see
187 /// [`LeaderEpochCheckpoint::truncate_from_start`]);
188 /// - a complete transaction whose marker lies below the new start stops
189 /// holding the last stable offset
190 /// (`ProducerStateManager.onLogStartOffsetIncremented` →
191 /// `removeUnreplicatedTransactions`), because nothing a leader change
192 /// could truncate is left of it;
193 /// - the first unstable offset is recomputed (`maybeIncrementFirstUnstableOffset`).
194 ///
195 /// `new_start` must be non-negative.
196 ///
197 /// # Errors
198 ///
199 /// Returns [`LogError::InvalidArgument`] if `new_start` is negative, and
200 /// [`LogError::Io`] if the log-start or leader-epoch checkpoint cannot be
201 /// written.
202 pub fn set_log_start_offset(&mut self, new_start: Offset) -> Result<(), LogError> {
203 self.advance_log_start_offset(new_start, StartCheckpoint::Now)
204 }
205
206 /// [`Log::set_log_start_offset`] for retention, which moves the start to
207 /// the base offset of the first segment it keeps. The segment names
208 /// already record that start, so the checkpoint write waits for
209 /// [`Log::checkpoint_log_start_if_due`], as Kafka's waits for
210 /// `log.flush.start.offset.checkpoint.interval.ms`.
211 pub(super) fn follow_retained_segments(&mut self, now: Instant) -> Result<(), LogError> {
212 self.advance_log_start_offset(self.first_local_offset(), StartCheckpoint::Deferred(now))
213 }
214
215 fn advance_log_start_offset(
216 &mut self,
217 new_start: Offset,
218 checkpoint: StartCheckpoint,
219 ) -> Result<(), LogError> {
220 if new_start < 0 {
221 return Err(LogError::InvalidArgument(
222 "set_log_start_offset: new_start must be >= 0".into(),
223 ));
224 }
225 let incremented = new_start > self.start_offset;
226 let new_start = self.start_offset.max(new_start);
227 match checkpoint {
228 // The checkpoint is durable when this returns, directory sync
229 // included: `DeleteRecords` is acknowledged as soon as the trim
230 // does, and a trimmed partition can then sit idle with no later
231 // `sync()` to pay a deferred debt. A file that already holds the
232 // value is durable already.
233 StartCheckpoint::Now if self.checkpointed_start != Some(new_start) => {
234 self.write_start_checkpoint(new_start)?;
235 }
236 StartCheckpoint::Now => {}
237 StartCheckpoint::Deferred(now) => self.defer_start_checkpoint(new_start, now),
238 }
239 self.start_offset = new_start;
240 // Whoever calls this deleted the records below `new_start`, so the
241 // floor now means something a reader may be refused against, and the
242 // checkpoint carries that meaning across a reopen.
243 self.start_offset_established = true;
244 if incremented {
245 self.epoch_checkpoint.truncate_from_start(new_start)?;
246 self.unreplicated
247 .retain(|_, marker_offset| *marker_offset >= new_start);
248 self.refresh_lso()?;
249 }
250 Ok(())
251 }
252
253 /// Whether [`Log::follow_leader_log_start`] would change anything for a
254 /// leader that reports `leader_start`. A follower answers this on every
255 /// fetch response, so it only reads.
256 #[must_use]
257 pub fn differs_from_leader_log_start(&self, leader_start: Offset) -> bool {
258 if self
259 .config
260 .read()
261 .unwrap_or_else(PoisonError::into_inner)
262 .remote_storage_enable
263 {
264 self.established_log_start() != Some(leader_start)
265 } else {
266 leader_start > self.start_offset
267 }
268 }
269
270 /// Take a leader's global log start as this follower's own, and return
271 /// the resulting log start.
272 ///
273 /// A log with no remote tier only ever raises its start, as Kafka's
274 /// `ReplicaFetcherThread.processPartitionData` →
275 /// `maybeIncrementLogStartOffset` does, through [`Log::trim_to_offset`].
276 ///
277 /// A remote-storage-enabled log takes the leader's start in either
278 /// direction and establishes it. The leader is the replica that owns the
279 /// archive and moves its floor, so a follower start above the leader's is
280 /// one no acknowledged deletion put there:
281 ///
282 /// - A broker without the fix for #1444 reported a restarted leader's
283 /// inferred start, the base of its first local segment, and its
284 /// followers established it. Such a floor hides the archive below it,
285 /// and remote retention deletes against it once the follower leads.
286 /// - [`Log::reset_to`] rebuilds a follower at its leader's *local* start,
287 /// and Kafka's `truncateFullyAndStartAt(localLogStartOffset,
288 /// leaderLogStartOffset)` gives the rebuilt log the leader's global
289 /// start beneath it.
290 ///
291 /// Lowering the start leaves the local segments and the leader-epoch
292 /// cache alone, so [`Log::log_start_epoch`] answers `None` until a later
293 /// raise cuts the cache again.
294 ///
295 /// # Errors
296 /// Returns [`LogError::InvalidArgument`] if `leader_start` is negative,
297 /// and [`LogError::Io`] if the log-start checkpoint cannot be written or
298 /// a raise fails.
299 pub fn follow_leader_log_start(&mut self, leader_start: Offset) -> Result<Offset, LogError> {
300 if !self
301 .config
302 .read()
303 .unwrap_or_else(PoisonError::into_inner)
304 .remote_storage_enable
305 || leader_start > self.start_offset
306 {
307 return self.trim_to_offset(leader_start);
308 }
309 if leader_start < 0 {
310 return Err(LogError::InvalidArgument(
311 "follow_leader_log_start: leader_start must be >= 0".into(),
312 ));
313 }
314 self.write_start_checkpoint(leader_start)?;
315 self.start_offset = leader_start;
316 self.start_offset_established = true;
317 Ok(leader_start)
318 }
319
320 /// Move the log start down to `ceiling` when it sits above it. Truncation
321 /// is the one caller: Kafka's `UnifiedLog.truncateTo` sets
322 /// `logStartOffset = Math.min(targetOffset, logStartOffset)`, so a cut that
323 /// lands below a start `DeleteRecords` or retention had advanced pulls the
324 /// start back onto the retained data. The new value is checkpointed for
325 /// the same reason [`Log::set_log_start_offset`] checkpoints an advance.
326 pub(super) fn lower_log_start_offset(&mut self, ceiling: Offset) -> Result<(), LogError> {
327 if ceiling >= self.start_offset {
328 return Ok(());
329 }
330 self.write_start_checkpoint(ceiling)?;
331 self.start_offset = ceiling;
332 Ok(())
333 }
334
335 /// Reset the log to be empty at `new_base`.
336 ///
337 /// This method drops every segment and every on-disk file, then creates
338 /// a fresh active segment at `new_base`. The replicator's
339 /// `OFFSET_OUT_OF_RANGE` recovery path uses it when the follower has
340 /// fallen behind the leader's `log_start`. `truncate_to` cannot help
341 /// there, because `log_start` must move *forward* past the point where
342 /// no local data exists.
343 #[instrument(level = "info", skip_all, fields(new_base = new_base.0), err)]
344 /// # Errors
345 /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
346 pub fn reset_to(&mut self, new_base: Offset) -> Result<(), LogError> {
347 self.rollover_flusher.finish()?;
348 // A read planned before the reset must not serve what it finds now.
349 self.truncations.fetch_add(1, Ordering::SeqCst);
350 self.roll_jitter = None;
351 if new_base < 0 {
352 return Err(LogError::OffsetMismatch {
353 expected: Offset(0),
354 actual: new_base,
355 });
356 }
357
358 producer_snapshot::remove_all(&self.dir)?;
359 self.invalidate_delivery_schedule(new_base);
360
361 // Drop every sealed segment + its on-disk files.
362 while let Some(segment) = self.segments.last() {
363 let base = segment.base_offset();
364 self.remove_truncated_segment_files(base)?;
365 self.segments.pop();
366 self.sealed_txn_indexes.remove(&base);
367 self.stamp_indexes.remove(&base);
368 }
369
370 // Drop the active segment + its on-disk files.
371 if let Some(active) = &self.active {
372 let base = active.base_offset();
373 self.remove_truncated_segment_files(base)?;
374 }
375
376 // A hard reset re-bases the local log after a divergence or a
377 // snapshot install. It says nothing about the remote tier, which may
378 // still hold lower offsets, so the floor moves without becoming a
379 // statement anything may be refused or deleted against -- and the
380 // checkpoint that would restore such a statement goes with it.
381 self.start_offset = new_base;
382 self.start_offset_established = false;
383 log_start_offset_checkpoint::remove(&self.dir)?;
384 self.forget_start_checkpoint();
385
386 let mut new_active = Segment::create(&self.dir, new_base)?;
387 new_active.set_io(Arc::clone(&self.io));
388 self.active_txn_index = TxnIndex::open(new_active.txn_index_path())?;
389 let stamp_index_path = new_active.stamp_index_path();
390 self.clear_producer_and_transaction_state();
391 self.sealed_txn_indexes.clear();
392 self.stamp_indexes.clear();
393 self.lso = new_active.last_offset() + 1; // = new_base (empty segment)
394 self.active = Some(new_active);
395 #[cfg(not(target_os = "wasi"))]
396 self.restart_writeback_pacing(0);
397 self.dir_sync_needed = true;
398 // Preserve any injected stamp source; reopen its (fresh) sidecar.
399 self.reopen_active_stamp_index(new_base, stamp_index_path)?;
400 // The log now holds no records, so the leader-epoch cache must hold no
401 // entries (Kafka's truncateFullyAndStartAt → leaderEpochCache.clearAndFlush).
402 // Leaving stale entries makes a follower advertise a `last_fetched_epoch`
403 // it has no record for, so the leader's KIP-320 reconciliation serves a
404 // batch at a mismatched base offset and the follower loops forever on
405 // append_at — a phantom ISR member that pins the high-watermark.
406 self.epoch_checkpoint.clear()?;
407 // Kafka's `truncateFullyAndStartAt` rebuilds the (now empty) producer
408 // state and takes a snapshot at the new base: its
409 // `ProducerStateManager.truncateFullyAndStartAt` resets the last
410 // snapshot offset to 0, so `takeSnapshot` writes one at any base above
411 // 0.
412 if new_base.0 > 0 {
413 producer_snapshot::write(&*self.io, &self.dir, new_base, &self.producer_state)?;
414 }
415 self.reap_deleted_files(SystemTime::now())
416 }
417
418 /// Kafka's `ProducerStateManager.takeSnapshot`: write the producer state at
419 /// the log end offset, so a reopen restores it from the snapshot and
420 /// replays nothing. A snapshot already at that offset is kept, and none is
421 /// written at or below the log start, which Kafka takes as the last
422 /// snapshot offset when no snapshot survives a reload.
423 ///
424 /// # Errors
425 /// Returns an error when the snapshot cannot be written.
426 pub fn take_producer_snapshot(&mut self) -> Result<(), LogError> {
427 let log_end = self.log_end_offset();
428 if log_end.0 > self.producer_reload_range(log_end).log_start {
429 self.sync()?;
430 producer_snapshot::write(&*self.io, &self.dir, log_end, &self.producer_state)?;
431 }
432 Ok(())
433 }
434
435 /// Refuses every append from now on with [`LogError::Fenced`], and stops
436 /// any compaction job.
437 ///
438 /// The broker fences a partition's log, under the log's mutex, before it
439 /// removes the partition's directory. An append that holds the mutex
440 /// first finishes before the directory goes; any later one, from whatever
441 /// task, is refused rather than written into a log that is about to be
442 /// deleted. Reads are unaffected.
443 ///
444 /// A compaction job builds without the mutex, writing `.cleaned` files
445 /// into the directory. The fence stops it at its next batch and returns
446 /// only once it writes nothing more, so the directory can go as soon as
447 /// the fence returns. [`Log::finish_compaction`] then installs nothing.
448 pub fn fence(&mut self) {
449 self.fenced = true;
450 self.compaction_gate.fence();
451 }
452
453 pub(super) fn ensure_unfenced(&self) -> Result<(), LogError> {
454 if self.fenced {
455 Err(LogError::Fenced)
456 } else {
457 Ok(())
458 }
459 }
460
461 /// Whether a raw read from `fetch_offset` may need bytes outside memory.
462 ///
463 /// Buffered reads of this process's active-segment appends use the page cache. Direct reads can run in memory when the tail cache covers the sparse-index floor and every byte after it. Older segments, reopened bytes, and evicted direct tails need the blocking path. The caller must retain the log lock through the read so a concurrent append cannot evict the checked bytes.
464 #[must_use]
465 pub fn read_may_block(&self, fetch_offset: Offset) -> bool {
466 self.active.as_ref().is_none_or(|active| {
467 fetch_offset < active.base_offset()
468 || active.read_may_block(fetch_offset, self.opened_end)
469 })
470 }
471
472 /// Whether appending `incoming_size` bytes whose newest record is stamped
473 /// `incoming_max_timestamp` may wait on more than the page cache.
474 ///
475 /// It is true when the log's flush policy can make an append fsync
476 /// (`flush.messages`, `flush.ms`, or a flush on every append), and when
477 /// the append would roll the active segment, which creates files and can
478 /// wait for an earlier roll's flush budget, and for every append to a log
479 /// with `preallocate`: that append can reserve the segment's blocks, open
480 /// its `O_DIRECT` handle, or wait for room in its direct write queue. It
481 /// errs towards true: a caller that sees false may run the append where a
482 /// wait would stall others.
483 pub fn append_may_block(
484 &mut self,
485 incoming_size: ByteSize,
486 incoming_max_timestamp: i64,
487 ) -> bool {
488 self.append_may_block_with(incoming_size, incoming_max_timestamp, None)
489 }
490
491 /// Whether appending this one verbatim batch can wait on storage. An initialized direct writer with queue space can accept ordinary batches without a worker handoff. Flushes, epoch changes, segment setup, and queue backpressure still require blocking placement. The caller must hold the log lock through the append.
492 pub fn verbatim_append_may_block(&mut self, batch: &VerbatimBatch) -> bool {
493 self.append_may_block_with(
494 ByteSize::from_bytes(u64::try_from(batch.bytes.len()).unwrap_or(u64::MAX)),
495 batch.max_timestamp,
496 Some(batch),
497 )
498 }
499
500 fn can_queue_verbatim(&self, batch: &VerbatimBatch, config: &LogConfig) -> bool {
501 !batch.is_transactional
502 && batch.bytes.len() >= HEADER_LEN
503 && batch
504 .bytes
505 .get(CRC_COVERAGE_START..)
506 .and_then(<[u8]>::first_chunk::<2>)
507 .is_some_and(|&attributes| {
508 !Attributes(i16::from_be_bytes(attributes)).is_control_batch()
509 })
510 && config.delivery_policy == DeliveryPolicy::Immediate
511 && config.message_timestamp_type == TimestampType::CreateTime
512 && self.active.as_ref().is_some_and(|active| {
513 active.can_queue_append(batch.bytes.len(), config.segment_size)
514 })
515 }
516
517 fn verbatim_epoch_is_current(&self, batch: &VerbatimBatch) -> bool {
518 !batch.leader_epoch.is_known()
519 || self.epoch_checkpoint.entries().last().is_some_and(|entry| {
520 entry.epoch == batch.leader_epoch
521 // A follower can clear a reconciled frontier before appending. The local log end is the lower bound shared by both append paths.
522 && entry.start_offset <= self.log_end_offset()
523 })
524 }
525
526 fn append_may_block_with(
527 &mut self,
528 incoming_size: ByteSize,
529 incoming_max_timestamp: i64,
530 verbatim: Option<&VerbatimBatch>,
531 ) -> bool {
532 let (flushes, segment_size, roll_interval, index_size) = {
533 let config = self.config.read().unwrap_or_else(PoisonError::into_inner);
534 let storage_may_block = match config.segment_allocation {
535 SegmentAllocation::Preallocate => {
536 verbatim.is_none_or(|batch| !self.can_queue_verbatim(batch, &config))
537 }
538 SegmentAllocation::OnWrite => {
539 self.active.as_ref().is_some_and(Segment::writes_direct)
540 }
541 };
542 (
543 config.flush_on_append
544 || config.flush_messages.is_some()
545 || config.flush_interval.is_some()
546 || self.stamp_source.is_some()
547 || storage_may_block
548 || verbatim.is_some_and(|batch| !self.verbatim_epoch_is_current(batch))
549 || (verbatim.is_some()
550 && config.message_timestamp_type == TimestampType::LogAppendTime),
551 config.segment_size,
552 config.segment_roll_interval,
553 config.segment_index_size,
554 )
555 };
556 if flushes {
557 return true;
558 }
559 let roll_interval = self.jittered_roll_interval(roll_interval);
560 self.should_roll_for_incoming(
561 incoming_size,
562 incoming_max_timestamp,
563 segment_size,
564 roll_interval,
565 index_size,
566 )
567 }
568
569 /// Next offset that `append` will assign.
570 #[must_use]
571 pub fn log_end_offset(&self) -> Offset {
572 if let Some(active) = &self.active {
573 return active.last_offset() + 1;
574 }
575 Offset(0)
576 }
577
578 /// Total `.log` size across sealed and active segments.
579 ///
580 /// The value comes from the segments' tracked logical size, not from a
581 /// filesystem stat. It therefore shows buffered appends immediately and
582 /// in the same way on every platform. On some operating systems a
583 /// directory stat can lag an open, unflushed write handle.
584 #[must_use]
585 pub fn size(&self) -> ByteSize {
586 let active = self.active.as_ref().map_or(ByteSize::ZERO, Segment::size);
587 self.segments
588 .iter()
589 .fold(active, |total, seg| total + seg.size())
590 }
591
592 /// First unstable offset: the first offset of the earliest transaction
593 /// that is open, or complete with a marker the high watermark has not
594 /// passed. It is the log end offset when there is no such transaction.
595 ///
596 /// This value does not know the high watermark. A reader must use
597 /// [`Log::last_stable_offset`], which releases replicated transactions
598 /// and caps the answer at the high watermark.
599 #[must_use]
600 pub fn lso(&self) -> Offset { self.lso }
601
602 /// First offset of `producer_id`'s currently open transaction on this
603 /// partition, or `None` when no transaction from that producer is pending.
604 #[must_use]
605 pub fn pending_transaction_start(&self, producer_id: ProducerId) -> Option<Offset> {
606 self.pending.get(producer_id).copied()
607 }
608
609 /// Producer and coordinator generations used to admit one transaction
610 /// marker, plus whether that producer currently has an open transaction.
611 /// Missing generations use Kafka's `-1` sentinel.
612 #[must_use]
613 pub fn transaction_marker_state(&self, producer_id: ProducerId) -> (i16, i32, bool) {
614 let producer_epoch = self
615 .producer_state
616 .get(&producer_id)
617 .map_or(-1, |entry| entry.producer_epoch);
618 let coordinator_epoch = self
619 .coordinator_epochs
620 .get(&producer_id)
621 .copied()
622 .unwrap_or(-1);
623 (
624 producer_epoch,
625 coordinator_epoch,
626 self.pending.contains_key(producer_id),
627 )
628 }
629
630 /// The producer state this log holds for `producer_id`, if any.
631 ///
632 /// Append and recovery update the same entry with the same rule, so the
633 /// value after a live append equals the value that a reopen rebuilds.
634 #[must_use]
635 pub fn producer_state_entry(&self, producer_id: ProducerId) -> Option<ProducerSnapshotEntry> {
636 self.producer_state.get(&producer_id).copied()
637 }
638
639 /// Producer state restored from the newest valid Kafka-compatible
640 /// snapshot and the uncovered local log tail.
641 #[must_use]
642 pub fn producer_state_snapshot(&self) -> Vec<ProducerSnapshotEntry> {
643 self.producer_state.values().copied().collect()
644 }
645
646 /// Every producer's state with the batches it appended before its last
647 /// one: [`Self::producer_state_snapshot`] plus the retained batches that
648 /// Kafka's `ProducerStateEntry` keeps for duplicate detection. After a
649 /// reopen they are the batches the replay past the loaded snapshot
650 /// rebuilt.
651 #[must_use]
652 pub fn recovered_producers(&self) -> Vec<RecoveredProducer> {
653 self.producer_state
654 .values()
655 .map(|entry| RecoveredProducer {
656 entry: *entry,
657 earlier: self
658 .earlier_batches
659 .get(&entry.producer_id)
660 .map_or_default(|earlier| earlier.iter().copied().collect()),
661 })
662 .collect()
663 }
664
665 /// Close all segments, first taking a producer-state snapshot at the log
666 /// end as Kafka's `UnifiedLog.close` does. A snapshot that cannot be
667 /// written is not an error: the next open replays the log instead.
668 ///
669 /// A running compaction build stops first, as on [`Log::fence`], and the
670 /// call returns once it writes nothing more.
671 pub fn close(mut self) {
672 // A compaction build writes into the directory without the log; stop
673 // it, as the fence does, before the caller moves or deletes the
674 // directory.
675 self.compaction_gate.fence();
676 if let Err(error) = self.take_producer_snapshot() {
677 tracing::warn!(
678 dir = %self.dir.display(),
679 %error,
680 "producer-state snapshot on close failed; the next open replays the log"
681 );
682 }
683 drop(self);
684 }
685
686 /// Atomically swap the active `LogConfig`.
687 ///
688 /// The next retention or roll check reads the new value. In-flight
689 /// `append` calls hold the lock for very short windows and will not see
690 /// a half-applied config.
691 ///
692 /// Callers can use this method through `&self`. The `Arc<RwLock<…>>`
693 /// wrapper permits mutation of the inner value without an exclusive
694 /// borrow on the `Log`.
695 ///
696 /// Delivery settings apply to subsequent watermark advances. Changing
697 /// clock uncertainty does not retract the already published delivery
698 /// prefix; see [`LogConfig::delivery_clock_uncertainty`].
699 pub fn set_config(&self, new: LogConfig) {
700 *self.config.write().unwrap_or_else(PoisonError::into_inner) = new;
701 }
702
703 /// Snapshot the current config. This allocates a clone, which is cheap
704 /// because `LogConfig` is small and `Clone`.
705 #[must_use]
706 pub fn config_snapshot(&self) -> LogConfig {
707 self.config
708 .read()
709 .unwrap_or_else(PoisonError::into_inner)
710 .clone()
711 }
712
713 /// Replace the log's I/O implementation for fault-injection tests.
714 ///
715 /// This reaches every durable write the log makes: the active `.log`, both
716 /// sparse indexes of every segment, the `.stampindex` sidecars, the
717 /// leader-epoch checkpoint, and -- through `self.io`, which the free
718 /// functions take -- the producer snapshots, the compaction swap, and the
719 /// segment deletions retention performs.
720 #[cfg(any(test, feature = "test-helpers"))]
721 pub fn test_set_io(&mut self, io: Arc<dyn LogIo>) {
722 self.io = Arc::clone(&io);
723 for segment in &mut self.segments {
724 segment.set_io(Arc::clone(&io));
725 }
726 if let Some(active) = &mut self.active {
727 active.set_io(Arc::clone(&io));
728 }
729 for index in self.stamp_indexes.values_mut() {
730 index.set_io(Arc::clone(&io));
731 }
732 self.epoch_checkpoint.set_io(io);
733 }
734
735 /// Return all aborted transactions whose offset range overlaps
736 /// `[start, end)`, including entries in sealed segments.
737 ///
738 /// Kafka's `UnifiedLog.collectAbortedTransactions`: the search starts at
739 /// the first segment that reaches `start` and stops at the first index
740 /// that reports it complete (see [`TxnIndex::collect_aborted`]).
741 #[must_use]
742 pub fn aborted_in_range(&self, start: Offset, end: Offset) -> Vec<AbortedTxn> {
743 let mut aborted = Vec::new();
744 // Segment last offsets rise with their base offsets.
745 let first = self
746 .segments
747 .partition_point(|segment| segment.last_offset() < start);
748 let sealed = self
749 .segments
750 .get(first)
751 .map(Segment::base_offset)
752 .into_iter()
753 .flat_map(|first_base| {
754 self.sealed_txn_indexes
755 .range(first_base..)
756 .map(|(_, index)| index)
757 });
758 for index in sealed.chain(iter::once(&self.active_txn_index)) {
759 if index.collect_aborted(start, end, &mut aborted) {
760 break;
761 }
762 }
763 aborted
764 }
765
766 /// Access the per-partition leader-epoch checkpoint.
767 #[must_use]
768 pub fn epoch_checkpoint(&self) -> &LeaderEpochCheckpoint { &self.epoch_checkpoint }
769
770 /// Kafka's `UnifiedLog.assignEpochStartOffset`: record that `epoch`
771 /// starts at `start_offset` through
772 /// [`LeaderEpochCheckpoint::assign`]. `Partition.makeLeader` calls it with
773 /// the log end offset when a replica takes a new leader epoch, so the
774 /// leader can place a follower's `last_fetched_epoch` before it has
775 /// written anything in that epoch.
776 ///
777 /// # Errors
778 /// Returns an error for a negative epoch or offset, or when the
779 /// checkpoint cannot be persisted.
780 pub fn assign_epoch_start_offset(
781 &mut self,
782 epoch: krabka_ids::LeaderEpoch,
783 start_offset: Offset,
784 ) -> Result<(), LogError> {
785 self.epoch_checkpoint.assign(epoch, start_offset)
786 }
787
788 /// Reconcile append-at offset assignment to an external next-offset frontier.
789 ///
790 /// Diskless partitions use the `KRaft` metadata log as the offset authority.
791 /// After a crash, `KRaft` may have committed a next-offset that is ahead of the
792 /// recovered local WAL tail. In that case the gap is intentional: the caller
793 /// sets this frontier and the next append-at must use it instead of the local
794 /// LEO. Classic logs never call this method and keep the default frontier 0.
795 pub fn reconcile_next_offset(&mut self, frontier: Offset) {
796 self.reconciled_frontier = self.reconciled_frontier.max(frontier);
797 }
798
799 /// Drop the frontier [`Log::reconcile_next_offset`] set, so append-at
800 /// bases are held only to the log end offset again.
801 ///
802 /// The frontier binds the broker that assigns this log's offsets. A
803 /// follower assigns none: it appends its leader's batches at the leader's
804 /// offsets, and Kafka's `UnifiedLog.appendAsFollower` refuses only a batch
805 /// below the log end. A diskless replica that reopened behind the
806 /// committed frontier -- it was down while its leader appended -- must
807 /// take the batches between its log end and that frontier from the
808 /// leader, and the frontier would refuse every one of them.
809 pub fn clear_reconciled_next_offset(&mut self) { self.reconciled_frontier = Offset(0); }
810}
811
812#[cfg(test)]
813mod tests {
814 use {
815 super::*,
816 crate::{
817 log::test_support::{
818 TempLog, append_samples, append_transaction, rolled_sample_log, sample_batch,
819 sample_batch_with_epoch, test_log, tiny_segments,
820 },
821 test_support::open_log,
822 },
823 assert2::check,
824 krabka_ids::LeaderEpoch,
825 krabka_units::prelude::{kibibytes, minutes},
826 std::path::Path,
827 tempfile::tempdir,
828 };
829
830 /// A hard reset leaves the log empty at the new base, with the last stable
831 /// offset there too.
832 ///
833 /// `lso` is derived from the fresh segment rather than from the base it was
834 /// asked for, and it gates what a `read_committed` consumer may see -- one
835 /// short of the base would expose an offset the log does not have.
836 #[test]
837 fn a_reset_puts_the_stable_offset_at_the_new_base() {
838 let TempLog { dir: _dir, mut log } = test_log();
839 append_samples(&mut log, 3, 2);
840 check!(log.log_end_offset() == Offset(6));
841
842 log.reset_to(Offset(50)).expect("reset");
843 check!(
844 log.log_start_offset() == Offset(50),
845 "starts at the new base"
846 );
847 check!(log.log_end_offset() == Offset(50), "and is empty there");
848 check!(
849 log.lso() == Offset(50),
850 "the stable offset is the base, got {:?}",
851 log.lso()
852 );
853 }
854
855 /// Zero is a legal log start; only a negative one is rejected.
856 #[test]
857 fn the_log_start_may_be_set_to_zero_but_not_below() {
858 let TempLog { dir: _dir, mut log } = test_log();
859 check!(
860 log.set_log_start_offset(Offset(0)).is_ok(),
861 "zero is a real offset"
862 );
863 check!(log.set_log_start_offset(Offset(7)).is_ok());
864 check!(
865 log.set_log_start_offset(Offset(-1)).is_err(),
866 "negative is not"
867 );
868 }
869
870 /// Kafka's `maybeIncrementLogStartOffset` → `onLogStartOffsetIncremented`:
871 /// a complete transaction whose marker falls below the new log start
872 /// stops holding the last stable offset, and one whose marker the start
873 /// only reaches keeps holding it. Producer 42 writes a transaction at
874 /// offset 0 and commits it at offset 1, the high watermark never passes
875 /// the marker, and three plain records follow at offsets 2 to 4.
876 #[yare::parameterized(
877 reaches_marker = { "the start reaches the marker", 1, false },
878 passes_marker = { "the start passes the marker", 2, true },
879 )]
880 fn raising_the_log_start_past_a_marker_releases_its_transaction(
881 name: &'static str,
882 new_start: i64,
883 released: bool,
884 ) {
885 use crate::log::test_support::commit_marker;
886
887 let TempLog { dir: _dir, mut log } = test_log();
888 append_transaction(&mut log, 42, 0, &["a"]);
889 log.append(&mut commit_marker(42, 0)).unwrap();
890 log.append(&mut sample_batch(3)).unwrap();
891 check!(log.lso() == Offset(0), "{name}: held before the move");
892
893 log.set_log_start_offset(Offset(new_start)).unwrap();
894
895 check!(
896 (log.lso() == log.log_end_offset()) == released,
897 "{name}: lso {:?}",
898 log.lso()
899 );
900 }
901
902 /// Kafka's `maybeIncrementLogStartOffset` →
903 /// `leaderEpochCache.truncateFromStart`: raising the start drops the
904 /// epochs that end below it, the epoch that covers it starts there, and
905 /// the trimmed history is what a reopen reads back. A start that does not
906 /// move leaves the cache alone.
907 #[test]
908 fn raising_the_log_start_truncates_the_epoch_cache_from_the_start() {
909 use crate::leader_epoch_checkpoint::EpochEntry;
910
911 let TempLog { dir, mut log } = test_log();
912 log.append(&mut sample_batch_with_epoch(3, 1)).unwrap(); // epoch 1 @ 0
913 log.append(&mut sample_batch_with_epoch(3, 2)).unwrap(); // epoch 2 @ 3
914 log.append(&mut sample_batch_with_epoch(3, 4)).unwrap(); // epoch 4 @ 6
915
916 log.set_log_start_offset(Offset(4)).unwrap();
917 log.set_log_start_offset(Offset(2)).unwrap();
918
919 let expected = [
920 EpochEntry {
921 epoch: LeaderEpoch(2),
922 start_offset: Offset(4),
923 },
924 EpochEntry {
925 epoch: LeaderEpoch(4),
926 start_offset: Offset(6),
927 },
928 ];
929 check!(log.epoch_checkpoint().entries() == &expected[..]);
930 drop(log);
931 let reopened = open_log(dir.path());
932 check!(reopened.epoch_checkpoint().entries() == &expected[..]);
933 }
934
935 /// The epoch the remote tier's leader-epoch-cache cleanup may measure
936 /// against is the earliest cache entry an advance of the log start put
937 /// there (#1200). The segments a tiered partition still holds locally
938 /// are not that: evicting them after the archive took them, and a restart
939 /// on what is left, leave the whole epoch history in place and answer
940 /// `None`, so the archive's older epochs are never mistaken for a
941 /// truncated lineage. A `DeleteRecords`-style advance is one, and it is
942 /// still one after a restart.
943 #[test]
944 fn the_log_start_epoch_comes_only_from_an_established_log_start() {
945 let config = LogConfig {
946 remote_storage_enable: true,
947 ..tiny_segments()
948 };
949 // Three batches of three records, each rolling into its own segment:
950 // epoch 1 at 0, epoch 2 at 3, epoch 4 at 6.
951 let filled = |dir: &Path| {
952 let mut log = Log::open(dir, config.clone()).unwrap();
953 for epoch in [1, 2, 4] {
954 log.append(&mut sample_batch_with_epoch(3, epoch)).unwrap();
955 }
956 log
957 };
958
959 // Local retention drops the segments the archive holds, and the
960 // restart infers a log start from what is left.
961 let evicted = tempdir().unwrap();
962 let mut log = filled(evicted.path());
963 check!(log.log_start_epoch() == None, "nobody moved the log start");
964 check!(log.delete_local_segments_through(Offset(6)).unwrap() == 2);
965 drop(log);
966 let log = Log::open(evicted.path(), config.clone()).unwrap();
967 check!(log.epoch_checkpoint().entries().len() == 3);
968 check!(
969 log.log_start_epoch() == None,
970 "the restart inferred a start from the surviving segments"
971 );
972
973 // A start that was moved cuts the cache to the epoch that owns it, and
974 // the restart keeps both.
975 let moved = tempdir().unwrap();
976 let mut log = filled(moved.path());
977 log.set_log_start_offset(Offset(4)).unwrap();
978 check!(log.log_start_epoch() == Some(LeaderEpoch(2)));
979 drop(log);
980 let log = Log::open(moved.path(), config).unwrap();
981 check!(log.log_start_epoch() == Some(LeaderEpoch(2)));
982 }
983
984 /// A log in its own directory holding three segments, at offsets 0, 3
985 /// and 6 and in epochs 1, 2 and 4, and the configuration that opens it
986 /// again.
987 struct ThreeSegments {
988 dir: tempfile::TempDir,
989 log: Log,
990 config: LogConfig,
991 }
992
993 fn three_segments(remote_storage_enable: bool) -> ThreeSegments {
994 let config = LogConfig {
995 remote_storage_enable,
996 ..tiny_segments()
997 };
998 let dir = tempdir().unwrap();
999 let mut log = Log::open(dir.path(), config.clone()).unwrap();
1000 for epoch in [1, 2, 4] {
1001 log.append(&mut sample_batch_with_epoch(3, epoch)).unwrap();
1002 }
1003 ThreeSegments { dir, log, config }
1004 }
1005
1006 /// A leader reports Kafka's `logStartOffset` to its followers, and they
1007 /// raise their own start to it. A remote-storage-enabled log reopened over
1008 /// the segments local retention left infers a start above the whole
1009 /// archive, and Kafka's `LogLoader.load` never takes that one: it reports
1010 /// the checkpointed start, 0 when nobody moved it. A start somebody moved
1011 /// is reported as it is, and a log with no remote tier reports the start
1012 /// its segments witness.
1013 ///
1014 /// Each case fills three segments (offsets 0, 3 and 6), optionally moves
1015 /// the start, drops the two sealed segments from disk, and reopens.
1016 #[yare::parameterized(
1017 tiered_nobody_moved_it = { true, None, 6, 0 },
1018 tiered_moved = { true, Some(4), 4, 4 },
1019 local_only = { false, None, 6, 6 },
1020 )]
1021 fn a_reopened_log_reports_the_start_kafka_reports(
1022 remote_storage_enable: bool,
1023 moved_to: Option<i64>,
1024 log_start: i64,
1025 reported: i64,
1026 ) {
1027 let ThreeSegments {
1028 dir,
1029 mut log,
1030 config,
1031 } = three_segments(remote_storage_enable);
1032 if let Some(start) = moved_to {
1033 log.set_log_start_offset(Offset(start)).unwrap();
1034 }
1035 check!(log.delete_local_segments_through(Offset(6)).unwrap() == 2);
1036 drop(log);
1037
1038 let log = Log::open(dir.path(), config).unwrap();
1039 check!(
1040 (log.log_start_offset(), log.reported_log_start())
1041 == (Offset(log_start), Offset(reported))
1042 );
1043 }
1044
1045 /// How a follower's log came by its start before the leader reports one.
1046 #[derive(Clone, Copy, Debug)]
1047 enum Prior {
1048 /// Nobody moved it.
1049 Untouched,
1050 /// Somebody established it at this offset.
1051 Moved(i64),
1052 /// `reset_to` rebuilt the log at this offset, the leader's local
1053 /// start.
1054 Rebuilt(i64),
1055 }
1056
1057 /// A follower takes its leader's log start. A log with no remote tier only
1058 /// raises its start, as Kafka's `maybeIncrementLogStartOffset` does. A
1059 /// remote-storage-enabled log takes the leader's start in either direction
1060 /// and establishes it: that repairs a floor no deletion put there (a
1061 /// restarted leader's inferred start, which an older broker reported) and
1062 /// gives a rebuilt follower the leader's global start below its own, as
1063 /// Kafka's `truncateFullyAndStartAt` does. The start a follower takes
1064 /// survives a reopen.
1065 ///
1066 /// Each case fills three segments (offsets 0, 3 and 6) before `prior`.
1067 #[yare::parameterized(
1068 tiered_bogus_floor_is_repaired = { true, Prior::Moved(6), 0, true, 0 },
1069 tiered_rebuilt_takes_the_global_start = { true, Prior::Rebuilt(6), 2, true, 2 },
1070 tiered_raise = { true, Prior::Untouched, 4, true, 4 },
1071 tiered_agreeing_floor_is_left_alone = { true, Prior::Moved(4), 4, false, 4 },
1072 local_never_lowers = { false, Prior::Moved(4), 2, false, 4 },
1073 local_raise = { false, Prior::Untouched, 4, true, 4 },
1074 )]
1075 fn a_follower_takes_its_leaders_log_start(
1076 remote_storage_enable: bool,
1077 prior: Prior,
1078 leader_start: i64,
1079 differs: bool,
1080 followed: i64,
1081 ) {
1082 let ThreeSegments {
1083 dir,
1084 mut log,
1085 config,
1086 } = three_segments(remote_storage_enable);
1087 match prior {
1088 Prior::Untouched => {}
1089 Prior::Moved(start) => log.set_log_start_offset(Offset(start)).unwrap(),
1090 Prior::Rebuilt(base) => log.reset_to(Offset(base)).unwrap(),
1091 }
1092
1093 let leader_start = Offset(leader_start);
1094 check!(log.differs_from_leader_log_start(leader_start) == differs);
1095 let result = log.follow_leader_log_start(leader_start).unwrap();
1096 let followed = Offset(followed);
1097 check!(
1098 (result, log.established_log_start()) == (followed, Some(followed)),
1099 "{prior:?}"
1100 );
1101 check!(!log.differs_from_leader_log_start(leader_start));
1102 drop(log);
1103 let reopened = Log::open(dir.path(), config).unwrap();
1104 check!(reopened.log_start_offset() == followed, "{prior:?}");
1105 }
1106
1107 /// A leader start below zero names no offset, and following it is refused
1108 /// on a log with a remote tier as on one without.
1109 #[yare::parameterized(tiered = { true }, local_only = { false })]
1110 fn a_negative_leader_start_is_refused(remote_storage_enable: bool) {
1111 let ThreeSegments {
1112 dir: _dir, mut log, ..
1113 } = three_segments(remote_storage_enable);
1114 check!(matches!(
1115 log.follow_leader_log_start(Offset(-1)),
1116 Err(LogError::InvalidArgument(_))
1117 ));
1118 check!(log.log_start_offset() == Offset(0));
1119 }
1120
1121 /// The log's size is every segment's size added up, the sealed ones as
1122 /// well as the active one.
1123 #[test]
1124 fn log_size_sums_the_sealed_segments_and_the_active_one() {
1125 let dir = tempdir().unwrap();
1126 let log = rolled_sample_log(dir.path());
1127
1128 let expected = log.segments.iter().map(Segment::size).fold(
1129 log.active.as_ref().map_or(ByteSize::ZERO, Segment::size),
1130 |a, b| a + b,
1131 );
1132 check!(
1133 log.size() == expected,
1134 "size {:?}, expected {:?}",
1135 log.size(),
1136 expected
1137 );
1138 check!(
1139 log.size() > kibibytes(1),
1140 "several segments should exceed one"
1141 );
1142 }
1143
1144 #[test]
1145 fn dir_returns_open_path() {
1146 // The broker's KIP-113 move machinery reads this back to
1147 // determine a partition's current owning `log.dir` without
1148 // re-implementing the directory-layout convention.
1149 let TempLog { dir, log } = test_log();
1150 assert2::assert!(log.dir() == dir.path());
1151 }
1152
1153 #[test]
1154 fn reset_to_clears_leader_epoch_checkpoint() {
1155 let TempLog { dir, mut log } = test_log();
1156 // A follower that replicated real data builds an epoch history.
1157 log.append(&mut sample_batch_with_epoch(3, 1)).unwrap(); // epoch 1 @ 0
1158 log.append(&mut sample_batch_with_epoch(2, 2)).unwrap(); // epoch 2 @ 3
1159 log.append(&mut sample_batch_with_epoch(1, 5)).unwrap(); // epoch 5 @ 5
1160 assert2::assert!(log.epoch_checkpoint().latest_epoch() == Some(LeaderEpoch(5)));
1161
1162 // Hard reset to an empty log — the replicator's OFFSET_OUT_OF_RANGE
1163 // recovery path (Kafka's `truncateFullyAndStartAt`). The log now has
1164 // NO records, so it must advertise NO leader epoch. Otherwise the
1165 // follower keeps sending a stale `last_fetched_epoch` and the leader's
1166 // KIP-320 reconciliation serves a batch at a mismatched base offset,
1167 // looping forever on `append_at` (phantom ISR member → pinned HW →
1168 // acks=all stall).
1169 log.reset_to(Offset(0)).unwrap();
1170
1171 assert2::assert!(log.epoch_checkpoint().latest_epoch() == None);
1172 assert2::assert!(log.epoch_checkpoint().entries() == &[][..]);
1173 // The cleared state must survive a reopen (a restarted broker re-reads
1174 // the on-disk checkpoint file).
1175 let reopened = open_log(dir.path());
1176 assert2::assert!(reopened.epoch_checkpoint().entries().is_empty());
1177 }
1178
1179 #[test]
1180 fn reset_to_nonzero_base_clears_all_epochs_not_just_tail() {
1181 // Guards against the subtly-wrong fix `truncate_from_end(new_base)`,
1182 // which retains an entry whose `start_offset < new_base` even though
1183 // the reset log holds no records below `new_base`.
1184 let TempLog { dir: _dir, mut log } = test_log();
1185 log.append(&mut sample_batch_with_epoch(3, 1)).unwrap(); // epoch 1 @ 0
1186 assert2::assert!(log.epoch_checkpoint().latest_epoch() == Some(LeaderEpoch(1)));
1187 log.reset_to(Offset(1000)).unwrap(); // empty log starting at 1000
1188 assert2::assert!(log.epoch_checkpoint().entries().is_empty());
1189 }
1190
1191 #[test]
1192 fn set_config_swaps_active_config() {
1193 let dir = tempdir().expect("tempdir");
1194 let log = Log::open(dir.path(), LogConfig {
1195 retention: Some(minutes(1)),
1196 ..LogConfig::default()
1197 })
1198 .expect("open");
1199 log.set_config(LogConfig {
1200 retention: Some(minutes(2)),
1201 ..LogConfig::default()
1202 });
1203 assert2::assert!(log.config_snapshot().retention == Some(minutes(2)));
1204 }
1205
1206 #[test]
1207 fn transaction_marker_state_and_producer_state_entry() {
1208 use crate::log::test_support::commit_marker;
1209
1210 let TempLog { dir: _dir, mut log } = test_log();
1211 let pid = ProducerId(42);
1212 assert2::assert!(log.transaction_marker_state(pid) == (-1, -1, false));
1213 assert2::assert!(log.producer_state_entry(pid).is_none());
1214
1215 let mut batch = sample_batch(1);
1216 batch.producer_id = 42;
1217 batch.producer_epoch = 2;
1218 batch.base_sequence = 0;
1219 batch.attributes = batch.attributes.with_transactional(true);
1220 log.append(&mut batch).unwrap();
1221
1222 assert2::assert!(log.transaction_marker_state(pid) == (2, -1, true));
1223 let entry = log
1224 .producer_state_entry(pid)
1225 .expect("producer state exists");
1226 assert2::assert!(entry.producer_id == 42);
1227 assert2::assert!(entry.producer_epoch == 2);
1228
1229 let mut marker = commit_marker(42, 2);
1230 log.append(&mut marker).unwrap();
1231 assert2::assert!(log.transaction_marker_state(pid) == (2, 17, false));
1232
1233 log.close();
1234 }
1235}