Skip to main content

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;