Skip to main content

lsm_tree/compaction/
fifo.rs

1// Copyright (c) 2024-present, fjall-rs
2// This source code is licensed under both the Apache 2.0 and MIT License
3// (found in the LICENSE-* files in the repository)
4
5use super::{Choice, CompactionStrategy};
6use crate::{
7    compaction::state::CompactionState, config::Config, time::unix_timestamp, version::Version,
8    HashSet, KvPair,
9};
10
11#[doc(hidden)]
12pub const NAME: &str = "FifoCompaction";
13
14/// FIFO-style compaction
15///
16/// Limits the tree size to roughly `limit` bytes, deleting the oldest table(s)
17/// when the threshold is reached.
18///
19/// Will also merge tables if the number of tables in level 0 grows too much, which
20/// could cause write stalls.
21///
22/// Additionally, a (lazy) TTL can be configured to drop old tables.
23///
24/// ###### Caution
25///
26/// Only use it for specific workloads where:
27///
28/// 1) You only want to store recent data (unimportant logs, ...)
29/// 2) The key order of inserts is strictly monotonically increasing or decreasing
30/// 3) You only insert new data (no updates/deletes)
31#[derive(Clone)]
32pub struct Strategy {
33    /// Data set size limit in bytes
34    pub limit: u64,
35
36    /// TTL in seconds, will be disabled if 0 or None
37    pub ttl_seconds: Option<u64>,
38}
39
40impl Strategy {
41    /// Configures a new `Fifo` compaction strategy
42    #[must_use]
43    pub fn new(limit: u64, ttl_seconds: Option<u64>) -> Self {
44        Self { limit, ttl_seconds }
45    }
46}
47
48impl CompactionStrategy for Strategy {
49    fn get_name(&self) -> &'static str {
50        NAME
51    }
52
53    fn get_config(&self) -> Vec<KvPair> {
54        vec![
55            (
56                crate::UserKey::from("fifo_limit"),
57                crate::UserValue::from(self.limit.to_le_bytes()),
58            ),
59            (
60                crate::UserKey::from("fifo_ttl"),
61                crate::UserValue::from(if self.ttl_seconds.is_some() {
62                    [1u8]
63                } else {
64                    [0u8]
65                }),
66            ),
67            (
68                crate::UserKey::from("fifo_ttl_seconds"),
69                crate::UserValue::from(self.ttl_seconds.map(u64::to_le_bytes).unwrap_or_default()),
70            ),
71        ]
72    }
73
74    fn choose(&self, version: &Version, _: &Config, state: &CompactionState) -> Choice {
75        let first_level = version.l0();
76
77        // Early return avoids unnecessary work and keeps FIFO a no-op when there is nothing to do.
78        if first_level.is_empty() {
79            return Choice::DoNothing;
80        }
81
82        assert!(first_level.is_disjoint(), "L0 needs to be disjoint");
83
84        assert!(
85            !version.level_is_busy(0, state.hidden_set()),
86            "FIFO compaction never compacts",
87        );
88
89        // Account for both table file bytes and value-log (blob) bytes to enforce the true space limit.
90        let db_size = first_level.size() + version.blob_files.on_disk_size();
91
92        let mut ids_to_drop = HashSet::default();
93
94        // Compute TTL cutoff once and perform a single pass to mark expired tables and
95        // accumulate their sizes. Also collect non-expired tables for possible size-based drops.
96        let ttl_cutoff = match self.ttl_seconds {
97            Some(s) if s > 0 => Some(
98                unix_timestamp()
99                    .as_nanos()
100                    .saturating_sub(u128::from(s) * 1_000_000_000u128),
101            ),
102            _ => None,
103        };
104
105        let mut ttl_dropped_bytes = 0u64;
106        let mut alive = Vec::new();
107
108        for table in first_level.iter().flat_map(|run| run.iter()) {
109            let expired =
110                ttl_cutoff.is_some_and(|cutoff| u128::from(table.metadata.created_at) <= cutoff);
111
112            if expired {
113                ids_to_drop.insert(table.id());
114                let linked_blob_file_bytes = table.referenced_blob_bytes().unwrap_or_default();
115                ttl_dropped_bytes += table.file_size() + linked_blob_file_bytes;
116            } else {
117                alive.push(table);
118            }
119        }
120
121        // Subtract TTL-selected bytes to see if we're still over the limit.
122        let size_after_ttl = db_size.saturating_sub(ttl_dropped_bytes);
123
124        // If we still exceed the limit, drop additional oldest tables until within the limit.
125        if size_after_ttl > self.limit {
126            let overshoot = size_after_ttl - self.limit;
127
128            let mut collected_bytes = 0;
129
130            // Oldest-first list by creation time from the non-expired set.
131            alive.sort_by_key(|t| t.metadata.created_at);
132
133            for table in alive {
134                if collected_bytes >= overshoot {
135                    break;
136                }
137
138                ids_to_drop.insert(table.id());
139
140                let linked_blob_file_bytes = table.referenced_blob_bytes().unwrap_or_default();
141                collected_bytes += table.file_size() + linked_blob_file_bytes;
142            }
143        }
144
145        if ids_to_drop.is_empty() {
146            Choice::DoNothing
147        } else {
148            Choice::Drop(ids_to_drop)
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::Strategy;
156    use crate::{AbstractTree, Config, KvSeparationOptions, SequenceNumberCounter};
157    use std::sync::Arc;
158
159    #[test]
160    fn fifo_empty_levels() -> crate::Result<()> {
161        let dir = tempfile::tempdir()?;
162        let tree = Config::new(
163            dir.path(),
164            SequenceNumberCounter::default(),
165            SequenceNumberCounter::default(),
166        )
167        .open()?;
168
169        let fifo = Arc::new(Strategy::new(1, None));
170        tree.compact(fifo, 0)?;
171
172        assert_eq!(0, tree.table_count());
173        Ok(())
174    }
175
176    #[test]
177    fn fifo_below_limit() -> crate::Result<()> {
178        let dir = tempfile::tempdir()?;
179        let tree = Config::new(
180            dir.path(),
181            SequenceNumberCounter::default(),
182            SequenceNumberCounter::default(),
183        )
184        .open()?;
185
186        for i in 0..4u8 {
187            tree.insert([b'k', i].as_slice(), "v", u64::from(i));
188            tree.flush_active_memtable(u64::from(i))?;
189        }
190
191        let before = tree.table_count();
192        let fifo = Arc::new(Strategy::new(u64::MAX, None));
193        tree.compact(fifo, 4)?;
194
195        assert_eq!(before, tree.table_count());
196        Ok(())
197    }
198
199    #[test]
200    fn fifo_more_than_limit() -> crate::Result<()> {
201        let dir = tempfile::tempdir()?;
202        let tree = Config::new(
203            dir.path(),
204            SequenceNumberCounter::default(),
205            SequenceNumberCounter::default(),
206        )
207        .open()?;
208
209        for i in 0..4u8 {
210            tree.insert([b'k', i].as_slice(), "v", u64::from(i));
211            tree.flush_active_memtable(u64::from(i))?;
212        }
213
214        let before = tree.table_count();
215        // Very small limit forces dropping oldest tables
216        let fifo = Arc::new(Strategy::new(1, None));
217        tree.compact(fifo, 4)?;
218
219        assert!(tree.table_count() < before);
220        Ok(())
221    }
222
223    #[test]
224    fn fifo_more_than_limit_blobs() -> crate::Result<()> {
225        let dir = tempfile::tempdir()?;
226        let tree = Config::new(
227            dir.path(),
228            SequenceNumberCounter::default(),
229            SequenceNumberCounter::default(),
230        )
231        .with_kv_separation(Some(KvSeparationOptions::default().separation_threshold(1)))
232        .open()?;
233
234        for i in 0..3u8 {
235            tree.insert([b'k', i].as_slice(), "$", u64::from(i));
236            tree.flush_active_memtable(u64::from(i))?;
237        }
238
239        let before = tree.table_count();
240        let fifo = Arc::new(Strategy::new(1, None));
241        tree.compact(fifo, 3)?;
242
243        assert!(tree.table_count() < before);
244        Ok(())
245    }
246
247    #[test]
248    fn fifo_ttl() -> crate::Result<()> {
249        let dir = tempfile::tempdir()?;
250        let tree = Config::new(
251            dir.path(),
252            SequenceNumberCounter::default(),
253            SequenceNumberCounter::default(),
254        )
255        .open()?;
256
257        // Freeze time and create first (older) table at t=1000s
258        crate::time::set_unix_timestamp_for_test(Some(std::time::Duration::from_secs(1_000)));
259        tree.insert("a", "1", 0);
260        tree.flush_active_memtable(0)?;
261
262        // Advance time and create second (newer) table at t=1005s
263        crate::time::set_unix_timestamp_for_test(Some(std::time::Duration::from_secs(1_005)));
264        tree.insert("b", "2", 1);
265        tree.flush_active_memtable(1)?;
266
267        // Now set current time to t=1011s; with TTL=10s, cutoff=1001s => drop first only
268        crate::time::set_unix_timestamp_for_test(Some(std::time::Duration::from_secs(1_011)));
269
270        assert_eq!(2, tree.table_count());
271
272        let fifo = Arc::new(Strategy::new(u64::MAX, Some(10)));
273        tree.compact(fifo, 2)?;
274
275        assert_eq!(1, tree.table_count());
276
277        // Reset override
278        crate::time::set_unix_timestamp_for_test(None);
279        Ok(())
280    }
281
282    #[test]
283    fn fifo_ttl_then_limit_additional_drops_blob_unit() -> crate::Result<()> {
284        let dir = tempfile::tempdir()?;
285        let tree = Config::new(
286            dir.path(),
287            SequenceNumberCounter::default(),
288            SequenceNumberCounter::default(),
289        )
290        .with_kv_separation(Some(KvSeparationOptions::default().separation_threshold(1)))
291        .open()?;
292
293        // Create two tables; we will expire them via time override and force additional drops via limit.
294        tree.insert("a", "$", 0);
295        tree.flush_active_memtable(0)?;
296        tree.insert("b", "$", 1);
297        tree.flush_active_memtable(1)?;
298
299        crate::time::set_unix_timestamp_for_test(Some(std::time::Duration::from_secs(10_000_000)));
300
301        // TTL=1s will mark both expired; very small limit ensures size-based collection path is also exercised.
302        let fifo = Arc::new(Strategy::new(1, Some(1)));
303        tree.compact(fifo, 2)?;
304
305        assert_eq!(0, tree.table_count());
306
307        crate::time::set_unix_timestamp_for_test(None);
308        Ok(())
309    }
310}