Skip to main content

krabka_log/log/
maintenance.rs

1//! Flush thresholds, per-segment roll jitter, and deferred file reclamation.
2
3// Only `pace_writeback` reads a byte count, and `wasm32-wasip1` has no
4// `pace_writeback`.
5#[cfg(not(target_os = "wasi"))]
6use {super::page_cache, krabka_units::prelude::ByteSizeExt};
7use {
8    super::{Log, PendingDelete, RollJitter},
9    crate::{
10        error::LogError,
11        io::LogIo,
12        retention::{self, DirSync},
13        segment::Segment,
14    },
15    krabka_ids::Offset,
16    krabka_units::{Time, prelude::TimeExt as _},
17    std::{
18        collections::hash_map::RandomState,
19        hash::BuildHasher,
20        mem,
21        sync::{Arc, PoisonError},
22        time::{Duration, Instant, SystemTime},
23    },
24};
25
26/// Retired segment files whose deletion delay has passed, taken out of the
27/// log by [`Log::maintain_deferring_deletions`].
28///
29/// Unlinking a large segment file drops its page cache, which takes tens of
30/// milliseconds a file on a loaded disk. Holding the files here lets the
31/// caller delete them without holding the log, so the appends and reads of
32/// the partition do not wait for it.
33#[derive(Debug, Default)]
34#[must_use = "the retired files stay on disk until `delete` runs"]
35pub struct DueDeletions {
36    io: Option<Arc<dyn LogIo>>,
37    pending: Vec<PendingDelete>,
38}
39
40/// A [`DueDeletions::delete`] that stopped at a failure.
41#[derive(Debug)]
42pub struct FailedDeletion {
43    /// The failure that stopped the deletion.
44    pub error: LogError,
45    /// The files not yet deleted. [`Log::restore_deletions`] hands them back
46    /// to the log for a later pass.
47    pub remaining: DueDeletions,
48}
49
50impl DueDeletions {
51    /// Whether there is nothing to delete.
52    #[must_use]
53    pub fn is_empty(&self) -> bool { self.pending.is_empty() }
54
55    /// Delete the files, newest name first within each retired segment, as
56    /// [`Log::maintain`] does.
57    ///
58    /// # Errors
59    /// Stops at the first file that fails to delete and returns the error with
60    /// every file not yet deleted.
61    pub fn delete(mut self) -> Result<(), FailedDeletion> {
62        let Some(io) = self.io.clone() else {
63            return Ok(());
64        };
65        while let Some(pending) = self.pending.first_mut() {
66            while let Some(path) = pending.paths.last() {
67                if let Err(error) = retention::remove_optional(&*io, path) {
68                    return Err(FailedDeletion {
69                        error,
70                        remaining: self,
71                    });
72                }
73                pending.paths.pop();
74            }
75            self.pending.remove(0);
76        }
77        Ok(())
78    }
79}
80
81impl Log {
82    /// Flush dirty data, write a log start retention moved once its
83    /// checkpoint is due, and reclaim retired files whose deadlines have
84    /// passed.
85    ///
86    /// # Errors
87    /// Returns the first flush or file deletion failure.
88    pub fn maintain(&mut self, now: SystemTime) -> Result<(), LogError> {
89        self.rollover_flusher.check()?;
90        self.flush_if_due(Instant::now())?;
91        self.checkpoint_log_start_if_due()?;
92        self.reap_deleted_files(now)
93    }
94
95    /// [`Self::maintain`], except that the retired files whose deadlines have
96    /// passed are handed back rather than deleted under `&mut self`.
97    ///
98    /// # Errors
99    /// Returns the first flush or checkpoint failure. The retired files then
100    /// stay with the log.
101    pub fn maintain_deferring_deletions(
102        &mut self,
103        now: SystemTime,
104    ) -> Result<DueDeletions, LogError> {
105        self.rollover_flusher.check()?;
106        self.flush_if_due(Instant::now())?;
107        self.checkpoint_log_start_if_due()?;
108        Ok(self.take_due_deletions(now))
109    }
110
111    /// Take the retired files whose deadlines have passed, leaving the rest.
112    pub fn take_due_deletions(&mut self, now: SystemTime) -> DueDeletions {
113        let (due, waiting) = mem::take(&mut self.pending_deletes)
114            .into_iter()
115            .partition(|pending| pending.deadline <= now);
116        self.pending_deletes = waiting;
117        DueDeletions {
118            io: Some(Arc::clone(&self.io)),
119            pending: due,
120        }
121    }
122
123    /// Hand back the files a [`DueDeletions::delete`] did not delete. They
124    /// become due again at `not_before`, so a caller retrying a failing disk
125    /// can space its attempts.
126    pub fn restore_deletions(&mut self, remaining: DueDeletions, not_before: SystemTime) {
127        self.pending_deletes
128            .extend(remaining.pending.into_iter().map(|pending| PendingDelete {
129                deadline: pending.deadline.max(not_before),
130                paths: pending.paths,
131            }));
132    }
133
134    /// Time until the next flush, log-start checkpoint or retired-file
135    /// deadline, or `None` when idle.
136    #[must_use]
137    pub fn maintenance_delay(&self, now: SystemTime) -> Option<Duration> {
138        self.maintenance_delay_at(now, Instant::now())
139    }
140
141    pub(super) fn maintenance_delay_at(
142        &self,
143        now: SystemTime,
144        elapsed: Instant,
145    ) -> Option<Duration> {
146        let config = self.config.read().unwrap_or_else(PoisonError::into_inner);
147        let flush = if self.unflushed_messages == 0 {
148            None
149        } else if config
150            .flush_messages
151            .is_some_and(|limit| self.unflushed_messages >= limit)
152        {
153            Some(Duration::ZERO)
154        } else {
155            config.flush_interval.map(|interval| {
156                Duration::from_millis(u64::try_from(interval.millis_i64_trunc()).unwrap_or(0))
157                    .saturating_sub(elapsed.saturating_duration_since(self.last_flush))
158            })
159        };
160        flush
161            .into_iter()
162            .chain(self.start_checkpoint_delay(elapsed))
163            .chain(
164                self.pending_deletes
165                    .iter()
166                    .map(|pending| pending.deadline.duration_since(now).unwrap_or_default()),
167            )
168            .min()
169    }
170
171    pub(super) fn jittered_roll_interval(&mut self, interval: Time) -> Time {
172        let bound = self
173            .config
174            .read()
175            .unwrap_or_else(PoisonError::into_inner)
176            .segment_jitter
177            .millis_i64_trunc()
178            .min(interval.millis_i64_trunc());
179        let Some(bound) = u64::try_from(bound).ok().filter(|&bound| bound > 0) else {
180            return interval;
181        };
182        // An open log always has an active segment; without one there is no
183        // roll to jitter.
184        let Some(base) = self.active.as_ref().map(Segment::base_offset) else {
185            return interval;
186        };
187        let sample = match self.roll_jitter {
188            Some(RollJitter {
189                segment_base,
190                sample,
191            }) if segment_base == base => sample,
192            _ => {
193                let sample = RandomState::new().hash_one(base);
194                self.roll_jitter = Some(RollJitter {
195                    segment_base: base,
196                    sample,
197                });
198                sample
199            }
200        };
201        // The remainder is below `bound`, which came from an `i64`.
202        let jitter = i64::try_from(sample % bound).unwrap_or(i64::MAX);
203        Time::from_millis(interval.millis_i64_trunc().saturating_sub(jitter))
204    }
205
206    pub(super) fn flush_after_append(&mut self, delta: i32, force: bool) -> Result<(), LogError> {
207        self.unflushed_messages = self
208            .unflushed_messages
209            .saturating_add(u64::try_from(delta).unwrap_or(0) + 1);
210        let threshold = self
211            .config
212            .read()
213            .unwrap_or_else(PoisonError::into_inner)
214            .flush_messages;
215        if force {
216            // Durable sidecars must not precede records in pending rollover jobs.
217            self.rollover_flusher.finish()?;
218            self.active_segment_flush()?;
219            self.unflushed_messages = 0;
220            self.last_flush = Instant::now();
221        } else if threshold.is_some_and(|limit| self.unflushed_messages >= limit) {
222            self.sync()?;
223        } else {
224            self.flush_if_due(Instant::now())?;
225        }
226        Ok(())
227    }
228
229    /// Start a background sync of the active segment after every
230    /// `writeback_window` bytes appended through the page cache, and wait for
231    /// the oldest one while two are still running.
232    ///
233    /// This is what bounds the dirty data the kernel holds for the segment,
234    /// to about two windows: see [`crate::LogConfig::writeback_window`]. The
235    /// wait is the only time an append stops for it, and it ends when a sync
236    /// of one window's bytes ends. Each sync also hands the segment's whole
237    /// pages written since the last one to the page-cache ledger, which
238    /// drops the oldest pages of the process's logs beyond
239    /// [`crate::LogConfig::page_cache_budget`]. A segment written through
240    /// `O_DIRECT` has no dirty pages, and `flush_on_append` syncs every
241    /// append, so neither counts bytes here. `wasm32-wasip1` has no flush
242    /// pool and paces nothing.
243    #[cfg(not(target_os = "wasi"))]
244    pub(super) fn pace_writeback(&mut self, appended: usize) -> Result<(), LogError> {
245        let (window, budget) = {
246            let config = self.config.read().unwrap_or_else(PoisonError::into_inner);
247            let window = if config.flush_on_append {
248                0
249            } else {
250                config.writeback_window.bytes_u64()
251            };
252            (window, config.page_cache_budget.map(ByteSizeExt::bytes_u64))
253        };
254        let active = self.active.as_ref().ok_or_else(super::no_active_segment)?;
255        if window == 0 || active.writes_direct() {
256            self.writeback_unsynced = 0;
257            return Ok(());
258        }
259        self.writeback_unsynced = self
260            .writeback_unsynced
261            .saturating_add(u64::try_from(appended).unwrap_or(u64::MAX));
262        if self.writeback_unsynced < window {
263            return Ok(());
264        }
265        self.writeback_unsynced = 0;
266        self.hand_over_pages(budget)
267    }
268
269    /// Hand the whole pages of the active segment written since the last
270    /// hand-over to a write-back job, after waiting for the oldest job while
271    /// two are still running. This is the end of a window of
272    /// [`Self::pace_writeback`], and what [`Log::sync`] does with the pages a
273    /// window had not reached, so no page written through the page cache
274    /// stays outside the ledger's accounting. Nothing happens for a segment
275    /// that writes directly, or when no whole page is new.
276    ///
277    /// Whole pages only: the page the next append writes into stays out of
278    /// the write-out, so the append never finds it busy and the ledger never
279    /// drops it from under it, and no page is handed over twice.
280    #[cfg(not(target_os = "wasi"))]
281    pub(super) fn hand_over_pages(&mut self, budget: Option<u64>) -> Result<(), LogError> {
282        let active = self.active.as_ref().ok_or_else(super::no_active_segment)?;
283        if active.writes_direct() {
284            return Ok(());
285        }
286        let end = active.size().bytes_u64() & !(page_cache::PAGE - 1);
287        let offset = self.writeback_handed;
288        if end <= offset {
289            return Ok(());
290        }
291        while self.writeback_jobs.len() >= 2
292            && let Some(oldest) = self.writeback_jobs.pop_front()
293        {
294            self.rollover_flusher.finish_through(oldest)?;
295        }
296        self.writeback_handed = end;
297        let file = active.log_file_handle();
298        self.rollover_flusher.submit_writeback(
299            file,
300            Arc::clone(&self.io),
301            offset,
302            end - offset,
303            budget,
304        )?;
305        self.writeback_jobs
306            .push_back(self.rollover_flusher.ticket());
307        Ok(())
308    }
309
310    /// The page-cache budget in bytes, as configured.
311    #[cfg(not(target_os = "wasi"))]
312    pub(super) fn page_cache_budget(&self) -> Option<u64> {
313        self.config
314            .read()
315            .unwrap_or_else(PoisonError::into_inner)
316            .page_cache_budget
317            .map(ByteSizeExt::bytes_u64)
318    }
319
320    /// Start the write-back pacing over for the segment that is active now,
321    /// with its pages handed over up to `handed`: a fresh segment at 0, a
322    /// segment cut in place at the cut, a sealed segment promoted by a cut at
323    /// its size, all on a page boundary. A roll, a truncation and a reset
324    /// each call this when they replace or cut the active segment; the
325    /// frontier of the segment before would otherwise hold back every window
326    /// until the new one grew past it.
327    #[cfg(not(target_os = "wasi"))]
328    pub(super) fn restart_writeback_pacing(&mut self, handed: u64) {
329        self.writeback_unsynced = 0;
330        self.writeback_jobs.clear();
331        self.writeback_handed = handed & !(page_cache::PAGE - 1);
332    }
333
334    pub(super) fn flush_if_due(&mut self, now: Instant) -> Result<(), LogError> {
335        let due = {
336            let config = self.config.read().unwrap_or_else(PoisonError::into_inner);
337            self.unflushed_messages > 0
338                && (config
339                    .flush_messages
340                    .is_some_and(|limit| self.unflushed_messages >= limit)
341                    || config.flush_interval.is_some_and(|interval| {
342                        now.saturating_duration_since(self.last_flush).as_millis()
343                            >= u128::try_from(interval.millis_i64_trunc()).unwrap_or(0)
344                    }))
345        };
346        if due {
347            self.sync()?;
348            self.last_flush = now;
349        }
350        Ok(())
351    }
352
353    pub(super) fn retire_segment(&mut self, base: Offset, now: SystemTime) -> Result<(), LogError> {
354        if self
355            .config
356            .read()
357            .unwrap_or_else(PoisonError::into_inner)
358            .file_delete_delay
359            <= Time::ZERO
360        {
361            return retention::delete_segment_files(&*self.io, &self.dir, base);
362        }
363        self.retire_files(
364            base,
365            now,
366            &[
367                "log",
368                "index",
369                "timeindex",
370                "txnindex",
371                "stampindex",
372                "snapshot",
373            ],
374            DirSync::Now,
375        )
376    }
377
378    pub(super) fn retire_files(
379        &mut self,
380        base: Offset,
381        now: SystemTime,
382        extensions: &[&str],
383        sync: DirSync,
384    ) -> Result<(), LogError> {
385        let delay = self
386            .config
387            .read()
388            .unwrap_or_else(PoisonError::into_inner)
389            .file_delete_delay;
390        let delay = Duration::from_millis(u64::try_from(delay.millis_i64_trunc()).unwrap_or(0));
391        let deadline = now
392            .checked_add(delay)
393            .ok_or_else(|| LogError::InvalidArgument("file deletion deadline overflow".into()))?;
394        let mut paths = Vec::new();
395        let result =
396            retention::retire_files(&*self.io, &self.dir, base, extensions, &mut paths, sync);
397        if !paths.is_empty() {
398            self.pending_deletes.push(PendingDelete { deadline, paths });
399        }
400        result
401    }
402
403    pub(super) fn reap_deleted_files(&mut self, now: SystemTime) -> Result<(), LogError> {
404        self.take_due_deletions(now)
405            .delete()
406            .map_err(|FailedDeletion { error, remaining }| {
407                self.restore_deletions(remaining, now);
408                error
409            })
410    }
411}
412
413#[cfg(test)]
414mod tests;