spool/
rocks.rs

1use crate::{
2    Spool, SpoolBackpressureTimeout, SpoolCallerDeadlineExceeded, SpoolEntry, SpoolId,
3    SpoolUnhealthyError,
4};
5use anyhow::Context;
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use flume::Sender;
9use kumo_prometheus::declare_metric;
10use rocksdb::perf::get_memory_usage_stats;
11use rocksdb::properties::{
12    ACTUAL_DELAYED_WRITE_RATE, BACKGROUND_ERRORS, COMPACTION_PENDING,
13    ESTIMATE_PENDING_COMPACTION_BYTES, IS_WRITE_STOPPED, NUM_RUNNING_COMPACTIONS,
14};
15use rocksdb::{
16    BottommostLevelCompaction, CompactOptions, DBCompressionType, ErrorKind, IteratorMode,
17    LogLevel, Options, WaitForCompactOptions, WriteBatch, WriteOptions, DB,
18};
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::{Arc, Weak};
23use std::time::{Duration, Instant};
24use tokio::runtime::Handle;
25use tokio::sync::Semaphore;
26use tokio::time::{sleep, timeout_at};
27
28#[derive(Serialize, Deserialize, Debug)]
29pub struct RocksSpoolParams {
30    pub increase_parallelism: Option<i32>,
31
32    pub optimize_level_style_compaction: Option<usize>,
33    pub optimize_universal_style_compaction: Option<usize>,
34    #[serde(default)]
35    pub paranoid_checks: bool,
36    #[serde(default)]
37    pub compression_type: DBCompressionTypeDef,
38
39    /// If non-zero, we perform bigger reads when doing compaction. If you’re running RocksDB on
40    /// spinning disks, you should set this to at least 2MB. That way RocksDB’s compaction is doing
41    /// sequential instead of random reads
42    pub compaction_readahead_size: Option<usize>,
43
44    #[serde(default)]
45    pub level_compaction_dynamic_level_bytes: bool,
46
47    #[serde(default)]
48    pub max_open_files: Option<usize>,
49
50    /// Size in bytes of the rocksdb memtable that buffers writes before
51    /// being flushed to disk as a new SST file.
52    ///
53    /// Smaller values produce smaller, more frequent SST files and
54    /// trigger compactions sooner -- useful in test setups that need
55    /// to force the storage through its full write/compact lifecycle
56    /// quickly.  Larger values amortize compaction overhead but
57    /// increase memory use and recovery time after restart.  Leave
58    /// unset to use the rocksdb default.
59    #[serde(default)]
60    pub write_buffer_size: Option<usize>,
61
62    /// Number of level-0 SST files at which rocksdb will stop
63    /// accepting writes.  Lower values transition the database into
64    /// the write-stopped state more quickly when background
65    /// compaction cannot keep up, which is useful for tests that
66    /// need to deterministically observe that condition.  Leave
67    /// unset to use the rocksdb default.
68    #[serde(default)]
69    pub level0_stop_writes_trigger: Option<i32>,
70
71    #[serde(default)]
72    pub log_level: LogLevelDef,
73
74    /// See:
75    /// <https://docs.rs/rocksdb/latest/rocksdb/struct.Options.html#method.set_memtable_huge_page_size>
76    #[serde(default)]
77    pub memtable_huge_page_size: Option<usize>,
78
79    #[serde(
80        with = "duration_serde",
81        default = "RocksSpoolParams::default_log_file_time_to_roll"
82    )]
83    pub log_file_time_to_roll: Duration,
84
85    #[serde(
86        with = "duration_serde",
87        default = "RocksSpoolParams::default_obsolete_files_period"
88    )]
89    pub obsolete_files_period: Duration,
90
91    #[serde(default)]
92    pub limit_concurrent_stores: Option<usize>,
93    #[serde(default)]
94    pub limit_concurrent_loads: Option<usize>,
95    #[serde(default)]
96    pub limit_concurrent_removes: Option<usize>,
97
98    /// Upper bound on the wait that `store()` and `remove()` will
99    /// tolerate when rocksdb is applying backpressure.  Callers may
100    /// provide a shorter deadline (typically derived from an SMTP
101    /// client's idle timeout); the effective deadline is the minimum
102    /// of the two.  Going longer than the caller-provided value risks
103    /// the client timing out and retrying, which would produce
104    /// duplicate deliveries -- this option therefore only narrows the
105    /// effective deadline, it never extends it.
106    #[serde(
107        with = "duration_serde",
108        default = "RocksSpoolParams::default_store_deadline"
109    )]
110    pub store_deadline: Duration,
111
112    /// How long the composite "this database is wedged" signal must
113    /// hold continuously before the load-shedding gate latches.
114    /// The signal goes high whenever the rocksdb
115    /// `background-errors` counter has grown above the value
116    /// observed at process start, or any foreground spool operation
117    /// has returned a rocksdb error since process start.  Brief
118    /// blips that recover within this window do not latch the gate.
119    #[serde(
120        with = "duration_serde",
121        default = "RocksSpoolParams::default_error_latch_duration"
122    )]
123    pub error_latch_duration: Duration,
124
125    /// How long the healthy state must hold continuously before the
126    /// load-shedding gate auto-unlatches.  Only consulted when
127    /// `allow_error_unlatch` is true.  A relatively long value
128    /// (minutes) gives operators time to inspect the database after a
129    /// brief failure window before the daemon starts accepting writes
130    /// again on its own.
131    #[serde(
132        with = "duration_serde",
133        default = "RocksSpoolParams::default_error_unlatch_duration"
134    )]
135    pub error_unlatch_duration: Duration,
136
137    /// When true (the default), the load-shedding gate clears itself
138    /// after `error_unlatch_duration` of observed recovery.  Set to
139    /// false to require an operator restart to clear the gate, which
140    /// is appropriate when you want a human to confirm the underlying
141    /// cause is resolved before accepting traffic again.
142    #[serde(default = "RocksSpoolParams::default_allow_error_unlatch")]
143    pub allow_error_unlatch: bool,
144}
145
146impl Default for RocksSpoolParams {
147    fn default() -> Self {
148        Self {
149            increase_parallelism: None,
150            optimize_level_style_compaction: None,
151            optimize_universal_style_compaction: None,
152            paranoid_checks: false,
153            compression_type: DBCompressionTypeDef::default(),
154            compaction_readahead_size: None,
155            level_compaction_dynamic_level_bytes: false,
156            max_open_files: None,
157            write_buffer_size: None,
158            level0_stop_writes_trigger: None,
159            log_level: LogLevelDef::default(),
160            memtable_huge_page_size: None,
161            log_file_time_to_roll: Self::default_log_file_time_to_roll(),
162            obsolete_files_period: Self::default_obsolete_files_period(),
163            limit_concurrent_stores: None,
164            limit_concurrent_loads: None,
165            limit_concurrent_removes: None,
166            store_deadline: Self::default_store_deadline(),
167            error_latch_duration: Self::default_error_latch_duration(),
168            error_unlatch_duration: Self::default_error_unlatch_duration(),
169            allow_error_unlatch: Self::default_allow_error_unlatch(),
170        }
171    }
172}
173
174impl RocksSpoolParams {
175    fn default_log_file_time_to_roll() -> Duration {
176        Duration::from_secs(86400)
177    }
178
179    fn default_obsolete_files_period() -> Duration {
180        Duration::from_secs(6 * 60 * 60)
181    }
182
183    fn default_store_deadline() -> Duration {
184        Duration::from_secs(30)
185    }
186
187    fn default_error_latch_duration() -> Duration {
188        Duration::from_secs(15)
189    }
190
191    fn default_error_unlatch_duration() -> Duration {
192        Duration::from_secs(5 * 60)
193    }
194
195    fn default_allow_error_unlatch() -> bool {
196        true
197    }
198}
199
200#[derive(Serialize, Deserialize, Debug)]
201pub enum DBCompressionTypeDef {
202    None,
203    Snappy,
204    Zlib,
205    Bz2,
206    Lz4,
207    Lz4hc,
208    Zstd,
209}
210
211impl From<DBCompressionTypeDef> for DBCompressionType {
212    fn from(val: DBCompressionTypeDef) -> Self {
213        match val {
214            DBCompressionTypeDef::None => DBCompressionType::None,
215            DBCompressionTypeDef::Snappy => DBCompressionType::Snappy,
216            DBCompressionTypeDef::Zlib => DBCompressionType::Zlib,
217            DBCompressionTypeDef::Bz2 => DBCompressionType::Bz2,
218            DBCompressionTypeDef::Lz4 => DBCompressionType::Lz4,
219            DBCompressionTypeDef::Lz4hc => DBCompressionType::Lz4hc,
220            DBCompressionTypeDef::Zstd => DBCompressionType::Zstd,
221        }
222    }
223}
224
225impl Default for DBCompressionTypeDef {
226    fn default() -> Self {
227        Self::Snappy
228    }
229}
230
231#[derive(Serialize, Deserialize, Debug)]
232pub enum LogLevelDef {
233    Debug,
234    Info,
235    Warn,
236    Error,
237    Fatal,
238    Header,
239}
240
241impl Default for LogLevelDef {
242    fn default() -> Self {
243        Self::Info
244    }
245}
246
247impl From<LogLevelDef> for LogLevel {
248    fn from(val: LogLevelDef) -> Self {
249        match val {
250            LogLevelDef::Debug => LogLevel::Debug,
251            LogLevelDef::Info => LogLevel::Info,
252            LogLevelDef::Warn => LogLevel::Warn,
253            LogLevelDef::Error => LogLevel::Error,
254            LogLevelDef::Fatal => LogLevel::Fatal,
255            LogLevelDef::Header => LogLevel::Header,
256        }
257    }
258}
259
260pub struct RocksSpool {
261    db: Arc<DB>,
262    runtime: Handle,
263    limit_concurrent_stores: Option<Arc<Semaphore>>,
264    limit_concurrent_loads: Option<Arc<Semaphore>>,
265    limit_concurrent_removes: Option<Arc<Semaphore>>,
266    /// Latched load-shedding gate driven by `metrics_monitor`.  When
267    /// set, foreground `store()`/`remove()` calls return an error
268    /// instead of waiting on rocksdb backpressure, and the ingress
269    /// paths refuse new traffic.  See `metrics_monitor` for the
270    /// composite signal that drives latch/unlatch transitions.
271    load_shed_active: Arc<AtomicBool>,
272    /// Count of foreground spool operations (load, enumerate,
273    /// store, remove) that have failed with a rocksdb error
274    /// since the last auto-unlatch (or since process start if no
275    /// auto-unlatch has happened).  `metrics_monitor` reads this
276    /// to detect failure modes that do not surface as background
277    /// errors -- notably a missing SST discovered during a
278    /// `get()`, which the C++ side reports to the caller but does
279    /// not feed into `rocksdb.background-errors`.  Reset to 0 by
280    /// the auto-unlatch path's compare-exchange so a subsequent
281    /// blip can be observed as fresh growth.
282    foreground_errors: Arc<AtomicU64>,
283    store_deadline: Duration,
284}
285
286/// Initial sleep interval for the `store`/`remove` backpressure loop.
287/// Chosen low enough that brief, sub-millisecond memtable backpressure
288/// is caught with negligible added latency on the slow path.
289const BACKOFF_INITIAL: Duration = Duration::from_micros(500);
290/// Upper bound on the backpressure loop sleep interval.  Keeps the
291/// load-shedding gate observable within a bounded window even during
292/// a long wedge.
293const BACKOFF_MAX: Duration = Duration::from_millis(50);
294
295impl RocksSpool {
296    /// Driver shared by `store()` and `remove()`.  Issues `write_opt`
297    /// with `no_slowdown=true` repeatedly with exponential backoff
298    /// until one of: the write succeeds, the effective deadline is
299    /// reached, the load-shedding gate latches, or rocksdb returns a
300    /// non-`Incomplete` error.
301    ///
302    /// This replaces an earlier design that used `spawn_blocking` with
303    /// `no_slowdown=false`.  That approach could not be cancelled, held
304    /// a blocking-pool worker per stalled call (risking pool
305    /// exhaustion during a wedge), and could not observe the latched
306    /// load-shedding gate.  The polling design preserves write
307    /// atomicity -- each iteration is a single rocksdb batch write,
308    /// which is atomic by construction -- while restoring
309    /// cancellation, gate observability, and bounded resource use.
310    async fn write_with_backpressure(
311        &self,
312        opts: WriteOptions,
313        caller_deadline: Option<Instant>,
314        permits: Option<Arc<Semaphore>>,
315        apply: impl Fn(&mut WriteBatch),
316    ) -> anyhow::Result<()> {
317        // Gate at the top so that the load-shedding mirror affects
318        // every store, not just those that happen to hit backpressure.
319        // A relaxed atomic load is essentially free compared to the
320        // rocksdb FFI write below; this preserves the healthy hot
321        // path's latency profile while giving the gate consistent
322        // semantics across the in-flight call sites that aren't
323        // covered by the per-connection ingress checks (notably,
324        // already-established SMTP connections doing new
325        // transactions).
326        if self.load_shed_active.load(Ordering::Relaxed) {
327            return Err(SpoolUnhealthyError.into());
328        }
329
330        let mut batch = WriteBatch::default();
331        apply(&mut batch);
332        match self.db.write_opt(batch, &opts) {
333            Ok(()) => return Ok(()),
334            Err(err) if err.kind() == ErrorKind::Incomplete => {}
335            Err(err) => {
336                record_foreground_error(
337                    &self.foreground_errors,
338                    &self.load_shed_active,
339                    self.db.path(),
340                    &err,
341                );
342                return Err(err.into());
343            }
344        }
345
346        let spool_deadline = Instant::now() + self.store_deadline;
347        // Decide upfront which side's deadline wins, so both the
348        // semaphore-acquisition timeout and the backpressure-loop
349        // timeout can surface the matching typed error.  Without
350        // this, the SMTP layer cannot tell a caller-provided
351        // `data_processing_timeout` from the spool's own
352        // `store_deadline` and would mis-label the wire response.
353        let (effective_deadline, caller_wins) = match caller_deadline {
354            Some(c) if c < spool_deadline => (c, true),
355            _ => (spool_deadline, false),
356        };
357        let timeout_err = || -> anyhow::Error {
358            if caller_wins {
359                SpoolCallerDeadlineExceeded.into()
360            } else {
361                SpoolBackpressureTimeout {
362                    deadline: self.store_deadline,
363                }
364                .into()
365            }
366        };
367
368        let _permit = match permits {
369            Some(s) => match timeout_at(effective_deadline.into(), s.acquire_owned()).await {
370                Ok(r) => Some(r?),
371                Err(_) => return Err(timeout_err()),
372            },
373            None => None,
374        };
375
376        let mut backoff = BACKOFF_INITIAL;
377        loop {
378            if self.load_shed_active.load(Ordering::Relaxed) {
379                return Err(SpoolUnhealthyError.into());
380            }
381            if Instant::now() >= effective_deadline {
382                // Sustained backpressure for the full deadline is
383                // itself a useful signal that the spool may be
384                // unhealthy.  Feed it into the foreground error
385                // machinery so the debounced latch path can react if
386                // we see this repeatedly; an occasional one-off
387                // (e.g. a brief load spike) gets washed out by the
388                // `error_latch_duration` window.  We do not
389                // immediate-latch because no rocksdb error has been
390                // returned -- the inability to make progress is
391                // ambiguous, not definitively bad.
392                self.foreground_errors.fetch_add(1, Ordering::Relaxed);
393                return Err(timeout_err());
394            }
395            sleep(backoff).await;
396            backoff = (backoff * 2).min(BACKOFF_MAX);
397
398            let mut batch = WriteBatch::default();
399            apply(&mut batch);
400            match self.db.write_opt(batch, &opts) {
401                Ok(()) => return Ok(()),
402                Err(err) if err.kind() == ErrorKind::Incomplete => continue,
403                Err(err) => {
404                    record_foreground_error(
405                        &self.foreground_errors,
406                        &self.load_shed_active,
407                        self.db.path(),
408                        &err,
409                    );
410                    return Err(err.into());
411                }
412            }
413        }
414    }
415
416    pub fn new(
417        path: &Path,
418        flush: bool,
419        params: Option<RocksSpoolParams>,
420        runtime: Handle,
421    ) -> anyhow::Result<Self> {
422        let mut opts = Options::default();
423        opts.set_use_fsync(flush);
424        opts.create_if_missing(true);
425        // The default is 1000, which is a bit high
426        opts.set_keep_log_file_num(10);
427
428        let p = params.unwrap_or_default();
429        if let Some(i) = p.increase_parallelism {
430            opts.increase_parallelism(i);
431        }
432        if let Some(i) = p.optimize_level_style_compaction {
433            opts.optimize_level_style_compaction(i);
434        }
435        if let Some(i) = p.optimize_universal_style_compaction {
436            opts.optimize_universal_style_compaction(i);
437        }
438        if let Some(i) = p.compaction_readahead_size {
439            opts.set_compaction_readahead_size(i);
440        }
441        if let Some(i) = p.max_open_files {
442            opts.set_max_open_files(i as _);
443        }
444        if let Some(i) = p.write_buffer_size {
445            opts.set_write_buffer_size(i);
446        }
447        if let Some(i) = p.level0_stop_writes_trigger {
448            opts.set_level_zero_stop_writes_trigger(i);
449        }
450        if let Some(i) = p.memtable_huge_page_size {
451            opts.set_memtable_huge_page_size(i);
452        }
453        opts.set_paranoid_checks(p.paranoid_checks);
454        opts.set_level_compaction_dynamic_level_bytes(p.level_compaction_dynamic_level_bytes);
455        opts.set_compression_type(p.compression_type.into());
456        opts.set_log_level(p.log_level.into());
457        opts.set_log_file_time_to_roll(p.log_file_time_to_roll.as_secs() as usize);
458        opts.set_delete_obsolete_files_period_micros(p.obsolete_files_period.as_micros() as u64);
459
460        let limit_concurrent_stores = p
461            .limit_concurrent_stores
462            .map(|n| Arc::new(Semaphore::new(n)));
463        let limit_concurrent_loads = p
464            .limit_concurrent_loads
465            .map(|n| Arc::new(Semaphore::new(n)));
466        let limit_concurrent_removes = p
467            .limit_concurrent_removes
468            .map(|n| Arc::new(Semaphore::new(n)));
469
470        // Ensure the directory exists so we can probe it before opening.
471        // Create it the way RocksDB would (mkdir 0755, subject to umask)
472        // so our pre-creation is indistinguishable from letting RocksDB
473        // create it; RocksDB has no option that influences this mode.
474        #[cfg(unix)]
475        {
476            use std::os::unix::fs::DirBuilderExt;
477            std::fs::DirBuilder::new()
478                .recursive(true)
479                .mode(0o755)
480                .create(path)
481                .with_context(|| format!("creating spool directory {}", path.display()))?;
482        }
483        #[cfg(not(unix))]
484        std::fs::create_dir_all(path)
485            .with_context(|| format!("creating spool directory {}", path.display()))?;
486
487        // Catch a split real/effective identity meeting an over-restrictive
488        // directory before RocksDB silently corrupts itself and later fails
489        // with an opaque "wal_dir contains existing log file" error.
490        dir_probe::probe_directory(path)
491            .with_context(|| format!("spool directory {} is not usable", path.display()))?;
492
493        let db = Arc::new(DB::open(&opts, path)?);
494        let load_shed_active = Arc::new(AtomicBool::new(false));
495        let foreground_errors = Arc::new(AtomicU64::new(0));
496        let store_deadline = p.store_deadline;
497
498        {
499            let weak_db = Arc::downgrade(&db);
500            let weak_mirror = Arc::downgrade(&load_shed_active);
501            let weak_fg_errors = Arc::downgrade(&foreground_errors);
502            tokio::spawn(metrics_monitor(
503                weak_db,
504                weak_mirror,
505                weak_fg_errors,
506                format!("{}", path.display()),
507                p.error_latch_duration,
508                p.error_unlatch_duration,
509                p.allow_error_unlatch,
510            ));
511        }
512
513        Ok(Self {
514            db,
515            runtime,
516            limit_concurrent_stores,
517            limit_concurrent_loads,
518            limit_concurrent_removes,
519            load_shed_active,
520            foreground_errors,
521            store_deadline,
522        })
523    }
524}
525
526#[async_trait]
527impl Spool for RocksSpool {
528    async fn load(&self, id: SpoolId) -> anyhow::Result<Vec<u8>> {
529        let permit = match self.limit_concurrent_loads.clone() {
530            Some(s) => Some(s.acquire_owned().await?),
531            None => None,
532        };
533        let db = self.db.clone();
534        let fg_errors = self.foreground_errors.clone();
535        let load_shed = self.load_shed_active.clone();
536        let db_path: PathBuf = self.db.path().to_owned();
537        tokio::task::Builder::new()
538            .name("rocksdb load")
539            .spawn_blocking_on(
540                move || {
541                    let result = match db.get(id.as_bytes()) {
542                        Ok(Some(v)) => v,
543                        Ok(None) => {
544                            drop(permit);
545                            anyhow::bail!("no such key {id}");
546                        }
547                        Err(err) => {
548                            // Rocksdb get errors (e.g. a missing SST
549                            // file discovered during the read) do not
550                            // increment rocksdb.background-errors.
551                            // Record them so the load-shedding gate
552                            // can react -- immediately for fatal
553                            // classes, or after debounce otherwise.
554                            record_foreground_error(&fg_errors, &load_shed, &db_path, &err);
555                            drop(permit);
556                            return Err(err.into());
557                        }
558                    };
559                    drop(permit);
560                    Ok(result)
561                },
562                &self.runtime,
563            )?
564            .await?
565    }
566
567    async fn store(
568        &self,
569        id: SpoolId,
570        data: Arc<Box<[u8]>>,
571        force_sync: bool,
572        deadline: Option<Instant>,
573    ) -> anyhow::Result<()> {
574        let mut opts = WriteOptions::default();
575        opts.set_sync(force_sync);
576        opts.set_no_slowdown(true);
577
578        self.write_with_backpressure(
579            opts,
580            deadline,
581            self.limit_concurrent_stores.clone(),
582            |batch| batch.put(id.as_bytes(), &*data),
583        )
584        .await
585    }
586
587    async fn remove(&self, id: SpoolId) -> anyhow::Result<()> {
588        let mut opts = WriteOptions::default();
589        opts.set_no_slowdown(true);
590
591        self.write_with_backpressure(opts, None, self.limit_concurrent_removes.clone(), |batch| {
592            batch.delete(id.as_bytes())
593        })
594        .await
595    }
596
597    async fn cleanup(&self) -> anyhow::Result<()> {
598        Ok(())
599    }
600
601    async fn compact(&self) -> anyhow::Result<()> {
602        let db = self.db.clone();
603        tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
604            db.flush()?;
605            // Force bottommost-level compaction so the entire keyspace
606            // is rewritten; without this, single-level layouts cause
607            // the call to be a no-op even when there are missing files
608            // that we'd want to surface as errors.
609            let mut opts = CompactOptions::default();
610            opts.set_bottommost_level_compaction(BottommostLevelCompaction::Force);
611            opts.set_exclusive_manual_compaction(true);
612            db.compact_range_opt::<&[u8], &[u8]>(None, None, &opts);
613            // compact_range itself does not return errors -- wait_for_compact
614            // does, and is what surfaces background failures (e.g. a
615            // missing SST encountered during compaction) to the caller.
616            let wait_opts = WaitForCompactOptions::default();
617            db.wait_for_compact(&wait_opts)?;
618            Ok(())
619        })
620        .await?
621    }
622
623    async fn shutdown(&self) -> anyhow::Result<()> {
624        let db = self.db.clone();
625        tokio::task::spawn_blocking(move || db.cancel_all_background_work(true)).await?;
626        Ok(())
627    }
628
629    fn unhealthy_reason(&self) -> Option<&'static str> {
630        if self.load_shed_active.load(Ordering::Relaxed) {
631            Some("the spool is not accepting writes")
632        } else {
633            None
634        }
635    }
636
637    async fn advise_low_memory(&self) -> anyhow::Result<isize> {
638        let db = self.db.clone();
639        tokio::task::spawn_blocking(move || {
640            let usage_before = match get_memory_usage_stats(Some(&[&db]), None) {
641                Ok(stats) => {
642                    let stats: Stats = stats.into();
643                    tracing::debug!("pre-flush: {stats:#?}");
644                    stats.total()
645                }
646                Err(err) => {
647                    tracing::error!("error getting stats: {err:#}");
648                    0
649                }
650            };
651
652            if let Err(err) = db.flush() {
653                tracing::error!("error flushing memory: {err:#}");
654            }
655
656            let usage_after = match get_memory_usage_stats(Some(&[&db]), None) {
657                Ok(stats) => {
658                    let stats: Stats = stats.into();
659                    tracing::debug!("post-flush: {stats:#?}");
660                    stats.total()
661                }
662                Err(err) => {
663                    tracing::error!("error getting stats: {err:#}");
664                    0
665                }
666            };
667
668            Ok(usage_before - usage_after)
669        })
670        .await?
671    }
672
673    fn enumerate(
674        &self,
675        sender: Sender<SpoolEntry>,
676        start_time: DateTime<Utc>,
677    ) -> anyhow::Result<()> {
678        let db = Arc::clone(&self.db);
679        let fg_errors = self.foreground_errors.clone();
680        let load_shed = self.load_shed_active.clone();
681        let db_path: PathBuf = self.db.path().to_owned();
682        tokio::task::Builder::new()
683            .name("rocksdb enumerate")
684            .spawn_blocking_on(
685                move || {
686                    let iter = db.iterator(IteratorMode::Start);
687                    for entry in iter {
688                        let (key, value) = match entry {
689                            Ok(e) => e,
690                            Err(err) => {
691                                // Iterator errors typically indicate a
692                                // missing or corrupt SST file
693                                // discovered while walking the
694                                // keyspace.  Feed into the foreground
695                                // error machinery so the gate latches
696                                // (immediately for IOError /
697                                // Corruption) and abort the
698                                // enumeration.
699                                record_foreground_error(&fg_errors, &load_shed, &db_path, &err);
700                                return Err(err.into());
701                            }
702                        };
703                        let id = SpoolId::from_slice(&key)
704                            .ok_or_else(|| anyhow::anyhow!("invalid spool id {key:?}"))?;
705
706                        if id.created() >= start_time {
707                            // Entries created since we started must have
708                            // landed there after we started and are thus
709                            // not eligible for discovery via enumeration
710                            continue;
711                        }
712
713                        sender
714                            .send(SpoolEntry::Item {
715                                id,
716                                data: value.to_vec(),
717                            })
718                            .map_err(|err| {
719                                anyhow::anyhow!("failed to send SpoolEntry for {id}: {err:#}")
720                            })?;
721                    }
722                    Ok::<(), anyhow::Error>(())
723                },
724                &self.runtime,
725            )?;
726        Ok(())
727    }
728}
729
730#[cfg(test)]
731mod test {
732    use super::*;
733
734    #[tokio::test]
735    async fn rocks_spool() -> anyhow::Result<()> {
736        let location = tempfile::tempdir()?;
737        let spool = RocksSpool::new(location.path(), false, None, Handle::current())?;
738
739        {
740            let id1 = SpoolId::new();
741
742            // Can't load an entry that doesn't exist
743            assert_eq!(
744                format!("{:#}", spool.load(id1).await.unwrap_err()),
745                format!("no such key {id1}")
746            );
747        }
748
749        // Insert some entries
750        let mut ids = vec![];
751        for i in 0..100 {
752            let id = SpoolId::new();
753            spool
754                .store(
755                    id,
756                    Arc::new(format!("I am {i}").as_bytes().to_vec().into_boxed_slice()),
757                    false,
758                    None,
759                )
760                .await?;
761            ids.push(id);
762        }
763
764        // Verify that we can load those entries
765        for (i, &id) in ids.iter().enumerate() {
766            let data = spool.load(id).await?;
767            let text = String::from_utf8(data)?;
768            assert_eq!(text, format!("I am {i}"));
769        }
770
771        {
772            // Verify that we can enumerate them
773            let (tx, rx) = flume::bounded(32);
774            spool.enumerate(tx, Utc::now())?;
775            let mut count = 0;
776
777            while let Ok(item) = rx.recv_async().await {
778                match item {
779                    SpoolEntry::Item { id, data } => {
780                        let i = ids
781                            .iter()
782                            .position(|&item| item == id)
783                            .ok_or_else(|| anyhow::anyhow!("{id} not found in ids!"))?;
784
785                        let text = String::from_utf8(data)?;
786                        assert_eq!(text, format!("I am {i}"));
787
788                        spool.remove(id).await?;
789                        // Can't load an entry that we just removed
790                        assert_eq!(
791                            format!("{:#}", spool.load(id).await.unwrap_err()),
792                            format!("no such key {id}")
793                        );
794                        count += 1;
795                    }
796                    SpoolEntry::Corrupt { id, error } => {
797                        anyhow::bail!("Corrupt: {id}: {error}");
798                    }
799                }
800            }
801
802            assert_eq!(count, 100);
803        }
804
805        // Now that we've removed the files, try enumerating again.
806        // We expect to receive no entries.
807        // Do it a couple of times to verify that none of the cleanup
808        // stuff that happens in enumerate breaks the directory
809        // structure
810        for _ in 0..2 {
811            // Verify that we can enumerate them
812            let (tx, rx) = flume::bounded(32);
813            spool.enumerate(tx, Utc::now())?;
814            let mut unexpected = vec![];
815
816            while let Ok(item) = rx.recv_async().await {
817                match item {
818                    SpoolEntry::Item { id, .. } | SpoolEntry::Corrupt { id, .. } => {
819                        unexpected.push(id)
820                    }
821                }
822            }
823
824            assert_eq!(unexpected.len(), 0);
825        }
826
827        Ok(())
828    }
829}
830
831/// The rocksdb type doesn't impl Debug, so we get to do it
832#[allow(unused)]
833#[derive(Debug)]
834struct Stats {
835    pub mem_table_total: u64,
836    pub mem_table_unflushed: u64,
837    pub mem_table_readers_total: u64,
838    pub cache_total: u64,
839}
840
841impl Stats {
842    fn total(&self) -> isize {
843        (self.mem_table_total + self.mem_table_readers_total + self.cache_total) as isize
844    }
845}
846
847impl From<rocksdb::perf::MemoryUsageStats> for Stats {
848    fn from(s: rocksdb::perf::MemoryUsageStats) -> Self {
849        Self {
850            mem_table_total: s.mem_table_total,
851            mem_table_unflushed: s.mem_table_unflushed,
852            mem_table_readers_total: s.mem_table_readers_total,
853            cache_total: s.cache_total,
854        }
855    }
856}
857
858/// Read an integer-valued rocksdb property, returning 0 if the property
859/// is missing or cannot be parsed.  Used for hot-path checks and metrics
860/// gathering; callers that want to distinguish "missing" from "zero"
861/// should call `property_int_value` directly.
862fn property_u64(db: &DB, name: &rocksdb::properties::PropName) -> u64 {
863    db.property_int_value(name).ok().flatten().unwrap_or(0)
864}
865
866/// Classify a rocksdb error returned from a foreground spool
867/// operation.  `Corruption` and `IOError` are returned by rocksdb
868/// when the underlying database state is observably wrong (a missing
869/// or corrupt SST file, a checksum mismatch, etc.) -- conditions
870/// that do not have any transient interpretation.  We use this to
871/// latch the load-shedding gate immediately rather than waiting out
872/// the normal debounce window.
873fn is_definitively_bad(err: &rocksdb::Error) -> bool {
874    matches!(err.kind(), ErrorKind::Corruption | ErrorKind::IOError)
875}
876
877/// Record a foreground spool error: always increment the counter so
878/// the metrics monitor can observe sustained-failure patterns, and
879/// for errors classified as definitively bad, latch the gate now.
880/// Logs once on the false-to-true transition of the gate.
881fn record_foreground_error(
882    fg_errors: &AtomicU64,
883    load_shed: &AtomicBool,
884    path: &Path,
885    err: &rocksdb::Error,
886) {
887    fg_errors.fetch_add(1, Ordering::Relaxed);
888    if is_definitively_bad(err) && !load_shed.swap(true, Ordering::Relaxed) {
889        tracing::error!(
890            "rocksdb at {}: fatal foreground error ({:?}); load-shedding \
891             gate latched immediately. Underlying error: {}",
892            path.display(),
893            err.kind(),
894            err.as_ref(),
895        );
896    }
897}
898
899declare_metric! {
900/// Approximate memory usage (bytes) of all the mem-tables.
901///
902/// This may be useful when understanding the memory usage of
903/// the system.
904static MEM_TABLE_TOTAL: IntGaugeVec(
905        "rocks_spool_mem_table_total",
906        &["path"]
907    );
908}
909
910declare_metric! {
911/// Approximate memory usage (bytes) of un-flushed mem-tables.
912///
913/// This may be useful when understanding the memory usage of
914/// the system.
915static MEM_TABLE_UNFLUSHED: IntGaugeVec(
916        "rocks_spool_mem_table_unflushed",
917        &["path"]
918    );
919}
920
921declare_metric! {
922/// Approximate memory usage (bytes) of all the table readers.
923///
924/// This may be useful when understanding the memory usage of
925/// the system.
926static MEM_TABLE_READERS_TOTAL: IntGaugeVec(
927        "rocks_spool_mem_table_readers_total",
928        &["path"]
929    );
930}
931
932declare_metric! {
933/// Approximate memory (bytes) usage by cache.
934///
935/// This may be useful when understanding the memory usage of
936/// the system.
937static CACHE_TOTAL: IntGaugeVec(
938        "rocks_spool_cache_total",
939        &["path"]
940    );
941}
942
943declare_metric! {
944/// Accumulated count of background errors encountered by the rocksdb
945/// instance (failed flushes or compactions, typically caused by I/O
946/// errors such as missing or corrupt SST files, ENOSPC, or permission
947/// problems).
948///
949/// {{since('dev')}}
950///
951/// This counter is **monotonic** for the lifetime of the process: it
952/// does not decrease when rocksdb auto-resumes from transient errors
953/// such as a brief ENOSPC.  A non-zero value therefore does not
954/// necessarily mean the database is currently wedged; it means at
955/// least one background error has occurred since the process started.
956///
957/// For SRE monitoring, alert on the **rate of change** (e.g.
958/// `increase(rocks_spool_background_errors[5m]) > 0`) to catch new
959/// occurrences.  For the actionable "the database is wedged right
960/// now and we are shedding load" signal, page on
961/// `rocks_spool_load_shed_active` instead, which combines this
962/// counter, foreground read/write errors, and rocksdb error
963/// severity into a single latched indicator.
964static BACKGROUND_ERRORS_METRIC: IntGaugeVec(
965        "rocks_spool_background_errors",
966        &["path"]
967    );
968}
969
970declare_metric! {
971/// Set to 1 when the rocksdb instance is currently refusing writes
972/// at the WriteController layer (memtable count or L0 file count
973/// reached the stop threshold), 0 otherwise.
974///
975/// {{since('dev')}}
976///
977/// This reflects rocksdb's own `is-write-stopped` property and
978/// indicates backpressure rather than a fatal background error.
979/// Healthy databases under bursty load may briefly report 1 here.
980/// For the "the database is wedged due to a background error"
981/// signal, see `rocks_spool_load_shed_active` instead.
982static WRITE_STOPPED: IntGaugeVec(
983        "rocks_spool_write_stopped",
984        &["path"]
985    );
986}
987
988declare_metric! {
989/// Set to 1 when this spool's load-shedding gate is latched, 0
990/// otherwise.  When set, ingress paths (SMTP, HTTP inject) reject
991/// traffic and foreground store/remove operations fail fast rather
992/// than stall.
993///
994/// {{since('dev')}}
995///
996/// The gate latches in either of two ways:
997///
998/// * **Immediate**: a foreground spool operation (load, store,
999///   remove) returns a rocksdb error classified as definitively
1000///   bad (`Corruption` or `IOError` -- e.g. a missing or corrupt
1001///   SST file discovered during a read).  These conditions have
1002///   no transient interpretation, so the gate latches on the
1003///   first such observation.
1004/// * **Debounced**: less specific failure signals --
1005///   `background-errors` has grown since this process started, or
1006///   foreground operations have returned non-fatal errors --
1007///   sustained continuously for the configured
1008///   `error_latch_duration` (default 15s).  This filters out
1009///   brief auto-resumed errors.
1010///
1011/// If `allow_error_unlatch` is enabled (the default), the gate
1012/// auto-clears after `error_unlatch_duration` of observed recovery
1013/// (default 5 minutes) with no new errors of either class.
1014/// Otherwise it stays set until the process is restarted.
1015///
1016/// SREs should treat any sustained non-zero value as an
1017/// operator-actionable incident; pair this metric with
1018/// `rocks_spool_background_errors` to understand why.
1019static LOAD_SHED_ACTIVE: IntGaugeVec(
1020        "rocks_spool_load_shed_active",
1021        &["path"]
1022    );
1023}
1024
1025declare_metric! {
1026/// Number of background compactions currently running for this
1027/// rocksdb instance.
1028///
1029/// {{since('dev')}}
1030///
1031/// In a healthy, actively-written spool this is typically non-zero
1032/// in bursts.  A value persistently stuck at 0 while
1033/// `rocks_spool_compaction_pending` or
1034/// `rocks_spool_estimate_pending_compaction_bytes` is growing is a
1035/// strong indicator that the background worker is wedged --
1036/// cross-reference `rocks_spool_write_stopped` and
1037/// `rocks_spool_background_errors`.
1038static NUM_RUNNING_COMPACTIONS_METRIC: IntGaugeVec(
1039        "rocks_spool_num_running_compactions",
1040        &["path"]
1041    );
1042}
1043
1044declare_metric! {
1045/// Set to 1 when at least one compaction is pending for this rocksdb
1046/// instance, 0 otherwise.
1047///
1048/// {{since('dev')}}
1049///
1050/// Brief flapping is normal under write load.  A value of 1 that
1051/// persists alongside `rocks_spool_num_running_compactions == 0` is
1052/// suspicious and suggests the compaction worker is not making
1053/// progress.
1054static COMPACTION_PENDING_METRIC: IntGaugeVec(
1055        "rocks_spool_compaction_pending",
1056        &["path"]
1057    );
1058}
1059
1060declare_metric! {
1061/// Estimated total bytes that compaction needs to rewrite to bring
1062/// all levels back under their target sizes.
1063///
1064/// {{since('dev')}}
1065///
1066/// This is a backlog indicator.  Steady-state values depend heavily
1067/// on write rate, compression, and the configured compaction style,
1068/// so absolute thresholds should be derived from each deployment's
1069/// baseline.  Unbounded growth over a multi-hour window indicates
1070/// that compaction cannot keep up with the write rate, which
1071/// eventually leads to write slowdown
1072/// (`rocks_spool_actual_delayed_write_rate` becomes non-zero) and
1073/// then to write stop (`rocks_spool_write_stopped` becomes 1).
1074///
1075/// Only meaningful for level-style compaction.
1076static ESTIMATE_PENDING_COMPACTION_BYTES_METRIC: IntGaugeVec(
1077        "rocks_spool_estimate_pending_compaction_bytes",
1078        &["path"]
1079    );
1080}
1081
1082declare_metric! {
1083/// Current delayed write rate (bytes/second) applied by rocksdb to
1084/// throttle foreground writers.  0 means no slowdown is in effect.
1085///
1086/// {{since('dev')}}
1087///
1088/// A non-zero value means rocksdb is intentionally slowing writers
1089/// down because compaction or flush is falling behind.  This is the
1090/// early-warning signal that precedes a full write stop: if this
1091/// remains non-zero for an extended period, investigate the
1092/// compaction backlog
1093/// (`rocks_spool_estimate_pending_compaction_bytes`) and underlying
1094/// disk throughput before the database transitions to
1095/// `rocks_spool_write_stopped == 1`.
1096static ACTUAL_DELAYED_WRITE_RATE_METRIC: IntGaugeVec(
1097        "rocks_spool_actual_delayed_write_rate",
1098        &["path"]
1099    );
1100}
1101
1102/// Internal state for the load-shedding latch state machine.  See the
1103/// per-tick logic in `metrics_monitor`.
1104struct HealthState {
1105    /// `background-errors` count observed on the first monitor tick.
1106    /// Only growth above this baseline counts toward latching, so a
1107    /// daemon restarted against a DB whose accumulated count is
1108    /// already non-zero does not immediately latch.
1109    initial_bg_errors: u64,
1110    /// `background-errors` count from the previous monitor tick.
1111    /// Used both for once-per-transition logging and to detect
1112    /// quiet windows when deciding whether to auto-unlatch.
1113    prev_bg_errors: u64,
1114    /// Foreground spool error count from the previous monitor tick.
1115    /// The counter itself starts at zero per process, so unlike
1116    /// `initial_bg_errors` there is no separate baseline -- any
1117    /// non-zero observation reflects errors in the current run.
1118    prev_fg_errors: u64,
1119    /// Instant we first observed an unhealthy signal (bg above
1120    /// baseline OR any foreground errors) in the current run.
1121    unhealthy_since: Option<Instant>,
1122    /// Instant of the most recent monitor tick where bg_errors
1123    /// increased over the previous tick.
1124    last_bg_growth_at: Option<Instant>,
1125    /// Instant of the most recent monitor tick where the
1126    /// foreground error counter increased over the previous tick.
1127    last_fg_growth_at: Option<Instant>,
1128    latched: bool,
1129}
1130
1131async fn metrics_monitor(
1132    db: Weak<DB>,
1133    mirror: Weak<AtomicBool>,
1134    foreground_errors: Weak<AtomicU64>,
1135    path: String,
1136    latch_duration: Duration,
1137    unlatch_duration: Duration,
1138    allow_unlatch: bool,
1139) {
1140    let mem_table_total = MEM_TABLE_TOTAL
1141        .get_metric_with_label_values(&[path.as_str()])
1142        .unwrap();
1143    let mem_table_unflushed = MEM_TABLE_UNFLUSHED
1144        .get_metric_with_label_values(&[path.as_str()])
1145        .unwrap();
1146    let mem_table_readers_total = MEM_TABLE_READERS_TOTAL
1147        .get_metric_with_label_values(&[path.as_str()])
1148        .unwrap();
1149    let cache_total = CACHE_TOTAL
1150        .get_metric_with_label_values(&[path.as_str()])
1151        .unwrap();
1152    let background_errors = BACKGROUND_ERRORS_METRIC
1153        .get_metric_with_label_values(&[path.as_str()])
1154        .unwrap();
1155    let write_stopped = WRITE_STOPPED
1156        .get_metric_with_label_values(&[path.as_str()])
1157        .unwrap();
1158    let load_shed_active = LOAD_SHED_ACTIVE
1159        .get_metric_with_label_values(&[path.as_str()])
1160        .unwrap();
1161    let num_running_compactions = NUM_RUNNING_COMPACTIONS_METRIC
1162        .get_metric_with_label_values(&[path.as_str()])
1163        .unwrap();
1164    let compaction_pending = COMPACTION_PENDING_METRIC
1165        .get_metric_with_label_values(&[path.as_str()])
1166        .unwrap();
1167    let estimate_pending_compaction_bytes = ESTIMATE_PENDING_COMPACTION_BYTES_METRIC
1168        .get_metric_with_label_values(&[path.as_str()])
1169        .unwrap();
1170    let actual_delayed_write_rate = ACTUAL_DELAYED_WRITE_RATE_METRIC
1171        .get_metric_with_label_values(&[path.as_str()])
1172        .unwrap();
1173
1174    // Initial bg_errors observation anchors the latch logic against
1175    // pre-existing accumulated errors from prior process lifetimes,
1176    // so a restart against a DB with a historical count does not
1177    // immediately latch.  If the DB has already been dropped before
1178    // we get here, exit silently -- there is nothing to monitor.
1179    let Some(db_init) = db.upgrade() else {
1180        return;
1181    };
1182    let initial_bg = property_u64(&db_init, BACKGROUND_ERRORS);
1183    drop(db_init);
1184    let mut state = HealthState {
1185        initial_bg_errors: initial_bg,
1186        prev_bg_errors: initial_bg,
1187        prev_fg_errors: 0,
1188        unhealthy_since: None,
1189        last_bg_growth_at: None,
1190        last_fg_growth_at: None,
1191        latched: false,
1192    };
1193
1194    loop {
1195        match db.upgrade() {
1196            Some(db) => {
1197                match get_memory_usage_stats(Some(&[&db]), None) {
1198                    Ok(stats) => {
1199                        mem_table_total.set(stats.mem_table_total as i64);
1200                        mem_table_unflushed.set(stats.mem_table_unflushed as i64);
1201                        mem_table_readers_total.set(stats.mem_table_readers_total as i64);
1202                        cache_total.set(stats.cache_total as i64);
1203                    }
1204                    Err(err) => {
1205                        tracing::error!("error getting stats: {err:#}");
1206                    }
1207                };
1208
1209                let bg = property_u64(&db, BACKGROUND_ERRORS);
1210                let stopped = property_u64(&db, IS_WRITE_STOPPED);
1211                let compaction_pending_now = property_u64(&db, COMPACTION_PENDING);
1212                let num_running = property_u64(&db, NUM_RUNNING_COMPACTIONS);
1213                background_errors.set(bg as i64);
1214                write_stopped.set(stopped as i64);
1215                num_running_compactions.set(num_running as i64);
1216                compaction_pending.set(compaction_pending_now as i64);
1217                estimate_pending_compaction_bytes
1218                    .set(property_u64(&db, ESTIMATE_PENDING_COMPACTION_BYTES) as i64);
1219                actual_delayed_write_rate.set(property_u64(&db, ACTUAL_DELAYED_WRITE_RATE) as i64);
1220
1221                let now = Instant::now();
1222                let fg = foreground_errors
1223                    .upgrade()
1224                    .map(|c| c.load(Ordering::Relaxed))
1225                    .unwrap_or(0);
1226
1227                // The foreground error path may have latched the gate
1228                // directly on a fatal error (Corruption/IOError) since
1229                // our last tick.  Reconcile our internal state so the
1230                // unlatch logic sees the gate as latched without
1231                // duplicating the "gate latched" log.
1232                if let Some(m) = mirror.upgrade() {
1233                    if m.load(Ordering::Relaxed) && !state.latched {
1234                        state.latched = true;
1235                        state.unhealthy_since = Some(now);
1236                    }
1237                }
1238
1239                if bg > state.prev_bg_errors {
1240                    tracing::error!(
1241                        "rocksdb at {path}: background error count \
1242                         increased from {prev} to {bg}; check the LOG \
1243                         file in that directory for details",
1244                        prev = state.prev_bg_errors,
1245                    );
1246                    state.last_bg_growth_at = Some(now);
1247                }
1248                state.prev_bg_errors = bg;
1249
1250                if fg > state.prev_fg_errors {
1251                    tracing::error!(
1252                        "rocksdb at {path}: foreground spool error count \
1253                         increased from {prev} to {fg}; this typically \
1254                         indicates a missing or corrupt SST file \
1255                         discovered during a read",
1256                        prev = state.prev_fg_errors,
1257                    );
1258                    state.last_fg_growth_at = Some(now);
1259                }
1260                state.prev_fg_errors = fg;
1261
1262                // Latch signal: background errors have grown since this
1263                // process started, OR any foreground errors have been
1264                // observed.  We cannot use rocksdb's
1265                // `compaction-pending` or `is-write-stopped` properties
1266                // to refine this -- when paranoid_checks fires,
1267                // rocksdb pauses background scheduling and both
1268                // properties drop to 0 even though the DB is wedged.
1269                // The sustained-for-latch_duration window is what
1270                // filters out brief auto-resumed blips.
1271                let unhealthy_now = bg > state.initial_bg_errors || fg > 0;
1272
1273                if unhealthy_now {
1274                    let since = *state.unhealthy_since.get_or_insert(now);
1275                    if !state.latched && now.duration_since(since) >= latch_duration {
1276                        state.latched = true;
1277                        if let Some(m) = mirror.upgrade() {
1278                            m.store(true, Ordering::Relaxed);
1279                        }
1280                        tracing::error!(
1281                            "rocksdb at {path}: load-shedding gate latched \
1282                             after {latch_duration:?} of sustained background \
1283                             errors (accumulated count: {bg}). Ingress paths \
1284                             will now reject traffic. Inspect the LOG file \
1285                             for the underlying cause.",
1286                        );
1287                    }
1288                } else if !state.latched {
1289                    // Healthy tick before latch: reset the debounce
1290                    // window so a future blip gets its full
1291                    // latch_duration grace, not a stale baseline
1292                    // from an earlier, separate transient.
1293                    state.unhealthy_since = None;
1294                }
1295
1296                // Auto-unlatch: neither bg_errors nor fg_errors have
1297                // grown for `unlatch_duration`.  This catches
1298                // self-healed transients (one blip, then quiet).  It
1299                // does NOT distinguish a self-healed transient from a
1300                // truly wedged DB where compactions have been
1301                // abandoned and simply stopped producing further
1302                // errors.  Operators who require a stronger
1303                // guarantee should set `allow_error_unlatch = false`.
1304                if state.latched && allow_unlatch {
1305                    let bg_stable_since = state.last_bg_growth_at.unwrap_or(now);
1306                    let fg_stable_since = state.last_fg_growth_at.unwrap_or(now);
1307                    let stable_since = bg_stable_since.max(fg_stable_since);
1308                    if now.duration_since(stable_since) >= unlatch_duration {
1309                        // CAS the foreground counter from our tick
1310                        // snapshot to 0.  This is the synchronization
1311                        // point that prevents an auto-unlatch from
1312                        // racing with a concurrent
1313                        // record_foreground_error: if a fresh fatal
1314                        // error landed mid-tick, the CAS fails and we
1315                        // defer the unlatch to the next tick.
1316                        let cleared = match foreground_errors.upgrade() {
1317                            Some(c) => c
1318                                .compare_exchange(fg, 0, Ordering::Relaxed, Ordering::Relaxed)
1319                                .is_ok(),
1320                            // Process is shutting down; skip.
1321                            None => false,
1322                        };
1323                        if cleared {
1324                            state.latched = false;
1325                            // Re-anchor the baselines at the current
1326                            // counts so that we only re-latch on *new*
1327                            // growth above this point; otherwise the
1328                            // static post-transient counts would keep us
1329                            // permanently unhealthy.
1330                            state.initial_bg_errors = bg;
1331                            state.prev_fg_errors = 0;
1332                            state.unhealthy_since = None;
1333                            if let Some(m) = mirror.upgrade() {
1334                                m.store(false, Ordering::Relaxed);
1335                            }
1336                            tracing::info!(
1337                                "rocksdb at {path}: load-shedding gate cleared \
1338                                 after {unlatch_duration:?} without new errors \
1339                                 (bg baseline re-anchored at {bg}); ingress \
1340                                 paths will accept traffic again",
1341                            );
1342                        }
1343                    }
1344                }
1345                load_shed_active.set(if state.latched { 1 } else { 0 });
1346            }
1347            None => {
1348                // Dead
1349                return;
1350            }
1351        }
1352        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
1353    }
1354}