Skip to main content

subms_lsm_tree/
lib.rs

1//! Log-structured merge tree with background flush.
2//!
3//! ```
4//! use subms_lsm_tree::LsmTree;
5//!
6//! # fn main() -> std::io::Result<()> {
7//! let dir = std::env::temp_dir().join("subms-lsm-doctest");
8//! # std::fs::remove_dir_all(&dir).ok();
9//! let mut lsm = LsmTree::open(&dir, 16_000)?;
10//! lsm.put("AAPL", b"150.10")?;
11//! assert_eq!(lsm.get("AAPL")?.as_deref(), Some(&b"150.10"[..])); // stored key: a hit
12//! assert_eq!(lsm.get("ZZZZ")?, None);                            // absent: bloom-accelerated miss
13//! # std::fs::remove_dir_all(&dir).ok();
14//! # Ok(())
15//! # }
16//! ```
17//!
18//! Writes land in an in-memory `active` memtable. When it exceeds
19//! `flush_threshold_bytes` the tree *rotates*: the full memtable is frozen and
20//! a fresh one is installed so the triggering write returns immediately. By
21//! default ([`FlushMode::Background`]) a background thread turns each frozen
22//! memtable into an SSTable (with a bloom-filter trailer) off the write path,
23//! so `put` never pays the O(memtable) flush cost - only the swap. Reads check
24//! `active`, then the frozen memtables still awaiting flush (newest first),
25//! then SSTables newest-to-oldest; with [`BloomMode::On`] each SSTable consults
26//! its bloom filter before scanning, so misses short-circuit in a few hash
27//! probes. First hit wins, tombstones included.
28//!
29//! [`FlushMode::Sync`] flushes inline on the calling thread instead - thread-free
30//! and deterministic (for single-threaded / wasm targets or deterministic
31//! replay), at the cost of a periodic write-latency spike on the write that
32//! triggers a flush.
33//!
34//! Durability: a frozen memtable queued for flush is not on disk until the
35//! worker writes it, so a hard crash loses the queued + active memtables unless
36//! the `wal` feature is recording them for replay - the same no-durability-
37//! without-WAL profile as before, just with a slightly wider in-memory window.
38//! [`LsmTree::flush`] forces everything pending to disk and blocks until it is.
39//!
40//! If the background worker ever stops - a write error is retried, but a panic
41//! is terminal - the tree stops accepting flushes: `put`, `delete`, `flush` and
42//! `compact` return an error from that point rather than parking on a queue
43//! nothing will drain. Reads keep serving whatever is already in memory and on
44//! disk.
45//!
46//! Full writeup, design notes and measured benchmarks:
47//! <https://www.submillisecond.com/cookbook/recipes/subms-lsm-tree>
48
49mod memtable;
50mod sstable;
51
52#[cfg(test)]
53#[path = "lsm_tree_tests.rs"]
54mod lsm_tree_tests;
55
56#[cfg(test)]
57#[path = "sample_app_tests.rs"]
58mod sample_app_tests;
59
60#[cfg(feature = "harness")]
61pub mod recipe;
62
63// Opt-in feature modules. Each is gated by its own Cargo feature flag;
64// `cargo add subms-lsm-tree` keeps the base build identical to 0.4.
65//
66// See README + cookbook page for per-feature p99, memory cost, and
67// composition guidance.
68#[cfg(any(
69    feature = "wal",
70    feature = "tiered-compaction",
71    feature = "leveled-compaction",
72    feature = "snapshot",
73    feature = "lz4",
74    feature = "zstd",
75    feature = "block-cache-integration",
76))]
77pub mod features;
78
79#[cfg(feature = "block-cache-integration")]
80pub use features::block_cache_integration::{Block, BlockCache, BlockKey, LruBlockCache};
81#[cfg(feature = "leveled-compaction")]
82pub use features::leveled_compaction::{LeveledCompactionPlanner, LeveledManifest, LeveledRun};
83#[cfg(feature = "lz4")]
84pub use features::lz4::Lz4BlockCompressor;
85#[cfg(feature = "snapshot")]
86pub use features::snapshot::{Snapshot, SnapshotManager, SnapshotManifest};
87#[cfg(feature = "tiered-compaction")]
88pub use features::tiered_compaction::{TieredCompactionPlanner, TieredManifest, TieredRun};
89#[cfg(feature = "wal")]
90pub use features::wal::WriteAheadLog;
91#[cfg(feature = "zstd")]
92pub use features::zstd::ZstdBlockCompressor;
93
94use std::collections::{BTreeMap, VecDeque};
95use std::fs;
96use std::io;
97use std::path::{Path, PathBuf};
98use std::sync::{Arc, Condvar, Mutex};
99use std::thread::JoinHandle;
100
101use memtable::Memtable;
102use sstable::SsTable;
103
104/// Read-path bloom-filter behaviour. The filter is always *written* into
105/// every SSTable trailer - this just controls whether reads consult it.
106#[derive(Copy, Clone, Debug, PartialEq, Eq)]
107pub enum BloomMode {
108    /// Check the bloom filter before scanning each SSTable. Default.
109    On,
110    /// Skip the bloom probe. Every SSTable in the walk pays a full scan.
111    Off,
112}
113
114/// When and where a full memtable is turned into an SSTable.
115#[derive(Copy, Clone, Debug, PartialEq, Eq)]
116pub enum FlushMode {
117    /// The default. A full memtable is handed to a background thread and a fresh
118    /// memtable is installed immediately, so the triggering write pays only the
119    /// swap, not the O(memtable) flush. This is what keeps the write tail flat.
120    Background,
121    /// The triggering write flushes inline on the caller's thread. Deterministic
122    /// and thread-free (single-threaded / wasm targets, deterministic replay) at
123    /// the cost of a periodic write-latency spike.
124    Sync,
125}
126
127/// Frozen memtables allowed to queue ahead of the background writer before a
128/// `put` blocks (back-pressure). Bounds the in-memory overhang if writes ever
129/// outrun the flush thread; at the recipe's workload the queue never fills.
130const DEFAULT_MAX_IMMUTABLE: usize = 4;
131
132/// State shared between the writer and the background flush worker.
133struct Shared {
134    state: Mutex<State>,
135    signal: Condvar,
136    data_dir: PathBuf,
137    bloom_mode: BloomMode,
138    max_immutable: usize,
139    /// Test-only fault injection, per tree so parallel tests cannot disturb each
140    /// other. Compiled out of every consumer build.
141    #[cfg(test)]
142    fault_flush: std::sync::atomic::AtomicBool,
143}
144
145struct State {
146    /// Frozen memtables awaiting flush, oldest at the front. Reads still see
147    /// them; the worker pops the front once its SSTable is registered.
148    immutable: VecDeque<Arc<Memtable>>,
149    /// On-disk runs, oldest -> newest, as one immutable snapshot. A reader clones
150    /// this single `Arc` (O(1)) rather than the whole run list; registering a run
151    /// rebuilds the vector (copy-on-write) - the rare path pays, the hot read does
152    /// not.
153    sstables: Arc<Vec<Arc<SsTable>>>,
154    next_seq: u64,
155    shutdown: bool,
156    /// First error the background worker hit, surfaced to the next writer call.
157    flush_err: Option<io::Error>,
158    /// False before the worker is spawned and again the moment it leaves its
159    /// loop for any reason, panic included. Once a spawned worker clears this
160    /// nothing will ever drain `immutable` again, so a writer that parked on the
161    /// queue must fail instead of waiting for a thread that no longer exists.
162    worker_alive: bool,
163}
164
165/// Publishes the worker's exit from a drop guard so an unwinding panic is
166/// covered, not just the clean return.
167struct WorkerExit<'a>(&'a Shared);
168
169impl Drop for WorkerExit<'_> {
170    fn drop(&mut self) {
171        let mut st = self.0.state.lock().unwrap_or_else(|e| e.into_inner());
172        st.worker_alive = false;
173        self.0.signal.notify_all();
174    }
175}
176
177/// The error every waiter fails with once the flush worker is gone. A clean
178/// exit only happens under `shutdown`, which is set while the tree is being
179/// dropped and nothing is left to wait, so a live waiter seeing this always
180/// means the worker died.
181fn worker_stopped() -> io::Error {
182    io::Error::other("lsm flush worker stopped; the tree can no longer flush")
183}
184
185pub struct LsmTree {
186    /// Writer-local buffer of pending writes. The hot `put` path never locks.
187    active: Memtable,
188    shared: Arc<Shared>,
189    /// Present once the background worker has been spawned (lazy, on first
190    /// [`FlushMode::Background`] flush). `None` in [`FlushMode::Sync`].
191    flush_handle: Option<JoinHandle<()>>,
192    flush_threshold_bytes: usize,
193    flush_mode: FlushMode,
194    /// Auto-compaction trigger: when the on-disk run count reaches this, a flush
195    /// merges every run into one, reclaiming superseded versions. 0 = disabled
196    /// (the base tree's documented no-automatic-compaction behaviour). Opt in via
197    /// [`Self::set_compaction_trigger`].
198    compaction_trigger: usize,
199}
200
201impl LsmTree {
202    /// Equivalent to [`Self::open_with`] with [`BloomMode::On`].
203    pub fn open(data_dir: impl AsRef<Path>, flush_threshold_bytes: usize) -> io::Result<Self> {
204        Self::open_with(data_dir, flush_threshold_bytes, BloomMode::On)
205    }
206
207    pub fn open_with(
208        data_dir: impl AsRef<Path>,
209        flush_threshold_bytes: usize,
210        bloom_mode: BloomMode,
211    ) -> io::Result<Self> {
212        let data_dir = data_dir.as_ref().to_path_buf();
213        fs::create_dir_all(&data_dir)?;
214
215        let mut files: Vec<PathBuf> = fs::read_dir(&data_dir)?
216            .filter_map(|e| e.ok().map(|e| e.path()))
217            .filter(|p| {
218                p.file_name()
219                    .and_then(|n| n.to_str())
220                    .map(|n| n.starts_with("sst-"))
221                    .unwrap_or(false)
222            })
223            .collect();
224        files.sort();
225
226        let next_seq = files
227            .last()
228            .and_then(|p| p.file_stem().and_then(|s| s.to_str()))
229            .and_then(|stem| stem.strip_prefix("sst-"))
230            .and_then(|n| n.parse::<u64>().ok())
231            .map(|n| n + 1)
232            .unwrap_or(0);
233
234        let mut sstables = Vec::with_capacity(files.len());
235        for f in files {
236            sstables.push(Arc::new(SsTable::open(f)?));
237        }
238
239        let shared = Arc::new(Shared {
240            state: Mutex::new(State {
241                immutable: VecDeque::new(),
242                sstables: Arc::new(sstables),
243                next_seq,
244                shutdown: false,
245                flush_err: None,
246                worker_alive: false,
247            }),
248            signal: Condvar::new(),
249            data_dir,
250            bloom_mode,
251            max_immutable: DEFAULT_MAX_IMMUTABLE,
252            #[cfg(test)]
253            fault_flush: std::sync::atomic::AtomicBool::new(false),
254        });
255
256        Ok(Self {
257            active: Memtable::new(),
258            shared,
259            flush_handle: None,
260            flush_threshold_bytes,
261            flush_mode: FlushMode::Background,
262            compaction_trigger: 0,
263        })
264    }
265
266    /// Choose inline vs background flush. [`FlushMode::Background`] is the
267    /// default; call this before the first write to opt into [`FlushMode::Sync`].
268    /// Returns `self` for builder-style construction.
269    pub fn set_flush_mode(&mut self, mode: FlushMode) -> &mut Self {
270        self.flush_mode = mode;
271        self
272    }
273
274    /// The active flush mode.
275    pub fn flush_mode(&self) -> FlushMode {
276        self.flush_mode
277    }
278
279    /// Enable automatic compaction: once the tree accumulates `trigger` on-disk
280    /// runs, the next flush merges them all into one, dropping every superseded
281    /// version and tombstone. `trigger = 0` disables it (the default). This is
282    /// what bounds on-disk size under overwrite-heavy workloads - without it,
283    /// every flush leaves a fresh run and the dead versions in older runs are
284    /// never reclaimed. Returns `self` for builder-style construction.
285    pub fn set_compaction_trigger(&mut self, trigger: usize) -> &mut Self {
286        self.compaction_trigger = trigger;
287        self
288    }
289
290    /// The current auto-compaction trigger (0 = disabled).
291    pub fn compaction_trigger(&self) -> usize {
292        self.compaction_trigger
293    }
294
295    /// Merge every on-disk run into a single run, keeping only the newest value
296    /// per key and discarding superseded versions and tombstones. Safe to call
297    /// manually at any time; a no-op when there are fewer than two runs. Drains
298    /// any pending background flush first so the merge sees every run.
299    pub fn compact(&mut self) -> io::Result<()> {
300        self.enqueue_active()?;
301        self.drain()?;
302
303        let ssts = {
304            let st = self.lock();
305            st.sstables.clone()
306        };
307        if ssts.len() < 2 {
308            return Ok(());
309        }
310        // Runs are ordered oldest -> newest, so a later run's value for a key
311        // wins. A full merge has no older run left to shadow, so a tombstone just
312        // drops the key entirely.
313        let mut merged: BTreeMap<String, Option<Vec<u8>>> = BTreeMap::new();
314        for sst in ssts.iter() {
315            for (key, value) in sst.entries() {
316                merged.insert(key, value);
317            }
318        }
319        let live: Vec<(String, Vec<u8>)> = merged
320            .into_iter()
321            .filter_map(|(k, v)| v.map(|val| (k, val)))
322            .collect();
323
324        let seq = self.reserve_seq();
325        let path = self.shared.data_dir.join(format!("sst-{seq:012}.dat"));
326        let new_sst = SsTable::write(
327            &path,
328            live.len(),
329            live.iter().map(|(k, v)| (k.as_str(), Some(v.as_slice()))),
330        )?;
331
332        let old_paths: Vec<PathBuf> = ssts.iter().map(|s| s.path().to_path_buf()).collect();
333        {
334            let mut st = self.lock();
335            // No flush can have raced us: we drained, and this tree is the only
336            // writer, so `immutable` stayed empty and no new run was appended.
337            st.sstables = Arc::new(vec![Arc::new(new_sst)]);
338        }
339        for p in old_paths {
340            let _ = fs::remove_file(p);
341        }
342        Ok(())
343    }
344
345    pub fn put(&mut self, key: &str, value: &[u8]) -> io::Result<()> {
346        self.active.put(key, Some(value.to_vec()));
347        self.maybe_rotate()
348    }
349
350    pub fn delete(&mut self, key: &str) -> io::Result<()> {
351        self.active.put(key, None);
352        self.maybe_rotate()
353    }
354
355    /// Returns `None` for absent *or* tombstoned keys.
356    pub fn get(&self, key: &str) -> io::Result<Option<Vec<u8>>> {
357        if let Some(hit) = self.active.get(key) {
358            return Ok(hit.map(|v| v.to_vec()));
359        }
360        let (imms, ssts) = self.snapshot();
361        for m in &imms {
362            if let Some(hit) = m.get(key) {
363                return Ok(hit.map(|v| v.to_vec()));
364            }
365        }
366        let check_bloom = matches!(self.shared.bloom_mode, BloomMode::On);
367        for sst in ssts.iter().rev() {
368            if let Some(hit) = sst.get(key, check_bloom) {
369                return Ok(hit);
370            }
371        }
372        Ok(None)
373    }
374
375    /// Every live key in `[lo, hi)` (either bound `None` = unbounded), in sorted
376    /// key order, as owned `(key, value)` pairs. Merges the active memtable over
377    /// the frozen memtables and every on-disk run newest-first: the newest write
378    /// per key wins and tombstoned keys are omitted - the same resolution as
379    /// [`Self::get`], across a range.
380    pub fn range(&self, lo: Option<&str>, hi: Option<&str>) -> io::Result<Vec<(String, Vec<u8>)>> {
381        // Newest source first: active memtable, frozen memtables newest -> oldest,
382        // then runs newest -> oldest. `or_insert` keeps the first (newest) value
383        // seen for a key; `None` marks a tombstone, dropped in the final pass so a
384        // delete shadows older sources.
385        let mut merged: BTreeMap<String, Option<Vec<u8>>> = BTreeMap::new();
386        for (k, v) in self.active.range(lo, hi) {
387            merged
388                .entry(k.to_string())
389                .or_insert_with(|| v.map(|s| s.to_vec()));
390        }
391        let (imms, ssts) = self.snapshot();
392        for m in &imms {
393            for (k, v) in m.range(lo, hi) {
394                merged
395                    .entry(k.to_string())
396                    .or_insert_with(|| v.map(|s| s.to_vec()));
397            }
398        }
399        for sst in ssts.iter().rev() {
400            for (k, v) in sst.range(lo, hi) {
401                merged.entry(k).or_insert(v);
402            }
403        }
404        Ok(merged
405            .into_iter()
406            .filter_map(|(k, v)| v.map(|val| (k, val)))
407            .collect())
408    }
409
410    /// Force everything pending - the active memtable and any frozen memtables
411    /// still queued - to disk, blocking until it is registered as SSTables. This
412    /// is the deterministic sync point tests and callers rely on.
413    pub fn flush(&mut self) -> io::Result<()> {
414        self.enqueue_active()?;
415        self.drain()
416    }
417
418    pub fn sstable_count(&self) -> usize {
419        self.lock().sstables.len()
420    }
421
422    pub fn bloom_mode(&self) -> BloomMode {
423        self.shared.bloom_mode
424    }
425
426    // ---- internals ----
427
428    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
429        self.shared.state.lock().unwrap_or_else(|e| e.into_inner())
430    }
431
432    /// A cheap, lock-free-after-clone view of everything a read must consult
433    /// behind the active memtable: frozen memtables newest-first, plus the run
434    /// list. Holding the lock only for the `Arc` clones keeps the flush worker
435    /// and readers from serialising on the actual scan.
436    fn snapshot(&self) -> (Vec<Arc<Memtable>>, Arc<Vec<Arc<SsTable>>>) {
437        let st = self.lock();
438        (
439            st.immutable.iter().rev().cloned().collect(),
440            Arc::clone(&st.sstables),
441        )
442    }
443
444    fn reserve_seq(&self) -> u64 {
445        let mut st = self.lock();
446        let seq = st.next_seq;
447        st.next_seq += 1;
448        seq
449    }
450
451    fn maybe_rotate(&mut self) -> io::Result<()> {
452        if self.active.approx_size_bytes() < self.flush_threshold_bytes {
453            return Ok(());
454        }
455        self.enqueue_active()?;
456        // Opt-in auto-compaction: bound the run count (and reclaim dead versions)
457        // once it reaches the trigger. `compact` drains first, so a background
458        // flush still in flight is accounted for.
459        if self.compaction_trigger > 0 && self.sstable_count() >= self.compaction_trigger {
460            self.compact()?;
461        }
462        Ok(())
463    }
464
465    /// Move the active memtable into the flush pipeline and install a fresh one.
466    /// Background mode enqueues it for the worker (spawning it on first use);
467    /// Sync mode writes the SSTable inline on this thread.
468    fn enqueue_active(&mut self) -> io::Result<()> {
469        if self.active.is_empty() {
470            // Still surface a prior background error even when there is nothing
471            // new to flush.
472            return self.take_flush_err();
473        }
474        match self.flush_mode {
475            FlushMode::Sync => self.flush_inline(),
476            FlushMode::Background => {
477                self.ensure_worker();
478                // Fail before the swap, not after: a memtable moved out of
479                // `active` and then abandoned on an error path is data the
480                // reader can no longer see.
481                {
482                    let mut st = self.lock();
483                    if let Some(e) = st.flush_err.take() {
484                        return Err(e);
485                    }
486                    if !st.worker_alive {
487                        return Err(worker_stopped());
488                    }
489                }
490                let frozen = Arc::new(std::mem::replace(&mut self.active, Memtable::new()));
491                let mut st = self.lock();
492                while st.immutable.len() >= self.shared.max_immutable && !st.shutdown {
493                    if !st.worker_alive {
494                        return Err(worker_stopped());
495                    }
496                    st = self
497                        .shared
498                        .signal
499                        .wait(st)
500                        .unwrap_or_else(|e| e.into_inner());
501                }
502                st.immutable.push_back(frozen);
503                self.shared.signal.notify_all();
504                Ok(())
505            }
506        }
507    }
508
509    /// Synchronous flush of the active memtable on the calling thread.
510    fn flush_inline(&mut self) -> io::Result<()> {
511        if self.active.is_empty() {
512            return Ok(());
513        }
514        let seq = self.reserve_seq();
515        let path = self.shared.data_dir.join(format!("sst-{seq:012}.dat"));
516        let sst = SsTable::write(
517            &path,
518            self.active.entry_count(),
519            self.active.sorted_entries(),
520        )?;
521        self.active.clear();
522        push_sstable(&mut self.lock(), sst);
523        Ok(())
524    }
525
526    /// Block until the frozen-memtable queue is empty (background mode only).
527    fn drain(&mut self) -> io::Result<()> {
528        if self.flush_handle.is_none() {
529            return Ok(());
530        }
531        let mut st = self.lock();
532        while !st.immutable.is_empty() && st.flush_err.is_none() {
533            if !st.worker_alive {
534                return Err(worker_stopped());
535            }
536            st = self
537                .shared
538                .signal
539                .wait(st)
540                .unwrap_or_else(|e| e.into_inner());
541        }
542        match st.flush_err.take() {
543            Some(e) => Err(e),
544            None => Ok(()),
545        }
546    }
547
548    fn take_flush_err(&self) -> io::Result<()> {
549        let mut st = self.lock();
550        if let Some(e) = st.flush_err.take() {
551            return Err(e);
552        }
553        if self.flush_handle.is_some() && !st.worker_alive {
554            return Err(worker_stopped());
555        }
556        Ok(())
557    }
558
559    fn ensure_worker(&mut self) {
560        if self.flush_handle.is_some() {
561            return;
562        }
563        self.lock().worker_alive = true;
564        let shared = Arc::clone(&self.shared);
565        self.flush_handle = Some(std::thread::spawn(move || flush_worker(&shared)));
566    }
567
568    /// Make the flush worker panic on its next SSTable write, so the writer-side
569    /// handling of a dead worker can be exercised. There is no legitimate input
570    /// that panics the worker, and the failure being unobservable is the whole
571    /// defect, so the fault has to be injected.
572    #[cfg(test)]
573    fn fault_next_flush(&self) {
574        self.shared
575            .fault_flush
576            .store(true, std::sync::atomic::Ordering::Relaxed);
577    }
578}
579
580/// Background flush loop: turn each frozen memtable into an SSTable off the
581/// write path. The memtable stays in `immutable` (visible to readers) until its
582/// SSTable is registered, so a key is never transiently invisible.
583fn flush_worker(shared: &Shared) {
584    let _exit = WorkerExit(shared);
585    loop {
586        let frozen = {
587            let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
588            loop {
589                if !st.immutable.is_empty() {
590                    break;
591                }
592                if st.shutdown {
593                    return;
594                }
595                st = shared.signal.wait(st).unwrap_or_else(|e| e.into_inner());
596            }
597            Arc::clone(st.immutable.front().unwrap())
598        };
599
600        let seq = {
601            let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
602            let seq = st.next_seq;
603            st.next_seq += 1;
604            seq
605        };
606        let path = shared.data_dir.join(format!("sst-{seq:012}.dat"));
607        #[cfg(test)]
608        assert!(
609            !shared
610                .fault_flush
611                .load(std::sync::atomic::Ordering::Relaxed),
612            "injected flush-worker fault"
613        );
614        let result = SsTable::write(&path, frozen.entry_count(), frozen.sorted_entries());
615
616        let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
617        match result {
618            Ok(sst) => push_sstable(&mut st, sst),
619            Err(e) => {
620                if st.flush_err.is_none() {
621                    st.flush_err = Some(e);
622                }
623            }
624        }
625        st.immutable.pop_front();
626        shared.signal.notify_all();
627    }
628}
629
630/// Append a run to the shared list, copy-on-write: readers holding the previous
631/// `Arc<Vec<..>>` snapshot keep scanning it untouched while the new snapshot
632/// takes its place.
633fn push_sstable(st: &mut State, sst: SsTable) {
634    let mut runs = (*st.sstables).clone();
635    runs.push(Arc::new(sst));
636    st.sstables = Arc::new(runs);
637}
638
639impl Drop for LsmTree {
640    fn drop(&mut self) {
641        if self.flush_handle.is_some() {
642            // Persist the active memtable via the worker, then stop and join it so
643            // every queued SSTable is on disk before the tree goes away.
644            let _ = self.enqueue_active();
645            {
646                let mut st = self.lock();
647                st.shutdown = true;
648                self.shared.signal.notify_all();
649            }
650            if let Some(h) = self.flush_handle.take() {
651                let _ = h.join();
652            }
653        } else {
654            // Sync mode, or background that never spawned a worker: flush inline.
655            let _ = self.flush_inline();
656        }
657    }
658}