Skip to main content

liminal/durability/
counting.rs

1//! A counting seam around any [`DurableStore`].
2//!
3//! Every durable operation liminal makes goes through a [`DurableStore`], so a
4//! wrapper at that seam is where store work per journey can be counted: calls
5//! per method, rows read, rows and bytes written, and flushes. The wrapper is
6//! a mirror, not a filter: each method is forwarded once, explicitly, and its
7//! answer is handed back untouched, so a caller cannot tell it is there.
8//!
9//! `append_all` and `read_at` are forwarded explicitly rather than left to the
10//! trait's defaults. A store may override both (haematite commits a batch as
11//! one write and answers a point read without a range read), and falling back
12//! to the defaults here would turn one batch into one write per row.
13//!
14//! What the seam cannot see is work a store does inside one call: the engine
15//! rows behind a short `read_from`, and the streams a `scan_page` lists. Both
16//! happen below the trait, and an answer carries no record of them.
17//!
18//! Cost, measured once by `the_wrapper_cost_per_call_is_measured_over_a_hundred_thousand_calls`
19//! on 6 October 2026 on Tom's Mac (debug build; Dean's laptop was offline):
20//! median 230 ns per `read_value` wrapped against 140 ns bare, so 90 ns per
21//! call for the seam, over 100,000 calls each. To be re-measured on Dean's
22//! laptop.
23
24use std::fmt;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex, OnceLock};
27
28use super::{DurabilityError, DurableStore, ScanCursor, ScanPage, StoredEntry};
29
30/// Every counter a [`CountingStore`] keeps, read at one moment.
31///
32/// Counters only grow, so the work between two snapshots is
33/// [`StoreCounts::since`] of the later over the earlier.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub struct StoreCounts {
36    /// Atomic routed batch calls, including refusals.
37    pub apply_routed_batch_calls: u64,
38    /// Conditional replacements in successful routed batches.
39    pub apply_routed_batch_items: u64,
40    /// Routed point-read calls, including errors.
41    pub get_routed_calls: u64,
42    /// Values returned by routed point reads.
43    pub get_routed_rows: u64,
44
45    /// `append` calls.
46    pub append_calls: u64,
47    /// Rows written by `append` calls that succeeded.
48    pub append_rows: u64,
49    /// Payload bytes written by `append` calls that succeeded.
50    pub append_bytes: u64,
51    /// `append_all` calls.
52    pub append_all_calls: u64,
53    /// Rows written by `append_all` calls that succeeded.
54    pub append_all_rows: u64,
55    /// Payload bytes written by `append_all` calls that succeeded.
56    pub append_all_bytes: u64,
57    /// `read_from` calls.
58    pub read_from_calls: u64,
59    /// Rows returned by `read_from`.
60    pub read_from_rows: u64,
61    /// `read_at` calls.
62    pub read_at_calls: u64,
63    /// Rows returned by `read_at`: one per call that found its row.
64    pub read_at_rows: u64,
65    /// `cas` calls.
66    pub cas_calls: u64,
67    /// `read_value` calls.
68    pub read_value_calls: u64,
69    /// `read_stream_next_seq` calls.
70    pub read_stream_next_seq_calls: u64,
71    /// `scan_page` calls.
72    pub scan_page_calls: u64,
73    /// Rows returned by `scan_page`.
74    pub scan_page_rows: u64,
75    /// `flush` calls.
76    pub flushes: u64,
77    /// `truncate_before` calls.
78    pub truncate_calls: u64,
79    /// Rows deleted by `truncate_before` calls that succeeded.
80    pub truncate_rows: u64,
81}
82
83impl StoreCounts {
84    /// The work counted after `earlier` was taken.
85    ///
86    /// `earlier` must be a snapshot of the same store taken before `self`.
87    /// Counters never decrease, so each difference is the work between the
88    /// two; a counter that reads lower in `self` answers zero rather than
89    /// wrapping.
90    #[must_use]
91    pub const fn since(&self, earlier: &Self) -> Self {
92        Self {
93            apply_routed_batch_calls: self
94                .apply_routed_batch_calls
95                .saturating_sub(earlier.apply_routed_batch_calls),
96            apply_routed_batch_items: self
97                .apply_routed_batch_items
98                .saturating_sub(earlier.apply_routed_batch_items),
99            get_routed_calls: self
100                .get_routed_calls
101                .saturating_sub(earlier.get_routed_calls),
102            get_routed_rows: self.get_routed_rows.saturating_sub(earlier.get_routed_rows),
103
104            append_calls: self.append_calls.saturating_sub(earlier.append_calls),
105            append_rows: self.append_rows.saturating_sub(earlier.append_rows),
106            append_bytes: self.append_bytes.saturating_sub(earlier.append_bytes),
107            append_all_calls: self
108                .append_all_calls
109                .saturating_sub(earlier.append_all_calls),
110            append_all_rows: self.append_all_rows.saturating_sub(earlier.append_all_rows),
111            append_all_bytes: self
112                .append_all_bytes
113                .saturating_sub(earlier.append_all_bytes),
114            read_from_calls: self.read_from_calls.saturating_sub(earlier.read_from_calls),
115            read_from_rows: self.read_from_rows.saturating_sub(earlier.read_from_rows),
116            read_at_calls: self.read_at_calls.saturating_sub(earlier.read_at_calls),
117            read_at_rows: self.read_at_rows.saturating_sub(earlier.read_at_rows),
118            cas_calls: self.cas_calls.saturating_sub(earlier.cas_calls),
119            read_value_calls: self
120                .read_value_calls
121                .saturating_sub(earlier.read_value_calls),
122            read_stream_next_seq_calls: self
123                .read_stream_next_seq_calls
124                .saturating_sub(earlier.read_stream_next_seq_calls),
125            scan_page_calls: self.scan_page_calls.saturating_sub(earlier.scan_page_calls),
126            scan_page_rows: self.scan_page_rows.saturating_sub(earlier.scan_page_rows),
127            flushes: self.flushes.saturating_sub(earlier.flushes),
128            truncate_calls: self.truncate_calls.saturating_sub(earlier.truncate_calls),
129            truncate_rows: self.truncate_rows.saturating_sub(earlier.truncate_rows),
130        }
131    }
132
133    /// The sum of two sets of counts, for work spread over two stores.
134    #[must_use]
135    pub const fn plus(&self, other: &Self) -> Self {
136        Self {
137            apply_routed_batch_calls: self
138                .apply_routed_batch_calls
139                .saturating_add(other.apply_routed_batch_calls),
140            apply_routed_batch_items: self
141                .apply_routed_batch_items
142                .saturating_add(other.apply_routed_batch_items),
143            get_routed_calls: self.get_routed_calls.saturating_add(other.get_routed_calls),
144            get_routed_rows: self.get_routed_rows.saturating_add(other.get_routed_rows),
145
146            append_calls: self.append_calls.saturating_add(other.append_calls),
147            append_rows: self.append_rows.saturating_add(other.append_rows),
148            append_bytes: self.append_bytes.saturating_add(other.append_bytes),
149            append_all_calls: self.append_all_calls.saturating_add(other.append_all_calls),
150            append_all_rows: self.append_all_rows.saturating_add(other.append_all_rows),
151            append_all_bytes: self.append_all_bytes.saturating_add(other.append_all_bytes),
152            read_from_calls: self.read_from_calls.saturating_add(other.read_from_calls),
153            read_from_rows: self.read_from_rows.saturating_add(other.read_from_rows),
154            read_at_calls: self.read_at_calls.saturating_add(other.read_at_calls),
155            read_at_rows: self.read_at_rows.saturating_add(other.read_at_rows),
156            cas_calls: self.cas_calls.saturating_add(other.cas_calls),
157            read_value_calls: self.read_value_calls.saturating_add(other.read_value_calls),
158            read_stream_next_seq_calls: self
159                .read_stream_next_seq_calls
160                .saturating_add(other.read_stream_next_seq_calls),
161            scan_page_calls: self.scan_page_calls.saturating_add(other.scan_page_calls),
162            scan_page_rows: self.scan_page_rows.saturating_add(other.scan_page_rows),
163            flushes: self.flushes.saturating_add(other.flushes),
164            truncate_calls: self.truncate_calls.saturating_add(other.truncate_calls),
165            truncate_rows: self.truncate_rows.saturating_add(other.truncate_rows),
166        }
167    }
168}
169
170/// Receives counter increments and explicit flush calls at the counting boundary.
171///
172/// The callback must not inspect history or perform work for ordinary increments.
173/// Flush observations describe the boundary call, including a call that fails.
174pub trait StoreCountObserver: fmt::Debug + Send + Sync {
175    /// Called after an actual counter increment, with no publication origin.
176    fn changed(&self);
177
178    /// Called once per explicit flush; a row count is absent unless the boundary knows it.
179    fn flushed(&self, store: &str, batch_rows: Option<u64>);
180}
181
182#[derive(Debug)]
183struct ObserverBinding {
184    name: String,
185    observer: Arc<dyn StoreCountObserver>,
186}
187
188static OBSERVER_INSTALL: Mutex<()> = Mutex::new(());
189
190/// The live counters behind a [`CountingStore`].
191#[derive(Debug, Default)]
192struct Counters {
193    apply_routed_batch_calls: AtomicU64,
194    apply_routed_batch_items: AtomicU64,
195    get_routed_calls: AtomicU64,
196    get_routed_rows: AtomicU64,
197
198    append_calls: AtomicU64,
199    append_rows: AtomicU64,
200    append_bytes: AtomicU64,
201    append_all_calls: AtomicU64,
202    append_all_rows: AtomicU64,
203    append_all_bytes: AtomicU64,
204    read_from_calls: AtomicU64,
205    read_from_rows: AtomicU64,
206    read_at_calls: AtomicU64,
207    read_at_rows: AtomicU64,
208    cas_calls: AtomicU64,
209    read_value_calls: AtomicU64,
210    read_stream_next_seq_calls: AtomicU64,
211    scan_page_calls: AtomicU64,
212    scan_page_rows: AtomicU64,
213    flushes: AtomicU64,
214    truncate_calls: AtomicU64,
215    truncate_rows: AtomicU64,
216    observer: OnceLock<ObserverBinding>,
217}
218
219impl Counters {
220    fn add(&self, counter: &AtomicU64, amount: u64) {
221        if amount == 0 {
222            return;
223        }
224        counter.fetch_add(amount, Ordering::Relaxed);
225        if let Some(binding) = self.observer.get() {
226            binding.observer.changed();
227        }
228    }
229
230    fn flushed(&self) {
231        if let Some(binding) = self.observer.get() {
232            binding.observer.flushed(&binding.name, None);
233        }
234    }
235
236    /// Every counter as it stands now, each read once without a lock.
237    fn snapshot(&self) -> StoreCounts {
238        let read = |counter: &AtomicU64| counter.load(Ordering::Relaxed);
239        StoreCounts {
240            apply_routed_batch_calls: read(&self.apply_routed_batch_calls),
241            apply_routed_batch_items: read(&self.apply_routed_batch_items),
242            get_routed_calls: read(&self.get_routed_calls),
243            get_routed_rows: read(&self.get_routed_rows),
244            append_calls: read(&self.append_calls),
245            append_rows: read(&self.append_rows),
246            append_bytes: read(&self.append_bytes),
247            append_all_calls: read(&self.append_all_calls),
248            append_all_rows: read(&self.append_all_rows),
249            append_all_bytes: read(&self.append_all_bytes),
250            read_from_calls: read(&self.read_from_calls),
251            read_from_rows: read(&self.read_from_rows),
252            read_at_calls: read(&self.read_at_calls),
253            read_at_rows: read(&self.read_at_rows),
254            cas_calls: read(&self.cas_calls),
255            read_value_calls: read(&self.read_value_calls),
256            read_stream_next_seq_calls: read(&self.read_stream_next_seq_calls),
257            scan_page_calls: read(&self.scan_page_calls),
258            scan_page_rows: read(&self.scan_page_rows),
259            flushes: read(&self.flushes),
260            truncate_calls: read(&self.truncate_calls),
261            truncate_rows: read(&self.truncate_rows),
262        }
263    }
264}
265
266/// A cloneable reader of one or more [`CountingStore`]s' counters.
267///
268/// Handed out where a store is built, beside the store itself, so a test can
269/// take a snapshot of a store it only holds as `Arc<dyn DurableStore>`. A
270/// handle over several stores answers their sum: the work of one journey,
271/// wherever it landed.
272#[derive(Clone, Debug, Default)]
273pub struct StoreCountsHandle {
274    stores: Vec<Arc<Counters>>,
275}
276
277impl StoreCountsHandle {
278    /// The number of independently counted stores read by this handle.
279    #[must_use]
280    pub fn store_count(&self) -> usize {
281        self.stores.len()
282    }
283
284    /// Installs one observer with one nonempty name for each counted store.
285    ///
286    /// # Errors
287    /// Refuses absent stores, a name count mismatch, empty names, duplicate stores,
288    /// an already installed observer, or a poisoned installation lock. Refusals
289    /// detected during validation leave every store unchanged.
290    pub fn install_observer(
291        &self,
292        names: &[&str],
293        observer: Arc<dyn StoreCountObserver>,
294    ) -> Result<(), DurabilityError> {
295        if self.stores.is_empty() || names.len() != self.stores.len() {
296            return Err(observer_error(
297                "one name is required for each counted store",
298            ));
299        }
300        if names.iter().any(|name| name.trim().is_empty()) {
301            return Err(observer_error("store name is empty"));
302        }
303        for (index, counters) in self.stores.iter().enumerate() {
304            if self.stores[..index]
305                .iter()
306                .any(|earlier| Arc::ptr_eq(earlier, counters))
307            {
308                return Err(observer_error(
309                    "the handle contains a duplicate counted store",
310                ));
311            }
312        }
313        let installation = OBSERVER_INSTALL
314            .lock()
315            .map_err(|_| observer_error("installation lock poisoned"))?;
316        if self
317            .stores
318            .iter()
319            .any(|counters| counters.observer.get().is_some())
320        {
321            return Err(observer_error("observer already installed"));
322        }
323        for ((counters, name), observer) in self
324            .stores
325            .iter()
326            .zip(names)
327            .zip(std::iter::repeat_n(observer, self.stores.len()))
328        {
329            counters
330                .observer
331                .set(ObserverBinding {
332                    name: (*name).to_owned(),
333                    observer,
334                })
335                .map_err(|_| observer_error("observer installation invariant failed"))?;
336        }
337        drop(installation);
338        Ok(())
339    }
340
341    /// One handle answering the sum of `self` and `other`.
342    #[must_use]
343    pub fn with(mut self, other: &Self) -> Self {
344        self.stores.extend(other.stores.iter().cloned());
345        self
346    }
347
348    /// Every counter, summed over the stores this handle reads, as it stands
349    /// now. See [`CountingStore::snapshot`] for what a snapshot taken during
350    /// a call may miss.
351    #[must_use]
352    pub fn snapshot(&self) -> StoreCounts {
353        self.stores
354            .iter()
355            .fold(StoreCounts::default(), |total, counters| {
356                total.plus(&counters.snapshot())
357            })
358    }
359}
360
361/// Databases opened or created in this process through liminal's open paths.
362static STORES_OPENED: AtomicU64 = AtomicU64::new(0);
363
364/// How many durable databases this process has opened or created through
365/// liminal's open paths, ephemeral and persistent alike. Only grows.
366#[must_use]
367pub fn stores_opened() -> u64 {
368    STORES_OPENED.load(Ordering::Relaxed)
369}
370
371/// Records one durable database opened or created. Every open path calls it
372/// once, after the database is open, so a failed open is never counted.
373pub fn record_store_opened() {
374    add(&STORES_OPENED, 1);
375}
376
377/// Adds `amount` to `counter`.
378///
379/// Relaxed ordering: each counter is a tally on its own, read only by a
380/// snapshot, and orders nothing else.
381fn add(counter: &AtomicU64, amount: u64) {
382    counter.fetch_add(amount, Ordering::Relaxed);
383}
384
385fn observer_error(message: &str) -> DurabilityError {
386    DurabilityError::ConfigError(format!("store count observer: {message}"))
387}
388
389/// A row or byte total as a counter increment; a `usize` always fits.
390fn widen(amount: usize) -> u64 {
391    u64::try_from(amount).unwrap_or(u64::MAX)
392}
393
394/// A [`DurableStore`] that forwards every method to `S` and counts the work.
395///
396/// Counter increments take no lock and allocate nothing. An installed observer
397/// receives the increment and explicit flush call; every await is the inner store's.
398#[derive(Debug)]
399pub struct CountingStore<S> {
400    inner: S,
401    counters: Arc<Counters>,
402}
403
404impl<S: DurableStore> CountingStore<S> {
405    /// Wraps `inner`, with every counter at zero.
406    #[must_use]
407    pub fn new(inner: S) -> Self {
408        Self {
409            inner,
410            counters: Arc::default(),
411        }
412    }
413
414    /// A handle that reads this store's counters, to be kept beside the store
415    /// once it is held only as `Arc<dyn DurableStore>`.
416    #[must_use]
417    pub fn handle(&self) -> StoreCountsHandle {
418        StoreCountsHandle {
419            stores: vec![Arc::clone(&self.counters)],
420        }
421    }
422
423    /// Every counter as it stands now.
424    ///
425    /// Each counter is read once, without a lock, so a snapshot taken while
426    /// other calls are in flight may count a call whose rows it does not yet
427    /// count. Between two steps of one journey nothing is in flight.
428    #[must_use]
429    pub fn snapshot(&self) -> StoreCounts {
430        self.counters.snapshot()
431    }
432}
433
434#[async_trait::async_trait]
435impl<S: DurableStore> DurableStore for CountingStore<S> {
436    fn reclaim_spine(&self) -> Option<std::sync::Arc<haematite::EventStore>> {
437        self.inner.reclaim_spine()
438    }
439
440    fn max_request_bytes(&self) -> Option<usize> {
441        self.inner.max_request_bytes()
442    }
443
444    async fn apply_routed_batch(
445        &self,
446        route_key: &str,
447        items: Vec<super::RoutedBatchItem>,
448    ) -> Result<(), DurabilityError> {
449        self.counters
450            .add(&self.counters.apply_routed_batch_calls, 1);
451        let count = widen(items.len());
452        self.inner.apply_routed_batch(route_key, items).await?;
453        self.counters
454            .add(&self.counters.apply_routed_batch_items, count);
455        Ok(())
456    }
457
458    /// Counted as a routed batch: one call, and every entry (a put or a
459    /// delete) an item of it once it committed.
460    async fn apply_routed_entries(
461        &self,
462        route_key: &str,
463        entries: Vec<super::RoutedBatchEntry>,
464    ) -> Result<(), DurabilityError> {
465        self.counters
466            .add(&self.counters.apply_routed_batch_calls, 1);
467        let count = widen(entries.len());
468        self.inner.apply_routed_entries(route_key, entries).await?;
469        self.counters
470            .add(&self.counters.apply_routed_batch_items, count);
471        Ok(())
472    }
473
474    async fn get_routed(
475        &self,
476        route_key: &str,
477        key: &[u8],
478    ) -> Result<Option<Vec<u8>>, DurabilityError> {
479        self.counters.add(&self.counters.get_routed_calls, 1);
480        let value = self.inner.get_routed(route_key, key).await?;
481        if value.is_some() {
482            self.counters.add(&self.counters.get_routed_rows, 1);
483        }
484        Ok(value)
485    }
486
487    async fn append(
488        &self,
489        stream_key: &str,
490        payload: Vec<u8>,
491        expected_seq: u64,
492    ) -> Result<u64, DurabilityError> {
493        self.counters.add(&self.counters.append_calls, 1);
494        let bytes = widen(payload.len());
495        let assigned = self.inner.append(stream_key, payload, expected_seq).await?;
496        self.counters.add(&self.counters.append_rows, 1);
497        self.counters.add(&self.counters.append_bytes, bytes);
498        Ok(assigned)
499    }
500
501    async fn read_from(
502        &self,
503        stream_key: &str,
504        offset: u64,
505        limit: usize,
506    ) -> Result<Vec<StoredEntry>, DurabilityError> {
507        self.counters.add(&self.counters.read_from_calls, 1);
508        let entries = self.inner.read_from(stream_key, offset, limit).await?;
509        self.counters
510            .add(&self.counters.read_from_rows, widen(entries.len()));
511        Ok(entries)
512    }
513
514    async fn append_all(
515        &self,
516        stream_key: &str,
517        payloads: Vec<Vec<u8>>,
518        expected_seq: u64,
519    ) -> Result<u64, DurabilityError> {
520        self.counters.add(&self.counters.append_all_calls, 1);
521        let rows = widen(payloads.len());
522        let bytes = payloads
523            .iter()
524            .map(|payload| widen(payload.len()))
525            .fold(0_u64, u64::saturating_add);
526        let next = self
527            .inner
528            .append_all(stream_key, payloads, expected_seq)
529            .await?;
530        self.counters.add(&self.counters.append_all_rows, rows);
531        self.counters.add(&self.counters.append_all_bytes, bytes);
532        Ok(next)
533    }
534
535    async fn read_at(
536        &self,
537        stream_key: &str,
538        sequence: u64,
539    ) -> Result<Option<StoredEntry>, DurabilityError> {
540        self.counters.add(&self.counters.read_at_calls, 1);
541        let entry = self.inner.read_at(stream_key, sequence).await?;
542        if entry.is_some() {
543            self.counters.add(&self.counters.read_at_rows, 1);
544        }
545        Ok(entry)
546    }
547
548    async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
549        self.counters.add(&self.counters.cas_calls, 1);
550        self.inner.cas(key, old_value, new_value).await
551    }
552
553    async fn read_stream_next_seq(&self, stream_key: &str) -> Result<Option<u64>, DurabilityError> {
554        self.counters
555            .add(&self.counters.read_stream_next_seq_calls, 1);
556        self.inner.read_stream_next_seq(stream_key).await
557    }
558
559    async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
560        self.counters.add(&self.counters.read_value_calls, 1);
561        self.inner.read_value(key).await
562    }
563
564    async fn scan_page(
565        &self,
566        prefix: &str,
567        cursor: Option<ScanCursor>,
568        limit: usize,
569    ) -> Result<ScanPage, DurabilityError> {
570        self.counters.add(&self.counters.scan_page_calls, 1);
571        let page = self.inner.scan_page(prefix, cursor, limit).await?;
572        self.counters
573            .add(&self.counters.scan_page_rows, widen(page.entries.len()));
574        Ok(page)
575    }
576
577    async fn flush(&self) -> Result<(), DurabilityError> {
578        self.counters.add(&self.counters.flushes, 1);
579        self.counters.flushed();
580        self.inner.flush().await
581    }
582
583    async fn truncate_before(
584        &self,
585        stream_key: &str,
586        before_seq: u64,
587    ) -> Result<u64, DurabilityError> {
588        self.counters.add(&self.counters.truncate_calls, 1);
589        let deleted = self.inner.truncate_before(stream_key, before_seq).await?;
590        self.counters.add(&self.counters.truncate_rows, deleted);
591        Ok(deleted)
592    }
593
594    async fn scan_routed(
595        &self,
596        route_key: &str,
597        from: &[u8],
598        to: &[u8],
599    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, DurabilityError> {
600        self.inner.scan_routed(route_key, from, to).await
601    }
602
603    async fn remove_routed_range(
604        &self,
605        route_key: &str,
606        from: &[u8],
607        to: &[u8],
608    ) -> Result<u64, DurabilityError> {
609        self.inner.remove_routed_range(route_key, from, to).await
610    }
611
612    fn store_counts(&self) -> Option<StoreCountsHandle> {
613        Some(self.handle())
614    }
615}
616
617#[path = "counting_names.rs"]
618mod names;
619
620#[cfg(test)]
621mod tests;