Skip to main content

lsm_tree/compaction/
fifo.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5use super::{Choice, CompactionStrategy};
6use crate::{
7    HashMap, HashSet, KvPair, TableId, compaction::state::CompactionState, config::Config,
8    table::Table, time::now_on, version::Version, vlog::BlobFileId,
9};
10#[cfg(not(feature = "std"))]
11use alloc::vec::Vec;
12
13#[doc(hidden)]
14pub const NAME: &str = "FifoCompaction";
15
16/// FIFO-style compaction
17///
18/// Limits the tree size to roughly `limit` bytes, deleting the oldest table(s)
19/// when the threshold is reached. Tables are dropped whole, oldest data first,
20/// from whichever level they are in, so a tree that was major-compacted keeps
21/// its limit, and overlapping tables are fine: the older one goes first. A
22/// table's age is its `created_at`: the write time of a flushed table, and for
23/// a compaction output the newest age among the inputs its key range meets,
24/// so a compaction neither makes data look newer nor restarts its TTL. Tables
25/// of one age, as the outputs that split one input, go by their highest
26/// sequence number, and in key order once a compaction has zeroed it. A table
27/// another compaction is working on is left for a later round, and no newer
28/// table is dropped ahead of it.
29///
30/// Age is as fine as a table and the clock, no finer: a table goes whole, so
31/// newer records of it go with its oldest, and data of one age is equally old
32/// to FIFO whatever order it was inserted in. FIFO itself never merges
33/// tables; another compaction run on the tree (`major_compact`, another
34/// strategy) can, and FIFO applies its limit and TTL to those outputs by the
35/// age they carry.
36///
37/// Additionally, a (lazy) TTL can be configured to drop old tables, measured on
38/// the tree's clock ([`Config::with_clock`]). It is off while the clock reads
39/// zero, which is no clock.
40///
41/// ###### Caution
42///
43/// Only use it for specific workloads where:
44///
45/// 1) You only want to store recent data (unimportant logs, ...)
46/// 2) The key order of inserts is strictly monotonically increasing or decreasing
47/// 3) You only insert new data (no updates/deletes)
48#[derive(Clone)]
49pub struct Strategy {
50    /// Data set size limit in bytes
51    pub limit: u64,
52
53    /// TTL in seconds, will be disabled if 0 or None
54    pub ttl_seconds: Option<u64>,
55}
56
57impl Strategy {
58    /// Configures a new `Fifo` compaction strategy
59    #[must_use]
60    pub fn new(limit: u64, ttl_seconds: Option<u64>) -> Self {
61        Self { limit, ttl_seconds }
62    }
63}
64
65impl CompactionStrategy for Strategy {
66    fn get_name(&self) -> &'static str {
67        NAME
68    }
69
70    fn get_config(&self) -> Vec<KvPair> {
71        vec![
72            (
73                crate::UserKey::from("fifo_limit"),
74                crate::UserValue::from(self.limit.to_le_bytes()),
75            ),
76            (
77                crate::UserKey::from("fifo_ttl"),
78                crate::UserValue::from(if self.ttl_seconds.is_some() {
79                    [1u8]
80                } else {
81                    [0u8]
82                }),
83            ),
84            (
85                crate::UserKey::from("fifo_ttl_seconds"),
86                crate::UserValue::from(self.ttl_seconds.map(u64::to_le_bytes).unwrap_or_default()),
87            ),
88        ]
89    }
90
91    fn choose(&self, version: &Version, config: &Config, state: &CompactionState) -> Choice {
92        // Early return avoids unnecessary work and keeps FIFO a no-op when there is nothing to do.
93        if version.iter_tables().next().is_none() {
94            return Choice::DoNothing;
95        }
96
97        // Account for both table file bytes and value-log (blob) bytes to enforce the true space
98        // limit. A table another compaction holds still occupies its space, so it counts here,
99        // but only free tables can be dropped below.
100        // Summed on-disk sizes cannot overflow u64.
101        let db_size = version.iter_tables().map(Table::file_size).sum::<u64>()
102            + version.blob_files.on_disk_size();
103        let hidden = state.hidden_set();
104
105        let mut ids_to_drop: HashSet<_> = HashSet::default();
106
107        // Compute TTL cutoff once and perform a single pass to mark expired tables and
108        // accumulate their sizes. Also collect non-expired tables for possible size-based drops.
109        let now = now_on(config.clock.as_ref());
110        let ttl_cutoff = match self.ttl_seconds {
111            // A clock at zero is no clock (a `no_std` build before one is
112            // registered): TTL is off then, as tables written meanwhile carry
113            // time zero too and would all look expired.
114            Some(s) if s > 0 && !now.is_zero() => Some(
115                // Clamp-to-zero: a TTL longer than the wall clock leaves no
116                // expiry cutoff rather than wrapping.
117                now.as_nanos()
118                    .saturating_sub(u128::from(s) * 1_000_000_000u128),
119            ),
120            _ => None,
121        };
122        // A table whose blob references cannot be read leaves the bytes it
123        // frees unknown: the round waits for one that can read them.
124        let mut blob_credit = BlobCredit::new(version, state);
125
126        let mut ttl_dropped_bytes = 0u64;
127        // Every table not expired, held ones included: they keep their place in
128        // the age order below.
129        let mut alive = Vec::new();
130
131        for table in version.iter_tables() {
132            let held = hidden.is_hidden(table.id());
133            // A table stamped while the clock read zero has no age at all: it
134            // is never TTL-expired, though the clock has run since.
135            let age = u128::from(table.metadata.created_at);
136            let expired = !held && age != 0 && ttl_cutoff.is_some_and(|cutoff| age <= cutoff);
137
138            if expired {
139                ids_to_drop.insert(table.id());
140                let Some(freed) = blob_credit.drop_table(table.id()) else {
141                    return Choice::DoNothing;
142                };
143                // Accumulated dropped-byte total, bounded by the on-disk size;
144                // cannot overflow u64.
145                ttl_dropped_bytes += table.file_size() + freed;
146            } else {
147                alive.push((table, held));
148            }
149        }
150
151        // Subtract TTL-selected bytes to see if we're still over the limit.
152        let size_after_ttl = db_size.saturating_sub(ttl_dropped_bytes);
153
154        // If we still exceed the limit, drop additional oldest tables until within the limit.
155        if size_after_ttl > self.limit {
156            let overshoot = size_after_ttl - self.limit;
157
158            let mut collected_bytes = 0u64;
159
160            // Oldest data first, by age: a compaction output carries the
161            // newest age of the inputs its keys came from, so the order holds
162            // after a major compaction, even one that zeroed the sequence
163            // numbers. The highest sequence number orders tables of one age:
164            // FIFO admits only inserts, so it follows insertion while kept.
165            // Once a bottommost compaction has zeroed it, tables of one age
166            // fall to key order: they are equally old, which is all the
167            // order FIFO promises, so the zeroing stays (it is what keeps a
168            // bottom-level record's sequence number at one byte).
169            alive.sort_by_key(|(t, _)| (t.metadata.created_at, t.get_highest_seqno(), t.id()));
170
171            for (table, held) in alive {
172                if collected_bytes >= overshoot {
173                    break;
174                }
175                // A held table still counts against the limit, and dropping
176                // newer tables in its place would lose recent data while the
177                // older one stays: wait for the round after its compaction.
178                if held {
179                    break;
180                }
181
182                ids_to_drop.insert(table.id());
183                let Some(freed) = blob_credit.drop_table(table.id()) else {
184                    return Choice::DoNothing;
185                };
186
187                // Accumulated collected-byte total, bounded by the on-disk size;
188                // cannot overflow u64.
189                collected_bytes += table.file_size() + freed;
190            }
191        }
192
193        if ids_to_drop.is_empty() {
194            Choice::DoNothing
195        } else {
196            Choice::Drop(ids_to_drop)
197        }
198    }
199}
200
201/// What dropping tables frees in blob files. A blob file goes only with the
202/// last table that references it, so several tables sharing one, as the
203/// outputs of a compaction do, free its bytes together and none of them
204/// alone. A file a memtable row references stays even then, so it frees
205/// nothing.
206///
207/// The references are read on the first drop, so a round that drops nothing
208/// reads none.
209struct BlobCredit<'v> {
210    version: &'v Version,
211    state: &'v CompactionState,
212    /// Every table's references, once read.
213    refs: Option<BlobRefs>,
214}
215
216/// Every table's blob references, and how many tables reference each file.
217struct BlobRefs {
218    /// The blob files each table references.
219    files_of: HashMap<TableId, Vec<BlobFileId>>,
220    /// How many tables, dropped or not yet, still reference each blob file.
221    refs: HashMap<BlobFileId, usize>,
222}
223
224impl BlobRefs {
225    /// The references of every table in `version`, or `None` when one cannot
226    /// be read. A version without blob files reads none.
227    fn read(version: &Version) -> Option<Self> {
228        let mut files_of = HashMap::default();
229        let mut refs: HashMap<BlobFileId, usize> = HashMap::default();
230        if version.blob_files.len() > 0 {
231            for table in version.iter_tables() {
232                let files: Vec<BlobFileId> = table
233                    .blob_links()
234                    .ok()?
235                    .iter()
236                    .map(|file| file.blob_file_id)
237                    .collect();
238                for &file in &files {
239                    *refs.entry(file).or_insert(0) += 1;
240                }
241                files_of.insert(table.id(), files);
242            }
243        }
244        Some(Self { files_of, refs })
245    }
246}
247
248impl<'v> BlobCredit<'v> {
249    fn new(version: &'v Version, state: &'v CompactionState) -> Self {
250        Self {
251            version,
252            state,
253            refs: None,
254        }
255    }
256
257    /// The blob bytes freed by dropping `table` after the tables dropped
258    /// before it: those of every blob file it was the last to reference.
259    /// `None` when the references cannot be read, which leaves what dropping
260    /// frees unknown.
261    fn drop_table(&mut self, table: TableId) -> Option<u64> {
262        if self.refs.is_none() {
263            self.refs = Some(BlobRefs::read(self.version)?);
264        }
265        let refs = self.refs.as_mut()?;
266        let Some(files) = refs.files_of.remove(&table) else {
267            return Some(0);
268        };
269        let mut freed = 0;
270        for file in files {
271            let Some(count) = refs.refs.get_mut(&file) else {
272                continue;
273            };
274            // Counted from the same lists, once per referencing table, and a
275            // table's list leaves `files_of` on its first drop.
276            debug_assert!(*count > 0, "blob file {file} released twice");
277            *count -= 1;
278            if *count == 0 && !self.state.memtable_references_blob_file(file) {
279                freed += self
280                    .version
281                    .blob_files
282                    .get(file)
283                    .map_or(0, |blob_file| blob_file.meta().total_compressed_bytes);
284            }
285        }
286        Some(freed)
287    }
288}
289
290#[cfg(test)]
291mod tests;