Skip to main content

rushdb_storage/
observability.rs

1//! Live store observability hooks: per-commit latency, backend lock
2//! contention, and the conflict window's footprint (C0, cloud-capacity F12).
3
4use std::sync::atomic::Ordering;
5use std::sync::Arc;
6
7use super::Store;
8
9/// Cumulative write-path counters since the store opened. They are plain
10/// atomics, read live (no keyspace scan), so a metrics scrape reports them
11/// at every refresh.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub struct WriteCounters {
14    /// Bounded admission-lock waits (including timed-out waits).
15    pub admission_waits: u64,
16    /// Milliseconds spent waiting for admission-lock ownership.
17    pub admission_wait_ms: u64,
18    /// Durability checkpoints that wrote: a checkpoint with nothing new to
19    /// make durable is skipped and not counted.
20    pub checkpoints: u64,
21    /// Milliseconds spent in the checkpoints counted above.
22    pub checkpoint_wait_ms: u64,
23    /// Bytes appended to the WAL (framed records).
24    pub wal_bytes_appended: u64,
25    /// Encoded bytes of every op, base and derived, handed to the backends.
26    /// With `wal_bytes_appended`, the two write streams a commit produces;
27    /// compare against `rushdb.redb` growth and the process's block writes
28    /// to attribute write amplification.
29    pub applied_bytes: u64,
30}
31
32/// One timed step of the commit pipeline. Committer-side phases are
33/// observed once per commit; publisher-side phases once per publish group.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum CommitPhase {
36    /// Waiting for the admission (sequencer) lock.
37    AdmissionWait,
38    /// Running the caller's planner/build closure.
39    Build,
40    /// Deterministic expansion into ops, including slot-reservation retries.
41    Expand,
42    /// Enqueue until the durable acknowledgement (the end-to-end latency the
43    /// client waits for after expansion).
44    Durable,
45    /// Publisher: encoding a group's WAL records.
46    WalEncode,
47    /// Publisher: appending and fsyncing a group's WAL records.
48    WalAppendSync,
49    /// Publisher: applying a group's ops to the backend materialization.
50    BackendApply,
51    /// Publisher: advancing derived state for a group.
52    Derive,
53    /// Publisher: conflict-window recording, watermark advance and acks.
54    Publish,
55}
56
57impl CommitPhase {
58    /// Every phase, in pipeline order.
59    pub const ALL: [Self; 9] = [
60        Self::AdmissionWait,
61        Self::Build,
62        Self::Expand,
63        Self::Durable,
64        Self::WalEncode,
65        Self::WalAppendSync,
66        Self::BackendApply,
67        Self::Derive,
68        Self::Publish,
69    ];
70
71    /// Stable metric label for this phase.
72    #[must_use]
73    pub const fn label(self) -> &'static str {
74        match self {
75            Self::AdmissionWait => "admission_wait",
76            Self::Build => "build",
77            Self::Expand => "expand",
78            Self::Durable => "durable",
79            Self::WalEncode => "wal_encode",
80            Self::WalAppendSync => "wal_append_sync",
81            Self::BackendApply => "backend_apply",
82            Self::Derive => "derive",
83            Self::Publish => "publish",
84        }
85    }
86}
87
88/// Callback receiving each timed commit phase, in seconds.
89pub type CommitObserver = dyn Fn(CommitPhase, f64) + Send + Sync;
90
91/// Cumulative backend database-lock timings, summed over every shard.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub struct BackendLockTimes {
94    /// Nanoseconds spent waiting to acquire the lock.
95    pub wait_ns: u64,
96    /// Nanoseconds the lock was held.
97    pub hold_ns: u64,
98    /// Longest single hold, in nanoseconds.
99    pub hold_max_ns: u64,
100}
101
102/// What holds a backend's single writer: one redb write transaction, from
103/// `begin_write` to its commit. Every other writer waits meanwhile, so the
104/// longest holds are what stall other tenants' commits.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum WriterHolder {
107    /// Publishing commit groups into the materialization.
108    Publish,
109    /// One batch of history garbage collection.
110    HistoryGc,
111    /// A durable checkpoint (fsync).
112    Checkpoint,
113    /// Writes paused for a consistent copy (backup).
114    Pause,
115}
116
117impl WriterHolder {
118    /// Every holder.
119    pub const ALL: [Self; 4] = [
120        Self::Publish,
121        Self::HistoryGc,
122        Self::Checkpoint,
123        Self::Pause,
124    ];
125
126    /// Stable metric label.
127    #[must_use]
128    pub const fn label(self) -> &'static str {
129        match self {
130            Self::Publish => "publish",
131            Self::HistoryGc => "history_gc",
132            Self::Checkpoint => "checkpoint",
133            Self::Pause => "pause",
134        }
135    }
136
137    pub(crate) const fn index(self) -> usize {
138        match self {
139            Self::Publish => 0,
140            Self::HistoryGc => 1,
141            Self::Checkpoint => 2,
142            Self::Pause => 3,
143        }
144    }
145}
146
147/// Upper bounds, in seconds, of the writer-hold histogram buckets.
148pub const WRITER_HOLD_BOUNDS: [f64; 12] = [
149    0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
150];
151
152/// One holder's writer holds since the store opened.
153#[derive(Debug, Clone, Copy, Default, PartialEq)]
154pub struct WriterHolds {
155    /// Holds per bucket of [`WRITER_HOLD_BOUNDS`] (not cumulative); the last
156    /// slot counts holds above every bound.
157    pub counts: [u64; WRITER_HOLD_BOUNDS.len() + 1],
158    /// Total seconds held.
159    pub sum_seconds: f64,
160}
161
162impl WriterHolds {
163    /// Fold `other` into `self`.
164    #[must_use]
165    pub fn plus(mut self, other: &Self) -> Self {
166        for (total, count) in self.counts.iter_mut().zip(other.counts) {
167            *total = total.saturating_add(count);
168        }
169        self.sum_seconds += other.sum_seconds;
170        self
171    }
172}
173
174impl Store {
175    /// Install the callback that receives every timed commit phase.
176    ///
177    /// Lock poisoning fences the store until reopen.
178    pub fn set_commit_observer(&self, observer: Arc<CommitObserver>) {
179        *self.health.cleanup(self.commit_observer.write()) = Some(observer);
180    }
181
182    /// Report the time since `started` for `phase` to the installed observer.
183    pub(crate) fn observe_phase(&self, phase: CommitPhase, started: std::time::Instant) {
184        let observer = self.health.cleanup(self.commit_observer.read()).clone();
185        if let Some(observer) = observer {
186            observer(phase, started.elapsed().as_secs_f64());
187        }
188    }
189
190    /// Backend lock timings summed over all shards (live, no scan).
191    #[must_use]
192    pub fn backend_lock_times(&self) -> BackendLockTimes {
193        self.all_backends()
194            .iter()
195            .map(|backend| backend.lock_times())
196            .fold(BackendLockTimes::default(), |total, times| {
197                BackendLockTimes {
198                    wait_ns: total.wait_ns.saturating_add(times.wait_ns),
199                    hold_ns: total.hold_ns.saturating_add(times.hold_ns),
200                    hold_max_ns: total.hold_max_ns.max(times.hold_max_ns),
201                }
202            })
203    }
204
205    /// Writer holds by holder, summed over every shard (live, no scan).
206    #[must_use]
207    pub fn writer_holds(&self) -> Vec<(WriterHolder, WriterHolds)> {
208        let mut totals = [WriterHolds::default(); WriterHolder::ALL.len()];
209        for backend in self.all_backends() {
210            for (holder, holds) in backend.writer_holds() {
211                totals[holder.index()] = totals[holder.index()].plus(&holds);
212            }
213        }
214        WriterHolder::ALL.into_iter().zip(totals).collect()
215    }
216
217    /// Copy-on-write state of a durable store's data files, recorded when it
218    /// opened; `None` in memory.
219    #[must_use]
220    pub fn copy_on_write(&self) -> Option<crate::CopyOnWrite> {
221        self.health.cleanup(self.copy_on_write.lock()).clone()
222    }
223
224    /// Directory holding a durable store's files, or `None` in memory.
225    #[must_use]
226    pub fn data_dir(&self) -> Option<std::path::PathBuf> {
227        self.health
228            .cleanup(self.data_path.lock())
229            .as_deref()
230            .and_then(std::path::Path::parent)
231            .map(std::path::Path::to_path_buf)
232    }
233
234    /// Cumulative write-path counters (live atomics, no scan).
235    #[must_use]
236    pub fn write_counters(&self) -> WriteCounters {
237        WriteCounters {
238            admission_waits: self.admission_waits.load(Ordering::Relaxed),
239            admission_wait_ms: self.admission_wait_ms.load(Ordering::Relaxed),
240            checkpoints: self.checkpoints.load(Ordering::Relaxed),
241            checkpoint_wait_ms: self.checkpoint_wait_ms.load(Ordering::Relaxed),
242            wal_bytes_appended: self.wal_bytes_appended.load(Ordering::Relaxed),
243            applied_bytes: self.applied_bytes.load(Ordering::Relaxed),
244        }
245    }
246
247    /// Bytes currently retained by the in-memory conflict window.
248    #[must_use]
249    pub fn conflict_window_bytes(&self) -> usize {
250        self.health.cleanup(self.conflicts.lock()).bytes()
251    }
252}