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;