Skip to main content

regolith/
event_listener.rs

1//! Lifecycle event callbacks for flush, compaction, and ingest.
2//!
3//! Callers register one or more [`EventListener`] implementations via
4//! [`crate::Options::listeners`] to react to engine lifecycle events.
5//! Typical uses: metrics pipelines (emit a span per flush), debugging
6//! (log which files compaction picked), test harnesses (wait on a
7//! callback instead of sleeping), and operational triggers (upload
8//! the freshly-flushed SSTable to object storage).
9//!
10//! # Dispatch
11//!
12//! Events are dispatched **synchronously** on the thread that
13//! triggered them - flush events on the write thread, compaction
14//! events on the compaction thread, ingest events on the ingest
15//! caller's thread. Listeners **MUST NOT block** or re-enter the
16//! database. The contract is "do a cheap thing or spawn a task."
17//! Blocking inside a listener stalls the engine and will starve
18//! background compaction / flush pipelines.
19//!
20//! # Non-guarantees
21//!
22//! - Listener order is unspecified. Multiple listeners registered
23//!   in the same `Options::listeners` vector see events in vector
24//!   order, but a caller that cares about sequencing should own
25//!   its own fan-out.
26//! - Per-column-family filtering is out of scope. A listener sees
27//!   every event from every CF; callers that only care about a
28//!   subset should filter in the callback.
29//! - `on_wal_full` is declared so listener implementations can
30//!   target a common shape across storage backends, but regolith
31//!   itself never fires it - the WAL is rotated alongside every
32//!   memtable, so there's no separate "WAL-full" condition.
33
34use std::path::PathBuf;
35use std::time::Duration;
36
37use crate::Error;
38
39/// Why the flush / compaction path chose to produce a file, used
40/// by [`TableFileCreationInfo::reason`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum TableFileCreationReason {
43    /// A memtable was flushed to L0.
44    Flush,
45    /// A compaction merged files at one level into another.
46    Compaction,
47    /// A file was ingested via [`crate::Db::ingest_external_files`].
48    Recovery,
49}
50
51/// Information about a flush that just completed.
52#[derive(Debug, Clone)]
53pub struct FlushJobInfo {
54    /// Numeric id of the SSTable that now holds the flushed
55    /// memtable's contents.
56    pub file_id: u64,
57    /// Path of the produced SSTable.
58    pub file_path: PathBuf,
59    /// File size on disk, in bytes.
60    pub file_size: u64,
61    /// Number of point entries in the flushed SSTable.
62    pub num_entries: u64,
63    /// Smallest user key in the file.
64    pub smallest_key: Vec<u8>,
65    /// Largest user key in the file.
66    pub largest_key: Vec<u8>,
67    /// Wall-clock duration of the flush, from memtable rotation
68    /// through manifest apply.
69    /// Zero on a platform whose [`crate::env::Env`] has no
70    /// monotonic clock, where nothing was measured.
71    pub duration: Duration,
72}
73
74/// Information about a compaction job, passed to
75/// [`EventListener::on_compaction_begin`] and
76/// [`EventListener::on_compaction_completed`].
77#[derive(Debug, Clone)]
78pub struct CompactionJobInfo {
79    /// Source level (the level compaction is reading from).
80    pub input_level: usize,
81    /// Destination level (one deeper than `input_level`).
82    pub output_level: usize,
83    /// File ids of the inputs at `input_level` (the "L" files).
84    pub input_files_input_level: Vec<u64>,
85    /// File ids of the inputs at `output_level` that overlap the
86    /// input range (the "L+1" files that get merged in).
87    pub input_files_output_level: Vec<u64>,
88    /// File ids of the newly-produced output files at
89    /// `output_level`. Populated on
90    /// [`EventListener::on_compaction_completed`]; empty on
91    /// [`EventListener::on_compaction_begin`].
92    pub output_files: Vec<u64>,
93    /// Wall-clock duration of the compaction. Zero on begin, and
94    /// zero on a platform whose [`crate::env::Env`] has no monotonic
95    /// clock, where nothing was measured.
96    pub duration: Duration,
97}
98
99/// Information about a freshly-created SSTable file. Fires for
100/// both flush output and compaction output, distinguished by
101/// [`TableFileCreationReason`].
102#[derive(Debug, Clone)]
103pub struct TableFileCreationInfo {
104    /// Numeric id of the SSTable.
105    pub file_id: u64,
106    /// Path of the created file.
107    pub file_path: PathBuf,
108    /// Level the file was placed at.
109    pub level: usize,
110    /// Reason the file was produced.
111    pub reason: TableFileCreationReason,
112    /// File size on disk.
113    pub file_size: u64,
114    /// Number of point entries in the file.
115    pub num_entries: u64,
116}
117
118/// Information about an SSTable file that was just unlinked from
119/// disk. Fires after compaction has committed the version edit
120/// that removed the file from the live set and the physical
121/// `unlink(2)` has succeeded.
122#[derive(Debug, Clone)]
123pub struct TableFileDeletionInfo {
124    /// Numeric id of the unlinked file.
125    pub file_id: u64,
126    /// Path of the unlinked file at the time of deletion.
127    pub file_path: PathBuf,
128}
129
130/// Information about a file ingested via
131/// [`crate::Db::ingest_external_files`].
132#[derive(Debug, Clone)]
133pub struct ExternalFileIngestionInfo {
134    /// Path the caller supplied to `ingest_external_files`.
135    pub external_file_path: PathBuf,
136    /// Internal file id assigned by the engine.
137    pub internal_file_id: u64,
138    /// Level the ingested file was placed at.
139    pub level: usize,
140    /// Total entries in the ingested file (point + range deletes).
141    pub num_entries: u64,
142    /// Size of the re-emitted file on disk.
143    pub file_size: u64,
144}
145
146/// Information about a full WAL. The struct is declared so that
147/// listener implementations can target a common shape across
148/// storage backends; regolith itself never fires this callback,
149/// because the engine rotates the WAL alongside every memtable
150/// and there is no separate "WAL-full" condition.
151#[derive(Debug, Clone)]
152pub struct WalFullInfo {
153    /// Numeric id of the full WAL file.
154    pub wal_id: u64,
155    /// Size of the full WAL file in bytes.
156    pub size: u64,
157}
158
159/// Reason passed to [`EventListener::on_background_error`].
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum BackgroundErrorReason {
162    /// A background flush (memtable → L0) failed.
163    Flush,
164    /// A background compaction failed.
165    Compaction,
166    /// A manifest write or manifest compaction failed.
167    Manifest,
168    /// A WAL append or WAL sync failed.
169    WriteAheadLog,
170}
171
172/// Trait implemented by callers that want to react to engine
173/// lifecycle events. Registered via [`crate::Options::listeners`].
174///
175/// Every method has a default empty body, so implementations only
176/// override the callbacks they care about.
177///
178/// **Listeners must not block or re-enter the database.** See the
179/// module-level docs for the dispatch contract.
180pub trait EventListener: Send + Sync + 'static {
181    /// Called after a memtable has been flushed to a new L0
182    /// SSTable and the manifest edit has been applied.
183    fn on_flush_completed(&self, info: &FlushJobInfo) {
184        let _ = info;
185    }
186
187    /// Called right before a compaction job starts reading its
188    /// input files. The `output_files` field is empty at this
189    /// point; it's populated on `on_compaction_completed`.
190    fn on_compaction_begin(&self, info: &CompactionJobInfo) {
191        let _ = info;
192    }
193
194    /// Called after a compaction job has written its output files
195    /// and applied the manifest edit.
196    fn on_compaction_completed(&self, info: &CompactionJobInfo) {
197        let _ = info;
198    }
199
200    /// Called when a new SSTable file is created. Fires once per
201    /// file for both flush and compaction paths, with
202    /// `info.reason` distinguishing the caller.
203    fn on_table_file_created(&self, info: &TableFileCreationInfo) {
204        let _ = info;
205    }
206
207    /// Called after an SSTable file has been physically unlinked
208    /// from disk (after the manifest edit that removed it from
209    /// the live set has been applied).
210    fn on_table_file_deleted(&self, info: &TableFileDeletionInfo) {
211        let _ = info;
212    }
213
214    /// Called per file inside
215    /// [`crate::Db::ingest_external_files`], once the ingested
216    /// file has been placed at a level and committed to the
217    /// manifest.
218    fn on_external_file_ingested(&self, info: &ExternalFileIngestionInfo) {
219        let _ = info;
220    }
221
222    /// Called when a background flush / compaction / manifest /
223    /// WAL operation returns an error. The engine keeps running -
224    /// the listener is for observability, not error handling.
225    fn on_background_error(&self, reason: BackgroundErrorReason, err: &Error) {
226        let _ = (reason, err);
227    }
228
229    /// Declared so listeners can target a common shape across
230    /// storage backends; regolith itself never fires this callback.
231    /// See the module-level docs.
232    fn on_wal_full(&self, info: &WalFullInfo) {
233        let _ = info;
234    }
235}
236
237/// Dispatch a closure over every listener in a slice. Silences
238/// the trivial fan-out boilerplate at every call site. `Arc` is
239/// cheap enough to clone through the iterator.
240pub(crate) fn dispatch<F>(listeners: &[std::sync::Arc<dyn EventListener>], f: F)
241where
242    F: Fn(&dyn EventListener),
243{
244    for l in listeners {
245        f(l.as_ref());
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::sync::Arc;
253    use std::sync::atomic::{AtomicUsize, Ordering};
254
255    /// Listener that counts every callback it receives. Used to
256    /// verify that `dispatch` reaches every registered listener
257    /// and that the default no-op implementations don't panic.
258    #[derive(Default)]
259    struct CountingListener {
260        flushes: AtomicUsize,
261        compactions: AtomicUsize,
262        files_created: AtomicUsize,
263        files_deleted: AtomicUsize,
264        ingests: AtomicUsize,
265        bg_errors: AtomicUsize,
266        wal_fulls: AtomicUsize,
267    }
268
269    impl EventListener for CountingListener {
270        fn on_flush_completed(&self, _: &FlushJobInfo) {
271            self.flushes.fetch_add(1, Ordering::Relaxed);
272        }
273        fn on_compaction_completed(&self, _: &CompactionJobInfo) {
274            self.compactions.fetch_add(1, Ordering::Relaxed);
275        }
276        fn on_table_file_created(&self, _: &TableFileCreationInfo) {
277            self.files_created.fetch_add(1, Ordering::Relaxed);
278        }
279        fn on_table_file_deleted(&self, _: &TableFileDeletionInfo) {
280            self.files_deleted.fetch_add(1, Ordering::Relaxed);
281        }
282        fn on_external_file_ingested(&self, _: &ExternalFileIngestionInfo) {
283            self.ingests.fetch_add(1, Ordering::Relaxed);
284        }
285        fn on_background_error(&self, _: BackgroundErrorReason, _: &Error) {
286            self.bg_errors.fetch_add(1, Ordering::Relaxed);
287        }
288        fn on_wal_full(&self, _: &WalFullInfo) {
289            self.wal_fulls.fetch_add(1, Ordering::Relaxed);
290        }
291    }
292
293    fn sample_flush() -> FlushJobInfo {
294        FlushJobInfo {
295            file_id: 1,
296            file_path: PathBuf::from("/tmp/x.sst"),
297            file_size: 1024,
298            num_entries: 10,
299            smallest_key: b"a".to_vec(),
300            largest_key: b"z".to_vec(),
301            duration: Duration::from_millis(5),
302        }
303    }
304
305    fn sample_compaction() -> CompactionJobInfo {
306        CompactionJobInfo {
307            input_level: 0,
308            output_level: 1,
309            input_files_input_level: vec![1, 2],
310            input_files_output_level: vec![3],
311            output_files: vec![4],
312            duration: Duration::from_millis(20),
313        }
314    }
315
316    #[test]
317    fn default_trait_impls_are_noop() {
318        struct NoOp;
319        impl EventListener for NoOp {}
320        let n = NoOp;
321        n.on_flush_completed(&sample_flush());
322        n.on_compaction_begin(&sample_compaction());
323        n.on_compaction_completed(&sample_compaction());
324        n.on_wal_full(&WalFullInfo { wal_id: 1, size: 0 });
325        // No panic reaching here is the assertion.
326    }
327
328    #[test]
329    fn dispatch_visits_every_listener() {
330        let a = Arc::new(CountingListener::default());
331        let b = Arc::new(CountingListener::default());
332        let listeners: Vec<Arc<dyn EventListener>> = vec![
333            a.clone() as Arc<dyn EventListener>,
334            b.clone() as Arc<dyn EventListener>,
335        ];
336
337        let info = sample_flush();
338        dispatch(&listeners, |l| l.on_flush_completed(&info));
339        assert_eq!(a.flushes.load(Ordering::Relaxed), 1);
340        assert_eq!(b.flushes.load(Ordering::Relaxed), 1);
341    }
342
343    #[test]
344    fn dispatch_on_empty_list_is_noop() {
345        let empty: Vec<Arc<dyn EventListener>> = Vec::new();
346        dispatch(&empty, |_| panic!("should not be called"));
347    }
348
349    #[test]
350    fn table_file_creation_reason_variants_are_equal_only_to_themselves() {
351        assert_eq!(
352            TableFileCreationReason::Flush,
353            TableFileCreationReason::Flush
354        );
355        assert_ne!(
356            TableFileCreationReason::Flush,
357            TableFileCreationReason::Compaction
358        );
359        assert_ne!(
360            TableFileCreationReason::Compaction,
361            TableFileCreationReason::Recovery
362        );
363    }
364
365    #[test]
366    fn background_error_reason_copy_and_eq() {
367        let r = BackgroundErrorReason::Flush;
368        let copy = r; // Copy
369        assert_eq!(r, copy);
370        assert_ne!(r, BackgroundErrorReason::Compaction);
371    }
372
373    #[test]
374    fn info_structs_are_cloneable_and_debug_formattable() {
375        // Sanity check that every public info struct carries
376        // Clone + Debug so telemetry callers can snapshot them.
377        let f = sample_flush();
378        let _ = format!("{f:?}");
379        let _ = f.clone();
380        let c = sample_compaction();
381        let _ = format!("{c:?}");
382        let _ = c.clone();
383        let ext = ExternalFileIngestionInfo {
384            external_file_path: PathBuf::from("/x"),
385            internal_file_id: 1,
386            level: 0,
387            num_entries: 1,
388            file_size: 2,
389        };
390        let _ = format!("{ext:?}");
391        let _ = ext.clone();
392    }
393}