Skip to main content

rushdb_storage/
usage.rs

1//! Per-tenant logical byte accounting.
2//!
3//! Every backend write already reads the value it replaces (for MVCC history),
4//! so the change in a tenant's stored `key + value` bytes is known at apply
5//! time at no extra read. A backend folds each op of a batch into
6//! [`UsageBytes`] and writes one [`usage_bytes_key`] row per touched tenant in
7//! the same backend transaction, so the figure is exact at every commit index
8//! and replay (WAL, replicas) reproduces it.
9//!
10//! The figure is *logical*: it sums the row bytes the engine stores, not the
11//! file pages that hold them (redb pages run partly full, so the file is
12//! larger). The usage row itself is excluded, which keeps it a fixed point.
13
14use std::collections::BTreeMap;
15
16use rushdb_types::ids::TenantId;
17use rushdb_types::keys::{parse_tenant, usage_bytes_key, F_USAGE_BYTES, TENANT_PREFIX_LEN};
18
19/// Whether `key` is a tenant's usage-bytes row.
20#[must_use]
21pub fn is_usage_bytes_key(key: &[u8]) -> bool {
22    key.len() == TENANT_PREFIX_LEN + 1
23        && parse_tenant(key).is_some()
24        && key[TENANT_PREFIX_LEN] == F_USAGE_BYTES
25}
26
27/// Byte deltas of one batch, per tenant.
28#[derive(Default)]
29pub(crate) struct UsageBytes {
30    deltas: BTreeMap<TenantId, i64>,
31}
32
33impl UsageBytes {
34    /// Record that `key` went from a value of `old` bytes to one of `new`
35    /// bytes (`None` = absent).
36    pub(crate) fn record(&mut self, key: &[u8], old: Option<usize>, new: Option<usize>) {
37        let Some(tenant) = parse_tenant(key) else {
38            return;
39        };
40        if is_usage_bytes_key(key) {
41            return;
42        }
43        let size = |value: Option<usize>| {
44            value.map_or(0, |len| i64::try_from(key.len() + len).unwrap_or(i64::MAX))
45        };
46        let delta = size(new) - size(old);
47        if delta != 0 {
48            *self.deltas.entry(tenant).or_default() += delta;
49        }
50    }
51
52    /// The usage rows to write: for each tenant, its new total from `current`
53    /// (the row's value before the batch). A total of zero deletes the row.
54    ///
55    /// # Errors
56    /// Returns a message when a stored total is malformed or the delta would
57    /// take it below zero, which means the accounting has diverged.
58    pub(crate) fn rows(
59        self,
60        mut current: impl FnMut(&[u8]) -> Result<Option<Vec<u8>>, String>,
61    ) -> Result<Vec<(Vec<u8>, Option<Vec<u8>>)>, String> {
62        let mut out = Vec::with_capacity(self.deltas.len());
63        for (tenant, delta) in self.deltas {
64            if delta == 0 {
65                continue;
66            }
67            let key = usage_bytes_key(tenant);
68            let total = rushdb_types::keys::decode_counter(current(&key)?.as_deref())
69                .ok_or_else(|| format!("tenant {tenant}: malformed usage-bytes row"))?;
70            let next = total
71                .checked_add_signed(delta)
72                .ok_or_else(|| format!("tenant {tenant}: usage bytes would underflow"))?;
73            out.push((key, (next > 0).then(|| next.to_be_bytes().to_vec())));
74        }
75        Ok(out)
76    }
77}