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}