Skip to main content

rushdb_storage/
resources.rs

1//! Shared managed-byte accounting. Reservations are ownership tokens, not
2//! allocations, an RSS limit, or a substitute for charging each consumer.
3use std::sync::{
4    atomic::{AtomicU64, AtomicUsize, Ordering},
5    Arc, Mutex, MutexGuard,
6};
7use thiserror::Error;
8
9/// Errors from managed-byte accounting: invalid budgets, exhausted limits,
10/// and reservation ownership violations.
11#[derive(Debug, Error, Clone, PartialEq, Eq)]
12pub enum ResourceError {
13    /// The requested budget or limit is invalid.
14    #[error("invalid resource budget: {0}")]
15    Invalid(&'static str),
16    /// A budget could not admit the requested bytes.
17    #[error("resource budget `{budget}` exhausted: requested {requested} bytes, used {used}, limit {limit}")]
18    Exhausted {
19        /// Budget that refused the request.
20        budget: &'static str,
21        /// Bytes requested.
22        requested: usize,
23        /// Bytes currently charged.
24        used: usize,
25        /// Configured byte limit.
26        limit: usize,
27    },
28    /// The two reservations belong to different resource trees.
29    #[error("reservation belongs to a different resource tree")]
30    DifferentTree,
31    /// A reservation operation would exceed the bytes it owns.
32    #[error("reservation operation exceeds its owned bytes")]
33    InvalidOwnership,
34}
35
36/// Point-in-time usage of one budget: configured limit, current and peak use.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct BudgetSnapshot {
39    /// Configured byte limit.
40    pub limit: usize,
41    /// Bytes currently charged.
42    pub used: usize,
43    /// Highest usage observed.
44    pub peak: usize,
45}
46
47/// Admission error for a [`FairAdmission::acquire`] attempt. A successful call
48/// returns the owned [`ByteReservation`]; failure returns one of these without
49/// charging bytes.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AdmissionError {
52    /// The deadline expired before bytes were available; no charge was made.
53    TimedOut,
54    /// The gate was cancelled before admission; no charge was made.
55    Cancelled,
56    /// The budget could not admit the request at this instant.
57    Exhausted,
58}
59
60/// Monotonic ticket source for fair-admission waiters.
61static NEXT_TICKET: AtomicU64 = AtomicU64::new(1);
62
63struct Node {
64    name: &'static str,
65    limit: usize,
66    used: AtomicUsize,
67    peak: AtomicUsize,
68    depth: usize,
69    parent: Option<Arc<Node>>,
70    ledger: Arc<Mutex<()>>,
71}
72
73/// Child usage is included in every ancestor; creating children does not
74/// multiply the parent limit. Names are static component names, not tenant
75/// identifiers or unbounded metric labels. Dropping the last child handle and
76/// reservation releases its metadata without retaining a central registry.
77#[derive(Clone)]
78pub struct ByteBudget(Arc<Node>);
79
80impl ByteBudget {
81    /// Create a root budget labelled `name` with the given byte `limit`.
82    ///
83    /// # Errors
84    /// Returns [`ResourceError::Invalid`] if `limit` is zero.
85    pub fn new(name: &'static str, limit: usize) -> Result<Self, ResourceError> {
86        if limit == 0 {
87            return Err(ResourceError::Invalid("limit must be positive"));
88        }
89        Ok(Self(Arc::new(Node {
90            name,
91            limit,
92            used: AtomicUsize::new(0),
93            peak: AtomicUsize::new(0),
94            depth: 0,
95            parent: None,
96            ledger: Arc::new(Mutex::new(())),
97        })))
98    }
99
100    /// Create a child budget whose usage is also charged to this budget.
101    ///
102    /// # Errors
103    /// Returns [`ResourceError::Invalid`] if `limit` is zero or exceeds the
104    /// parent limit, or if nesting would exceed 32 levels.
105    pub fn child(&self, name: &'static str, limit: usize) -> Result<Self, ResourceError> {
106        if limit == 0 || limit > self.0.limit {
107            return Err(ResourceError::Invalid(
108                "child limit must be positive and no greater than parent",
109            ));
110        }
111        if self.0.depth >= 32 {
112            return Err(ResourceError::Invalid("budget nesting exceeds 32 levels"));
113        }
114        Ok(Self(Arc::new(Node {
115            name,
116            limit,
117            used: AtomicUsize::new(0),
118            peak: AtomicUsize::new(0),
119            depth: self.0.depth + 1,
120            parent: Some(self.0.clone()),
121            ledger: self.0.ledger.clone(),
122        })))
123    }
124
125    /// Serialize *growth* (validate + add) across the whole budget tree.
126    /// Releases and reads are lock-free atomics, so this lock is held only on
127    /// the admission path and never blocks a `Drop`. Recover a poisoned mutex
128    /// so a panicked admission cannot wedge future reservations.
129    fn lock(&self) -> MutexGuard<'_, ()> {
130        self.0
131            .ledger
132            .lock()
133            .unwrap_or_else(std::sync::PoisonError::into_inner)
134    }
135
136    fn ancestors(&self) -> impl Iterator<Item = &Node> {
137        std::iter::successors(Some(self.0.as_ref()), |node| node.parent.as_deref())
138    }
139
140    fn validate_add(&self, bytes: usize) -> Result<(), ResourceError> {
141        for node in self.ancestors() {
142            let used = node.used.load(Ordering::Acquire);
143            if bytes > node.limit - used {
144                return Err(ResourceError::Exhausted {
145                    budget: node.name,
146                    requested: bytes,
147                    used,
148                    limit: node.limit,
149                });
150            }
151        }
152        Ok(())
153    }
154
155    fn add(&self, bytes: usize) {
156        for node in self.ancestors() {
157            let used = node.used.fetch_add(bytes, Ordering::AcqRel) + bytes;
158            node.peak.fetch_max(used, Ordering::Relaxed);
159        }
160    }
161
162    /// Release a charge. Atomic and lock-free: a release can only lower usage,
163    /// so it cannot race a concurrent admission into over-subscription. Callers
164    /// hold `ledger` only when growing.
165    fn subtract(&self, bytes: usize) {
166        for node in self.ancestors() {
167            node.used.fetch_sub(bytes, Ordering::Release);
168        }
169    }
170
171    /// Snapshot this budget's limit, current usage, and peak usage.
172    #[must_use]
173    pub fn snapshot(&self) -> BudgetSnapshot {
174        BudgetSnapshot {
175            limit: self.0.limit,
176            used: self.0.used.load(Ordering::Acquire),
177            peak: self.0.peak.load(Ordering::Relaxed),
178        }
179    }
180
181    /// Available in this budget AND every parent at one accounting instant.
182    ///
183    /// # Panics
184    /// Panics if the ancestor chain is empty, which cannot happen because a
185    /// budget always has at least itself.
186    #[must_use]
187    pub fn available(&self) -> usize {
188        self.ancestors()
189            .map(|node| node.limit - node.used.load(Ordering::Acquire))
190            .min()
191            .unwrap()
192    }
193
194    /// Immediate admission, before allocation. Queueing, deadlines and fair
195    /// scheduling are deliberately separate work; this never blocks for bytes.
196    ///
197    /// # Errors
198    /// Returns [`ResourceError::Exhausted`] if this budget or any ancestor
199    /// cannot admit `bytes`.
200    pub fn try_reserve(&self, bytes: usize) -> Result<ByteReservation, ResourceError> {
201        let _guard = self.lock();
202        self.validate_add(bytes)?;
203        self.add(bytes);
204        Ok(ByteReservation {
205            budget: self.clone(),
206            bytes,
207        })
208    }
209}
210
211/// Not Clone: exactly one owner releases each charge, including during unwind.
212/// Move this with the allocated buffer; do not drop it while retaining data.
213pub struct ByteReservation {
214    budget: ByteBudget,
215    bytes: usize,
216}
217
218impl ByteReservation {
219    /// Bytes currently owned by this reservation.
220    #[must_use]
221    pub fn bytes(&self) -> usize {
222        self.bytes
223    }
224
225    /// Change the owned byte count, charging or releasing the difference.
226    ///
227    /// # Errors
228    /// Returns [`ResourceError::Exhausted`] if growing the reservation cannot
229    /// be admitted.
230    pub fn resize(&mut self, bytes: usize) -> Result<(), ResourceError> {
231        if bytes > self.bytes {
232            // Growing must be atomic against other admissions.
233            let _guard = self.budget.lock();
234            self.budget.validate_add(bytes - self.bytes)?;
235            self.budget.add(bytes - self.bytes);
236        } else {
237            // Shrinking only releases; lock-free.
238            self.budget.subtract(self.bytes - bytes);
239        }
240        self.bytes = bytes;
241        Ok(())
242    }
243
244    /// Split ownership without releasing or charging the same bytes twice.
245    ///
246    /// # Errors
247    /// Returns [`ResourceError::InvalidOwnership`] if `bytes` exceeds the
248    /// owned amount.
249    pub fn split(&mut self, bytes: usize) -> Result<Self, ResourceError> {
250        if bytes > self.bytes {
251            return Err(ResourceError::InvalidOwnership);
252        }
253        self.bytes -= bytes;
254        Ok(Self {
255            budget: self.budget.clone(),
256            bytes,
257        })
258    }
259
260    /// Transfer a live charge atomically, including shared-parent accounting.
261    /// Failure leaves the source reservation and all counters unchanged.
262    ///
263    /// # Errors
264    /// Returns [`ResourceError::DifferentTree`] if `destination` is in another
265    /// tree, or [`ResourceError::Exhausted`] if it cannot admit the bytes.
266    pub fn transfer(&mut self, destination: &ByteBudget) -> Result<(), ResourceError> {
267        if !Arc::ptr_eq(&self.budget.0.ledger, &destination.0.ledger) {
268            return Err(ResourceError::DifferentTree);
269        }
270        {
271            let _guard = self.budget.lock();
272            self.budget.subtract(self.bytes);
273            if let Err(error) = destination.validate_add(self.bytes) {
274                self.budget.add(self.bytes);
275                return Err(error);
276            }
277            destination.add(self.bytes);
278        }
279        self.budget = destination.clone();
280        Ok(())
281    }
282}
283
284impl Drop for ByteReservation {
285    fn drop(&mut self) {
286        // Lock-free release: this is the hottest accounting path (every
287        // reservation, cache eviction, and rollback).
288        self.budget.subtract(self.bytes);
289    }
290}
291
292/// Bounded fair admission gate: serializes requests for bytes from a
293/// budget and admits them in arrival order when capacity frees up, honoring a
294/// caller deadline and an external cancellation flag. `try_reserve` remains
295/// the non-blocking primitive; this adds bounded queueing, deadlines, and
296/// cancellation for work that must wait rather than fail immediately (e.g.
297/// group-commit staging, transaction buffers).
298///
299/// Fairness is FIFO by arrival; a cancelled waiter releases its slot so a
300/// blocked call cannot be starved by a never-completing predecessor.
301pub struct FairAdmission {
302    budget: ByteBudget,
303    /// FIFO of queued waiters, each tracking the bytes it wants and its
304    /// deadline (ms since epoch) / cancellation flag.
305    queue: Mutex<std::collections::VecDeque<Waiter>>,
306    /// Broadcast when bytes are released or a waiter is removed.
307    changed: std::sync::Condvar,
308}
309
310struct Waiter {
311    deadline_ms: u64,
312    cancelled: Arc<std::sync::atomic::AtomicBool>,
313    ticket: u64,
314}
315
316impl FairAdmission {
317    /// Create a fair-admission gate over `budget`.
318    #[must_use]
319    pub fn new(budget: ByteBudget) -> Self {
320        Self {
321            budget,
322            queue: Mutex::new(std::collections::VecDeque::new()),
323            changed: std::sync::Condvar::new(),
324        }
325    }
326
327    /// Try to admit immediately without queueing (non-blocking).
328    ///
329    /// # Errors
330    /// Returns [`ResourceError::Exhausted`] if the budget cannot admit
331    /// `bytes` right now.
332    pub fn try_acquire(&self, bytes: usize) -> Result<ByteReservation, ResourceError> {
333        self.budget.try_reserve(bytes)
334    }
335
336    /// Queue until the budget admits `bytes`, the deadline (ms since epoch)
337    /// passes, or the caller's cancellation flag is set. FIFO: a request ahead
338    /// in the queue is served first; it may time out and release its slot.
339    ///
340    /// `cancelled` is an `Arc` the caller shares with this queue; setting it
341    /// wakes any queued waiter that shares it and removes it from the queue.
342    ///
343    /// # Errors
344    /// Returns [`AdmissionError::TimedOut`] on deadline expiry,
345    /// [`AdmissionError::Cancelled`] when the flag is observed, or
346    /// [`AdmissionError::Exhausted`] if the budget cannot admit `bytes`.
347    pub fn acquire(
348        &self,
349        bytes: usize,
350        deadline_ms: u64,
351        cancelled: &std::sync::Arc<std::sync::atomic::AtomicBool>,
352    ) -> Result<ByteReservation, AdmissionError> {
353        // Fast path: enough capacity now.
354        if self.budget.available() >= bytes {
355            return self
356                .budget
357                .try_reserve(bytes)
358                .map_err(|_| AdmissionError::Exhausted);
359        }
360        if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
361            return Err(AdmissionError::Cancelled);
362        }
363        if now_ms() >= deadline_ms {
364            return Err(AdmissionError::TimedOut);
365        }
366
367        // Each waiter carries a unique ticket identity so position tracking is
368        // robust to earlier waiters being purged.
369        let ticket_id = NEXT_TICKET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
370
371        let waiter = Waiter {
372            deadline_ms,
373            cancelled: Arc::clone(cancelled),
374            ticket: ticket_id,
375        };
376        let mut guard = self
377            .queue
378            .lock()
379            .unwrap_or_else(std::sync::PoisonError::into_inner);
380        guard.push_back(waiter);
381
382        loop {
383            // Drop expired or cancelled waiters that are NOT us (we handle our
384            // own timeout/cancel below).
385            let my_ticket = ticket_id;
386            let now = now_ms();
387            let mut changed = false;
388            guard.retain(|w| {
389                if w.ticket == my_ticket {
390                    true // keep ourselves
391                } else if w.cancelled.load(std::sync::atomic::Ordering::Relaxed)
392                    || w.deadline_ms <= now
393                {
394                    changed = true;
395                    false
396                } else {
397                    true
398                }
399            });
400            if changed {
401                self.changed.notify_all();
402            }
403
404            // Am I the head, and does the budget have capacity?
405            let is_head = guard.front().is_some_and(|w| w.ticket == my_ticket);
406            if is_head && self.budget.available() >= bytes {
407                if let Ok(res) = self.budget.try_reserve(bytes) {
408                    guard.pop_front();
409                    self.changed.notify_all();
410                    return Ok(res);
411                }
412                guard.pop_front();
413                self.changed.notify_all();
414                return Err(AdmissionError::Exhausted);
415            }
416
417            // Own deadline / cancellation check.
418            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
419                guard.retain(|w| w.ticket != my_ticket);
420                self.changed.notify_all();
421                return Err(AdmissionError::Cancelled);
422            }
423            if now_ms() >= deadline_ms {
424                guard.retain(|w| w.ticket != my_ticket);
425                self.changed.notify_all();
426                return Err(AdmissionError::TimedOut);
427            }
428
429            let (guard2, _timeout) = self
430                .changed
431                .wait_timeout(guard, std::time::Duration::from_millis(2))
432                .unwrap_or_else(std::sync::PoisonError::into_inner);
433            guard = guard2;
434        }
435    }
436}
437
438fn now_ms() -> u64 {
439    std::time::SystemTime::now()
440        .duration_since(std::time::UNIX_EPOCH)
441        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
442}
443
444/// Initial embedded storage configuration, not a whole-process RSS promise.
445#[derive(Debug, Clone, Copy)]
446pub struct StorageResources {
447    /// Total managed byte envelope for the store.
448    pub managed_limit_bytes: usize,
449    /// Bytes reserved for the redb page cache.
450    pub page_cache_bytes: usize,
451    /// Bytes reserved for transaction staging.
452    pub transaction_pool_bytes: usize,
453    /// Logical durability contract applied to writes.
454    pub durability: Durability,
455    /// Number of physical storage shards (independent redb files / writers).
456    /// Default 1 preserves today's single-file layout. A value > 1 enables the
457    /// per-shard backend array; the writer currently targets shard 0 until the
458    /// per-shard commit-index split (W4 phase 2) lands.
459    pub shard_count: usize,
460    /// Acknowledge that `shard_count > 1` is experimental. Multi-shard commit
461    /// groups are persisted as independent redb transactions per shard, so a
462    /// crash between shard commits is **not** atomic and can leave a torn
463    /// global commit sequence. Until a redo journal makes the group atomic,
464    /// multi-shard stores are refused unless this is set explicitly.
465    pub experimental_multi_shard: bool,
466    /// Max commits coalesced into one group-commit redb write transaction
467    /// (P04 bounded writer). 0 = no count cap.
468    pub max_group_count: usize,
469    /// Max staged op-set bytes coalesced into one group (P04 bounded writer).
470    /// 0 = no byte cap.
471    pub max_group_bytes: usize,
472    /// Max time the group-commit publisher lingers before flushing, giving
473    /// concurrent commits a window to join one fsync (V2 WAL batching tuning).
474    /// 0 = no linger (flush immediately).
475    pub max_group_delay_ms: u64,
476    /// Target size of one segmented WAL file before it is sealed and a new
477    /// segment starts (log-first). Must be at least 64 KiB.
478    pub log_segment_bytes: u64,
479    /// Bytes reserved for byte-accounted logical caches (decoded records,
480    /// adjacency, property dictionary). Charged to the shared budget tree so
481    /// cache growth is bounded and visible to the governor. 0 = disabled.
482    pub logical_cache_bytes: usize,
483    /// Bytes the resident ANN generations of vector spaces may hold, a
484    /// budget of its own under the managed limit (not a share of
485    /// `logical_cache_bytes`), so a large space keeps its index beside the
486    /// record and text caches. 0 = no resident ANN: searches answer exactly.
487    pub vector_cache_bytes: usize,
488    /// Bytes resident adjacency graphs (one relationship type at one
489    /// snapshot, for traversals) may hold, a budget of its own under the
490    /// managed limit. 0 = traversals always read adjacency from storage.
491    pub graph_cache_bytes: usize,
492    /// Bytes reserved for concurrent request *phases* (query planning and
493    /// execution admission). Each `ExecutionContext` and each `DataFusion`
494    /// session reserves a fixed per-request token from this pool, so the pool
495    /// size sets the process-wide concurrency ceiling. Decoupled from
496    /// `logical_cache_bytes` so a small cache budget does not accidentally cap
497    /// concurrency at a handful of requests. 0 = phase admission disabled.
498    pub phase_budget_bytes: usize,
499    /// Per-query working-set cap in bytes. When non-zero, a structured query
500    /// that would materialize more than this returns `resource exhausted`
501    /// instead of ballooning RSS (config #3). 0 = off.
502    pub query_memory_max_bytes: u64,
503    /// Largest record population a filter without a usable index may check
504    ///; a query above it is refused at planning with the index to
505    /// declare. 0 = off.
506    pub unindexed_walk_max_owners: u64,
507    /// Maximum time to wait for the admission lock before failing a commit
508    /// with `resource exhausted` (retryable). 0 = wait indefinitely.
509    pub lock_acquisition_timeout_ms: u64,
510    /// Max bytes of one commit's expanded write set (encoded op-set plus the
511    /// serialized `CommitBatch` command payload) before the commit index is
512    /// reserved (large-transaction support). A single oversized
513    /// ingest/mutate request is rejected here, cleanly and without fencing
514    /// the store, instead of burning a commit index and only failing much
515    /// later when a 64 MiB-capped WAL record can't hold it. Deliberately a
516    /// generous, product-level ceiling (hundreds of MB to low GB) — the
517    /// WAL's own `log_store::MAX_RECORD_BYTES` stays fixed, and a commit
518    /// under this ceiling but over that one is transparently chained across
519    /// several physical records (see `wal::encode_chained_ops_record`).
520    /// Above this ceiling, the expectation is an external ETL/bulk-load
521    /// pipeline, not the live transactional path (matching Postgres `COPY`,
522    /// Neo4j's offline bulk importer, MongoDB's `mongoimport`).
523    pub max_expanded_commit_bytes: usize,
524    /// Byte budget of the planner-statistics cache, a child of the
525    /// logical cache budget when that is enabled. 0 = no caching (every
526    /// estimate walks the value dictionary).
527    pub planner_stats_bytes: usize,
528    /// Seconds a planner-statistics entry may sit unused before eviction.
529    pub planner_stats_idle_seconds: u64,
530    /// Commits after which a cached planner-statistics entry is recomputed.
531    pub planner_stats_max_age_commits: u64,
532    /// How long a retry receipt answers a repeated commit key. A
533    /// retry after the window executes as a new commit.
534    pub idempotency_window_seconds: u64,
535}
536
537/// Logical durability contract (research D10). `Local` is the default and
538/// guarantees the commit is durable on this node before it is acknowledged.
539/// `Async` does **not** fsync: with redb 4.x the commit is not persisted until
540/// a later `Local` commit or checkpoint, so an acknowledged `Async` commit can
541/// be lost on a crash, power loss, or hard kill. It is intended only for
542/// re-importable bulk input, not as a weaker consistency mode. `Memory` is only
543/// meaningful for in-memory stores.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
545pub enum Durability {
546    /// In-memory only; no durable backend.
547    Memory,
548    /// Not fsynced and not persisted until a later `Local` commit or
549    /// checkpoint; an acknowledged commit can be lost on any crash. For
550    /// re-importable bulk input only.
551    Async,
552    /// Durable on this node before acknowledgement (default).
553    #[default]
554    Local,
555}
556
557impl Default for StorageResources {
558    fn default() -> Self {
559        Self {
560            managed_limit_bytes: 512 * 1024 * 1024,
561            page_cache_bytes: 128 * 1024 * 1024,
562            transaction_pool_bytes: 128 * 1024 * 1024,
563            durability: Durability::Local,
564            shard_count: 1,
565            experimental_multi_shard: false,
566            max_group_count: 0,
567            max_group_bytes: 0,
568            max_group_delay_ms: 0,
569            log_segment_bytes: 64 * 1024 * 1024,
570            // 128 MiB holds a useful ANN/Tantivy generation set; the previous
571            // 32 MiB shared pool made ANN builds fail at a few thousand
572            // vectors at realistic dimensions.
573            logical_cache_bytes: 128 * 1024 * 1024,
574            // ~150 B per vector of a 768-d space: one space of ~1.7M vectors.
575            vector_cache_bytes: 256 * 1024 * 1024,
576            // ~32 B per relationship (both directions): about 4M
577            // relationships of one type.
578            graph_cache_bytes: 128 * 1024 * 1024,
579            // 256 MiB / 8 MiB per request = 32 concurrent phase tokens by
580            // default, instead of the previous 4 that reused the 32 MiB cache
581            // budget. The parent managed limit still bounds the total.
582            phase_budget_bytes: 256 * 1024 * 1024,
583            query_memory_max_bytes: 0,
584            unindexed_walk_max_owners: 0,
585            lock_acquisition_timeout_ms: 0,
586            // 512 MiB: generous relative to typical single-request ingest
587            // sizes, comfortably bounded relative to process memory, and an
588            // explicit product choice (not derived from the WAL's own 64 MiB
589            // per-record ceiling, which chaining already decouples this
590            // from).
591            max_expanded_commit_bytes: 512 * 1024 * 1024,
592            planner_stats_bytes: 32 * 1024 * 1024,
593            planner_stats_idle_seconds: 600,
594            planner_stats_max_age_commits: 10_000,
595            idempotency_window_seconds: 24 * 3600,
596        }
597    }
598}
599
600impl StorageResources {
601    /// Validate the configuration envelope.
602    ///
603    /// # Errors
604    /// Returns [`ResourceError::Invalid`] when any size is zero or out of
605    /// range, the cache and transaction pools do not fit with headroom in the
606    /// managed envelope, `Memory` durability is configured, or the shard count
607    /// is outside `1..=256`.
608    pub fn validate(&self) -> Result<(), ResourceError> {
609        if self.managed_limit_bytes == 0
610            || self.page_cache_bytes < 64 * 1024
611            || self.transaction_pool_bytes == 0
612            || self
613                .page_cache_bytes
614                .checked_add(self.transaction_pool_bytes)
615                .is_none_or(|total| total >= self.managed_limit_bytes)
616        {
617            return Err(ResourceError::Invalid(
618                "cache must be at least 64 KiB; positive cache/transaction pools must fit with headroom in the managed envelope",
619            ));
620        }
621        if matches!(self.durability, Durability::Memory) {
622            return Err(ResourceError::Invalid(
623                "Memory durability is for in-memory stores; use new_in_memory() instead of a configured redb cache",
624            ));
625        }
626        if self.shard_count == 0 {
627            return Err(ResourceError::Invalid("shard-count must be >= 1"));
628        }
629        if self.shard_count > 256 {
630            return Err(ResourceError::Invalid("shard-count must be <= 256"));
631        }
632        if self.shard_count > 1 && !self.experimental_multi_shard {
633            return Err(ResourceError::Invalid(
634                "shard-count > 1 is experimental: multi-shard commit groups are not crash-atomic \
635                 (independent per-shard redb transactions). Set experimental_multi_shard / \
636                 --experimental-sharding to acknowledge, or use shard-count 1",
637            ));
638        }
639        if self.log_segment_bytes < 64 * 1024 {
640            return Err(ResourceError::Invalid(
641                "log-segment-bytes must be at least 64 KiB",
642            ));
643        }
644        if self.max_expanded_commit_bytes == 0 {
645            return Err(ResourceError::Invalid(
646                "max-expanded-commit-bytes must be positive",
647            ));
648        }
649        Ok(())
650    }
651}
652
653/// Deterministic tenant → physical shard mapping. Uses a fixed-width FNV-1a
654/// over the 32-byte tenant id so routing is restart-stable and independent of
655/// the in-memory admission-shard count. Commits without a tenant route to
656/// shard 0.
657#[must_use]
658#[expect(
659    clippy::cast_possible_truncation,
660    reason = "the hash is immediately reduced modulo shard_count, so truncating its high bits cannot change the restart-stable routing"
661)]
662pub fn physical_shard_for(
663    tenant: Option<&rushdb_types::ids::TenantId>,
664    shard_count: usize,
665) -> usize {
666    debug_assert!(shard_count >= 1);
667    match tenant {
668        Some(t) => {
669            let mut h = 2_166_136_261u64;
670            for b in &t.to_be_bytes() {
671                h ^= u64::from(*b);
672                h = h.wrapping_mul(16_777_619);
673            }
674            (h as usize) % shard_count
675        }
676        None => 0,
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn child_usage_draws_from_every_ancestor_without_oversubscription() {
686        let root = ByteBudget::new("root", 100).unwrap();
687        let query = root.child("query", 80).unwrap();
688        let leaf = query.child("operator", 60).unwrap();
689        let other = root.child("index", 80).unwrap();
690        let a = leaf.try_reserve(60).unwrap();
691        let b = other.try_reserve(40).unwrap();
692        assert_eq!(
693            root.snapshot(),
694            BudgetSnapshot {
695                limit: 100,
696                used: 100,
697                peak: 100
698            }
699        );
700        assert_eq!(query.snapshot().used, 60);
701        assert_eq!(query.available(), 0);
702        assert!(matches!(
703            query.try_reserve(1),
704            Err(ResourceError::Exhausted { budget: "root", .. })
705        ));
706        assert!(leaf.try_reserve(1).is_err());
707        drop(b);
708        assert_eq!(query.available(), 20);
709        drop(a);
710        assert_eq!(root.snapshot().used, 0);
711        assert_eq!(root.snapshot().peak, 100);
712    }
713
714    #[test]
715    fn split_resize_transfer_and_failed_transfer_preserve_ownership() {
716        let root = ByteBudget::new("root", 100).unwrap();
717        let a = root.child("a", 100).unwrap();
718        let b = root.child("b", 30).unwrap();
719        let mut owned = a.try_reserve(60).unwrap();
720        let mut split = owned.split(20).unwrap();
721        assert_eq!(
722            (owned.bytes(), split.bytes(), root.snapshot().used),
723            (40, 20, 60)
724        );
725        assert!(owned.split(41).is_err());
726        assert!(owned.resize(101).is_err());
727        assert_eq!(owned.bytes(), 40);
728        assert!(owned.transfer(&b).is_err());
729        assert_eq!(
730            (a.snapshot().used, b.snapshot().used, root.snapshot().used),
731            (60, 0, 60)
732        );
733        split.transfer(&b).unwrap();
734        assert_eq!(
735            (a.snapshot().used, b.snapshot().used, root.snapshot().used),
736            (40, 20, 60)
737        );
738        split.transfer(&root).unwrap();
739        split.transfer(&a).unwrap();
740        split.transfer(&a).unwrap(); // Same-node transfer is not a double charge.
741        let independent = ByteBudget::new("independent", 100).unwrap();
742        assert_eq!(
743            split.transfer(&independent),
744            Err(ResourceError::DifferentTree)
745        );
746        assert_eq!(independent.snapshot().used, 0);
747        owned.resize(10).unwrap();
748        assert_eq!(root.snapshot().used, 30);
749        drop(owned);
750        drop(split);
751        assert_eq!(root.snapshot().used, 0);
752        assert_eq!(root.snapshot().peak, 60);
753    }
754
755    #[test]
756    fn unwind_and_last_reservation_release_child_metadata() {
757        let root = ByteBudget::new("root", 100).unwrap();
758        let child = root.child("child", 100).unwrap();
759        let weak = Arc::downgrade(&child.0);
760        let token = child.try_reserve(50).unwrap();
761        drop(child);
762        assert!(weak.upgrade().is_some());
763        let panic = std::panic::catch_unwind(move || {
764            let _token = token;
765            panic!("simulated failed work");
766        });
767        assert!(panic.is_err());
768        assert_eq!(root.snapshot().used, 0);
769        assert!(weak.upgrade().is_none());
770    }
771
772    #[test]
773    fn concurrent_siblings_never_exceed_parent_or_child_limits() {
774        let root = ByteBudget::new("root", 64).unwrap();
775        let left = root.child("left", 32).unwrap();
776        let right = root.child("right", 32).unwrap();
777        let ready = std::sync::Barrier::new(16);
778        let release = std::sync::Barrier::new(16);
779        let admitted = AtomicUsize::new(0);
780        std::thread::scope(|scope| {
781            for i in 0..16 {
782                let pool = if i % 2 == 0 { &left } else { &right };
783                let ready = &ready;
784                let release = &release;
785                let admitted = &admitted;
786                scope.spawn(move || {
787                    ready.wait();
788                    let token = pool.try_reserve(8).ok();
789                    if token.is_some() {
790                        admitted.fetch_add(1, Ordering::Relaxed);
791                    }
792                    release.wait();
793                    assert!(pool.snapshot().used <= 32);
794                    drop(token);
795                });
796            }
797        });
798        assert_eq!(admitted.load(Ordering::Relaxed), 8);
799        assert_eq!(
800            root.snapshot(),
801            BudgetSnapshot {
802                limit: 64,
803                used: 0,
804                peak: 64
805            }
806        );
807    }
808
809    #[test]
810    fn invalid_limits_and_overflow_are_rejected_without_charging() {
811        assert!(ByteBudget::new("zero", 0).is_err());
812        let root = ByteBudget::new("root", usize::MAX).unwrap();
813        let all = root.try_reserve(usize::MAX).unwrap();
814        assert!(root.try_reserve(1).is_err());
815        assert!(root.child("zero", 0).is_err());
816        drop(all);
817        assert_eq!(root.snapshot().used, 0);
818        let mut child = root;
819        for _ in 0..32 {
820            child = child.child("depth", 1).unwrap();
821        }
822        assert!(child.child("too-deep", 1).is_err());
823    }
824
825    #[test]
826    fn fair_admission_admits_when_capacity_frees_and_times_out_on_deadline() {
827        let budget = ByteBudget::new("root", 10).unwrap();
828        let gate = FairAdmission::new(budget.clone());
829        // Consume 9 of 10 bytes.
830        let held = budget.try_reserve(9).unwrap();
831        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
832
833        // A request for 5 bytes cannot be admitted now; it must queue. Give it
834        // a short deadline that expires before the holder releases.
835        let expired = now_ms() + 20;
836        let outcome = gate.acquire(5, expired, &cancel);
837        assert_eq!(
838            outcome.err(),
839            Some(AdmissionError::TimedOut),
840            "must time out on deadline"
841        );
842        assert_eq!(
843            budget.snapshot().used,
844            9,
845            "timed-out waiter charges nothing"
846        );
847
848        // Now release the holder; a fresh request admits immediately and the
849        // caller owns the reservation.
850        drop(held);
851        let reservation = gate.acquire(5, now_ms() + 1000, &cancel).unwrap();
852        assert_eq!(
853            budget.snapshot().used,
854            5,
855            "admitted caller holds the charge"
856        );
857        drop(reservation);
858        assert_eq!(budget.snapshot().used, 0, "drop releases the charge");
859    }
860
861    #[test]
862    fn fair_admission_cancelled_waiter_is_removed_without_charging() {
863        let budget = ByteBudget::new("root", 10).unwrap();
864        let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
865        let _held = budget.try_reserve(9).unwrap();
866        // The waiter shares the cancel flag; flipping it must wake and remove
867        // the queued waiter with no charge and no deadlock.
868        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
869        let gate2 = std::sync::Arc::clone(&gate);
870        let cancel2 = std::sync::Arc::clone(&cancel);
871        let handle = std::thread::spawn(move || gate2.acquire(5, now_ms() + 2000, &cancel2));
872        // Give the waiter a moment to enqueue, then cancel it.
873        std::thread::sleep(std::time::Duration::from_millis(30));
874        cancel.store(true, std::sync::atomic::Ordering::Relaxed);
875        let outcome = handle.join().unwrap();
876        assert_eq!(
877            outcome.err(),
878            Some(AdmissionError::Cancelled),
879            "waiter must observe cancellation"
880        );
881        assert_eq!(
882            budget.snapshot().used,
883            9,
884            "cancelled waiter charges nothing"
885        );
886    }
887
888    #[test]
889    fn fair_admission_fifo_and_release_wakes_next_waiter() {
890        let budget = ByteBudget::new("root", 100).unwrap();
891        let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
892        let held = budget.try_reserve(95).unwrap();
893        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
894        let cancel2 = std::sync::Arc::clone(&cancel);
895        // A thread waits for 10 bytes (capacity held). Releasing the holder
896        // must admit the waiting thread (FIFO wake).
897        let gate2 = std::sync::Arc::clone(&gate);
898        let handle = std::thread::spawn(move || gate2.acquire(10, now_ms() + 2000, &cancel2));
899        std::thread::sleep(std::time::Duration::from_millis(30));
900        drop(held);
901        let outcome = handle.join().unwrap();
902        match outcome {
903            Ok(_) => {}
904            Err(e) => panic!("release must wake the waiter: {e:?}"),
905        }
906        assert_eq!(
907            budget.snapshot().used,
908            10,
909            "waiter holds the admitted charge"
910        );
911    }
912}
913
914#[cfg(test)]
915mod shard_tests {
916    use super::*;
917    use rushdb_types::ids::TenantId;
918
919    #[test]
920    fn physical_shard_routing_is_deterministic_and_in_range() {
921        for count in [1usize, 2, 4, 8, 64] {
922            for n in 0..100u64 {
923                let t = TenantId::from_ordinal(n);
924                let s = physical_shard_for(Some(&t), count);
925                assert!(s < count);
926                assert_eq!(physical_shard_for(Some(&t), count), s);
927            }
928        }
929        assert_eq!(physical_shard_for(None, 8), 0);
930    }
931
932    #[test]
933    fn different_tenants_distribute_across_shards() {
934        let count = 4usize;
935        let mut seen = std::collections::BTreeSet::new();
936        for n in 0..256u64 {
937            let t = TenantId::from_ordinal(n);
938            seen.insert(physical_shard_for(Some(&t), count));
939        }
940        assert_eq!(seen.len(), count, "tenants should land on every shard");
941    }
942
943    #[test]
944    fn shard_count_validation() {
945        let mut resources = StorageResources::default();
946        assert!(resources.validate().is_ok());
947        resources.shard_count = 0;
948        assert!(resources.validate().is_err());
949        resources.shard_count = 257;
950        assert!(resources.validate().is_err());
951    }
952
953    #[test]
954    fn multi_shard_requires_explicit_experimental_acknowledgement() {
955        // Multi-shard commit groups are not crash-atomic, so >1 shard must be
956        // an explicit, acknowledged choice.
957        let mut resources = StorageResources {
958            shard_count: 2,
959            ..StorageResources::default()
960        };
961        assert!(
962            resources.validate().is_err(),
963            "shard_count > 1 must be refused without acknowledgement"
964        );
965        resources.experimental_multi_shard = true;
966        assert!(resources.validate().is_ok());
967    }
968}