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}