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}