krabka_log/log/truncate.rs
1//! Truncation of the log tail and trimming of the log head.
2//!
3//! `truncate_to` discards every record at or past an offset, which
4//! replication and leader election need after a divergence, and
5//! `trim_to_offset` moves the log start forward without touching the
6//! active segment.
7
8#[cfg(not(target_os = "wasi"))]
9use krabka_units::prelude::ByteSizeExt as _;
10use {
11 super::Log,
12 crate::{
13 error::LogError, producer_snapshot, retention::DirSync, segment::Segment,
14 txn_index::TxnIndex,
15 },
16 krabka_ids::Offset,
17 std::{
18 collections::HashSet,
19 sync::{Arc, atomic::Ordering},
20 time::SystemTime,
21 },
22 tracing::{field, instrument},
23};
24
25impl Log {
26 /// Remove sealed segments, their cached indexes, and their producer snapshots.
27 /// The snapshot at the first surviving base stays for local and remote reads.
28 pub(super) fn remove_sealed_segments(&mut self, bases: &[Offset]) -> Result<(), LogError> {
29 let drop_set: HashSet<Offset> = bases.iter().copied().collect();
30 self.segments
31 .retain(|segment| !drop_set.contains(&segment.base_offset()));
32 self.sealed_txn_indexes
33 .retain(|base, _| !drop_set.contains(base));
34 self.stamp_indexes
35 .retain(|base, _| !drop_set.contains(base));
36 for base in bases {
37 self.retire_segment(*base, SystemTime::now())?;
38 producer_snapshot::remove_at(&self.dir, *base)?;
39 }
40 Ok(())
41 }
42
43 /// Retire a discarded tail before dropping its segment handle.
44 pub(super) fn remove_truncated_segment_files(&mut self, base: Offset) -> Result<(), LogError> {
45 // Producer snapshots are pruned separately by retain_reload_range:
46 // the snapshot exactly at the cut must survive deleting its segment.
47 let now = SystemTime::now();
48 self.retire_files(
49 base,
50 now,
51 &["log", "index", "timeindex", "txnindex", "stampindex"],
52 DirSync::Now,
53 )
54 }
55
56 /// Truncate the log so that no record at offset `>= offset` remains.
57 /// Replication and leader election use this method.
58 ///
59 /// The target can lie below the log start. This is Kafka's
60 /// `UnifiedLog.truncateTo`, which never refuses: when no local segment
61 /// reaches down to `offset`, the log is reset empty at `offset`
62 /// ([`Log::reset_to`], Kafka's `truncateFullyAndStartAt`); otherwise the
63 /// log is truncated and the log start moves down to `offset` if it was
64 /// above it. A follower whose start sits above the leader's divergence
65 /// point, after a long outage and retention or an unclean election, can
66 /// therefore always truncate.
67 #[instrument(level = "info", skip(self), err)]
68 /// # Errors
69 /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
70 pub fn truncate_to(&mut self, offset: Offset) -> Result<(), LogError> {
71 self.rollover_flusher.finish()?;
72 // Before any file changes: a read planned earlier and still running
73 // sees the count move and serves nothing, whatever it has copied.
74 self.truncations.fetch_add(1, Ordering::SeqCst);
75 let log_end = self.log_end_offset();
76 let empty_rolled_active_at_cut = offset == log_end
77 && !self.segments.is_empty()
78 && self
79 .active
80 .as_ref()
81 .is_some_and(|active| active.base_offset() == offset);
82 if offset > log_end || (offset == log_end && !empty_rolled_active_at_cut) {
83 // No record to discard, but Kafka's `UnifiedLog.truncateTo` still
84 // trims the epoch cache at the log end: a replica that took a
85 // leader epoch (`assign_epoch_start_offset` at its log end) and
86 // lost leadership before writing keeps an entry no record backs,
87 // which would otherwise conflict with the new leader's epoch.
88 self.epoch_checkpoint.truncate_from_end(log_end)?;
89 return Ok(());
90 }
91 // The discarded tail may hold the batch that stopped the last
92 // activation walk, so what that walk learned about this offset and
93 // above no longer describes the log.
94 self.invalidate_delivery_schedule(offset);
95 if offset < self.first_local_offset() {
96 return self.reset_to(offset);
97 }
98
99 // Kafka's `truncateAndReload` deletes every snapshot outside
100 // `(log start, cut]` on every truncation. Delete them before any
101 // segment goes, so a snapshot of the discarded tail can never stand
102 // in for the one the next roll at the same offset writes.
103 producer_snapshot::retain_reload_range(&self.dir, self.producer_reload_range(offset))?;
104
105 let sealed_bases: Vec<i64> = self
106 .segments
107 .iter()
108 .map(|segment| segment.base_offset().0)
109 .collect();
110 let plan = krabka_verified::local_truncation_plan(
111 &sealed_bases,
112 self.active.as_ref().map(|segment| segment.base_offset().0),
113 offset.0,
114 );
115
116 // Drop exactly the sealed suffix selected by the verified plan.
117 while self.segments.len() > plan.retained_sealed
118 && let Some(base) = self.segments.last().map(Segment::base_offset)
119 {
120 self.remove_truncated_segment_files(base)?;
121 self.segments.pop();
122 self.sealed_txn_indexes.remove(&base);
123 self.stamp_indexes.remove(&base);
124 }
125
126 // Drop the active segment if its base_offset >= offset.
127 if !plan.keep_active
128 && let Some(active) = &self.active
129 {
130 let base = active.base_offset();
131 self.remove_truncated_segment_files(base)?;
132 self.active = None;
133 self.stamp_indexes.remove(&base);
134 }
135
136 // If no active segment, promote the last sealed one (if any) and
137 // truncate it in place. Otherwise, create a fresh one at `offset`.
138 if self.active.is_none() {
139 if let Some(mut seg) = self.segments.pop() {
140 let base = seg.base_offset();
141 let rel = krabka_verified::truncation_relative_offset(base.0, offset.0)
142 .ok_or_else(|| LogError::BadSegmentName("offset overflow".into()))?;
143 seg.truncate_to_relative(rel)?;
144 let mut active_txn_index =
145 self.sealed_txn_indexes.remove(&base).ok_or_else(|| {
146 LogError::Corrupt(format!(
147 "sealed segment {} has no cached transaction index",
148 base.0
149 ))
150 })?;
151 active_txn_index.truncate_from(offset)?;
152 self.active_txn_index = active_txn_index;
153 let stamp_index_path = seg.stamp_index_path();
154 // The promoted segment's pages were handed over while it was
155 // active; the pacing goes on from its cut.
156 #[cfg(not(target_os = "wasi"))]
157 let handed = seg.size().bytes_u64();
158 self.active = Some(seg);
159 #[cfg(not(target_os = "wasi"))]
160 self.restart_writeback_pacing(handed);
161 self.stamp_indexes
162 .retain(|segment_base, _| *segment_base <= base);
163 self.reopen_active_stamp_index(base, stamp_index_path)?;
164 if let Some(index) = self.stamp_indexes.get_mut(&base) {
165 index.truncate_from(offset)?;
166 }
167 } else {
168 let mut new_seg = Segment::create(&self.dir, offset)?;
169 new_seg.set_io(Arc::clone(&self.io));
170 self.active_txn_index = TxnIndex::open(new_seg.txn_index_path())?;
171 let stamp_index_path = new_seg.stamp_index_path();
172 self.active = Some(new_seg);
173 #[cfg(not(target_os = "wasi"))]
174 self.restart_writeback_pacing(0);
175 self.dir_sync_needed = true;
176 self.stamp_indexes.clear();
177 self.reopen_active_stamp_index(offset, stamp_index_path)?;
178 }
179 } else if let Some(active) = self.active.as_mut()
180 && active.last_offset() >= offset
181 {
182 // The surviving active segment contains records at or past
183 // `offset`; truncate them in place.
184 let rel = krabka_verified::truncation_relative_offset(active.base_offset().0, offset.0)
185 .ok_or_else(|| LogError::BadSegmentName("offset overflow".into()))?;
186 active.truncate_to_relative(rel)?;
187 self.active_txn_index = TxnIndex::open(active.txn_index_path())?;
188 self.active_txn_index.truncate_from(offset)?;
189 let base = active.base_offset();
190 let stamp_index_path = active.stamp_index_path();
191 // The pages handed over past the cut are gone; the pacing goes on
192 // from the cut or from where it was, whichever comes first.
193 #[cfg(not(target_os = "wasi"))]
194 let handed = self.writeback_handed.min(active.size().bytes_u64());
195 #[cfg(not(target_os = "wasi"))]
196 self.restart_writeback_pacing(handed);
197 self.reopen_active_stamp_index(base, stamp_index_path)?;
198 if let Some(index) = self.stamp_indexes.get_mut(&base) {
199 index.truncate_from(offset)?;
200 }
201 }
202 // Kafka's `rebuildProducerState` skips the replay when there were no
203 // producers and no unreplicated transactions before the truncation:
204 // cutting the log cannot create any.
205 if !self.producer_state.is_empty() || !self.unreplicated.is_empty() {
206 self.rebuild_producer_and_transaction_state()?;
207 }
208 // Kafka's `UnifiedLog.truncateTo` rebuilds the producer state and
209 // takes a snapshot at the new log end, so a reopen replays nothing.
210 self.take_producer_snapshot()?;
211 // A verification started against the discarded tail proves nothing
212 // about the retained log. A reopen starts without one too.
213 self.verification_states.clear();
214 let new_end = self.log_end_offset();
215 // Every cached visibility frontier is clamped to the actual retained
216 // batch prefix, which can end before a cut that lands inside a batch.
217 self.lso = Offset(krabka_verified::truncation_frontier(self.lso.0, new_end.0));
218 self.invalidate_delivery_schedule(new_end);
219 // Drop leader-epoch checkpoint entries for the truncated-away tail so
220 // latest_epoch()/end_offset_for_epoch() don't report epochs that no
221 // longer have records (mirrors Kafka's truncateFromEnd).
222 self.epoch_checkpoint.truncate_from_end(new_end)?;
223 // Kafka's `logStartOffset = Math.min(targetOffset, logStartOffset)`.
224 // The retained log ends at `new_end`, which sits below `offset` when
225 // the cut landed inside a batch, and a start past the end would name
226 // records the log no longer holds.
227 self.lower_log_start_offset(offset.min(new_end))?;
228 self.reap_deleted_files(SystemTime::now())
229 }
230
231 /// Trim from the start of the log and return the resulting
232 /// `log_start_offset`.
233 ///
234 /// This method drops every sealed segment whose last offset is
235 /// `< target`. It advances `log_start_offset` when `target` falls inside
236 /// the active segment. It never deletes the active segment.
237 ///
238 /// `target` is clamped to `[0, log_end_offset()]`. A caller that asks for
239 /// a trim past the LEO gets a trim to the LEO.
240 ///
241 /// # Errors
242 ///
243 /// Returns `LogError::InvalidArgument` if `target < 0`.
244 #[instrument(
245 level = "info",
246 skip(self),
247 fields(new_log_start = field::Empty),
248 err,
249 )]
250 pub fn trim_to_offset(&mut self, target: Offset) -> Result<Offset, LogError> {
251 self.rollover_flusher.finish()?;
252 if target < 0 {
253 return Err(LogError::InvalidArgument(
254 "trim_to_offset: target must be >= 0".into(),
255 ));
256 }
257 let leo = self.log_end_offset();
258 let target = target.min(leo);
259 let log_start = self.log_start_offset();
260 if target <= log_start {
261 tracing::Span::current().record("new_log_start", log_start.0);
262 return Ok(log_start);
263 }
264
265 let to_drop: Vec<Offset> = self
266 .sealed_segments_with_next_base()
267 .take_while(|sealed| sealed.next_base <= target)
268 .map(|sealed| sealed.segment.base_offset())
269 .collect();
270 self.remove_sealed_segments(&to_drop)?;
271
272 // Every dropped segment ended below `target`, so the first offset
273 // still in the log is `target` itself: either it falls inside the
274 // first surviving sealed segment or inside the active one. The early
275 // return above already established `target > log_start`.
276 self.set_log_start_offset(target)?;
277 let result = self.log_start_offset();
278 tracing::Span::current().record("new_log_start", result.0);
279 Ok(result)
280 }
281}
282
283#[cfg(test)]
284mod tests;