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 reserved for concurrent request *phases* (query planning and
489    /// execution admission). Each `ExecutionContext` and each `DataFusion`
490    /// session reserves a fixed per-request token from this pool, so the pool
491    /// size sets the process-wide concurrency ceiling. Decoupled from
492    /// `logical_cache_bytes` so a small cache budget does not accidentally cap
493    /// concurrency at a handful of requests. 0 = phase admission disabled.
494    pub phase_budget_bytes: usize,
495    /// Per-query working-set cap in bytes. When non-zero, a structured query
496    /// that would materialize more than this returns `resource exhausted`
497    /// instead of ballooning RSS (config #3). 0 = off.
498    pub query_memory_max_bytes: u64,
499    /// Largest record population a filter without a usable index may check
500    ///; a query above it is refused at planning with the index to
501    /// declare. 0 = off.
502    pub unindexed_walk_max_owners: u64,
503    /// Maximum time to wait for the admission lock before failing a commit
504    /// with `resource exhausted` (retryable). 0 = wait indefinitely.
505    pub lock_acquisition_timeout_ms: u64,
506    /// Max bytes of one commit's expanded write set (encoded op-set plus the
507    /// serialized `CommitBatch` command payload) before the commit index is
508    /// reserved (large-transaction support). A single oversized
509    /// ingest/mutate request is rejected here, cleanly and without fencing
510    /// the store, instead of burning a commit index and only failing much
511    /// later when a 64 MiB-capped WAL record can't hold it. Deliberately a
512    /// generous, product-level ceiling (hundreds of MB to low GB) — the
513    /// WAL's own `log_store::MAX_RECORD_BYTES` stays fixed, and a commit
514    /// under this ceiling but over that one is transparently chained across
515    /// several physical records (see `wal::encode_chained_ops_record`).
516    /// Above this ceiling, the expectation is an external ETL/bulk-load
517    /// pipeline, not the live transactional path (matching Postgres `COPY`,
518    /// Neo4j's offline bulk importer, MongoDB's `mongoimport`).
519    pub max_expanded_commit_bytes: usize,
520    /// Byte budget of the planner-statistics cache, a child of the
521    /// logical cache budget when that is enabled. 0 = no caching (every
522    /// estimate walks the value dictionary).
523    pub planner_stats_bytes: usize,
524    /// Seconds a planner-statistics entry may sit unused before eviction.
525    pub planner_stats_idle_seconds: u64,
526    /// Commits after which a cached planner-statistics entry is recomputed.
527    pub planner_stats_max_age_commits: u64,
528    /// How long a retry receipt answers a repeated commit key. A
529    /// retry after the window executes as a new commit.
530    pub idempotency_window_seconds: u64,
531}
532
533/// Logical durability contract (research D10). `Local` is the default and
534/// guarantees the commit is durable on this node before it is acknowledged.
535/// `Async` does **not** fsync: with redb 4.x the commit is not persisted until
536/// a later `Local` commit or checkpoint, so an acknowledged `Async` commit can
537/// be lost on a crash, power loss, or hard kill. It is intended only for
538/// re-importable bulk input, not as a weaker consistency mode. `Memory` is only
539/// meaningful for in-memory stores.
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
541pub enum Durability {
542    /// In-memory only; no durable backend.
543    Memory,
544    /// Not fsynced and not persisted until a later `Local` commit or
545    /// checkpoint; an acknowledged commit can be lost on any crash. For
546    /// re-importable bulk input only.
547    Async,
548    /// Durable on this node before acknowledgement (default).
549    #[default]
550    Local,
551}
552
553impl Default for StorageResources {
554    fn default() -> Self {
555        Self {
556            managed_limit_bytes: 512 * 1024 * 1024,
557            page_cache_bytes: 128 * 1024 * 1024,
558            transaction_pool_bytes: 128 * 1024 * 1024,
559            durability: Durability::Local,
560            shard_count: 1,
561            experimental_multi_shard: false,
562            max_group_count: 0,
563            max_group_bytes: 0,
564            max_group_delay_ms: 0,
565            log_segment_bytes: 64 * 1024 * 1024,
566            // 128 MiB holds a useful ANN/Tantivy generation set; the previous
567            // 32 MiB shared pool made ANN builds fail at a few thousand
568            // vectors at realistic dimensions.
569            logical_cache_bytes: 128 * 1024 * 1024,
570            // ~150 B per vector of a 768-d space: one space of ~1.7M vectors.
571            vector_cache_bytes: 256 * 1024 * 1024,
572            // 256 MiB / 8 MiB per request = 32 concurrent phase tokens by
573            // default, instead of the previous 4 that reused the 32 MiB cache
574            // budget. The parent managed limit still bounds the total.
575            phase_budget_bytes: 256 * 1024 * 1024,
576            query_memory_max_bytes: 0,
577            unindexed_walk_max_owners: 0,
578            lock_acquisition_timeout_ms: 0,
579            // 512 MiB: generous relative to typical single-request ingest
580            // sizes, comfortably bounded relative to process memory, and an
581            // explicit product choice (not derived from the WAL's own 64 MiB
582            // per-record ceiling, which chaining already decouples this
583            // from).
584            max_expanded_commit_bytes: 512 * 1024 * 1024,
585            planner_stats_bytes: 32 * 1024 * 1024,
586            planner_stats_idle_seconds: 600,
587            planner_stats_max_age_commits: 10_000,
588            idempotency_window_seconds: 24 * 3600,
589        }
590    }
591}
592
593impl StorageResources {
594    /// Validate the configuration envelope.
595    ///
596    /// # Errors
597    /// Returns [`ResourceError::Invalid`] when any size is zero or out of
598    /// range, the cache and transaction pools do not fit with headroom in the
599    /// managed envelope, `Memory` durability is configured, or the shard count
600    /// is outside `1..=256`.
601    pub fn validate(&self) -> Result<(), ResourceError> {
602        if self.managed_limit_bytes == 0
603            || self.page_cache_bytes < 64 * 1024
604            || self.transaction_pool_bytes == 0
605            || self
606                .page_cache_bytes
607                .checked_add(self.transaction_pool_bytes)
608                .is_none_or(|total| total >= self.managed_limit_bytes)
609        {
610            return Err(ResourceError::Invalid(
611                "cache must be at least 64 KiB; positive cache/transaction pools must fit with headroom in the managed envelope",
612            ));
613        }
614        if matches!(self.durability, Durability::Memory) {
615            return Err(ResourceError::Invalid(
616                "Memory durability is for in-memory stores; use new_in_memory() instead of a configured redb cache",
617            ));
618        }
619        if self.shard_count == 0 {
620            return Err(ResourceError::Invalid("shard-count must be >= 1"));
621        }
622        if self.shard_count > 256 {
623            return Err(ResourceError::Invalid("shard-count must be <= 256"));
624        }
625        if self.shard_count > 1 && !self.experimental_multi_shard {
626            return Err(ResourceError::Invalid(
627                "shard-count > 1 is experimental: multi-shard commit groups are not crash-atomic \
628                 (independent per-shard redb transactions). Set experimental_multi_shard / \
629                 --experimental-sharding to acknowledge, or use shard-count 1",
630            ));
631        }
632        if self.log_segment_bytes < 64 * 1024 {
633            return Err(ResourceError::Invalid(
634                "log-segment-bytes must be at least 64 KiB",
635            ));
636        }
637        if self.max_expanded_commit_bytes == 0 {
638            return Err(ResourceError::Invalid(
639                "max-expanded-commit-bytes must be positive",
640            ));
641        }
642        Ok(())
643    }
644}
645
646/// Deterministic tenant → physical shard mapping. Uses a fixed-width FNV-1a
647/// over the 32-byte tenant id so routing is restart-stable and independent of
648/// the in-memory admission-shard count. Commits without a tenant route to
649/// shard 0.
650#[must_use]
651#[expect(
652    clippy::cast_possible_truncation,
653    reason = "the hash is immediately reduced modulo shard_count, so truncating its high bits cannot change the restart-stable routing"
654)]
655pub fn physical_shard_for(
656    tenant: Option<&rushdb_types::ids::TenantId>,
657    shard_count: usize,
658) -> usize {
659    debug_assert!(shard_count >= 1);
660    match tenant {
661        Some(t) => {
662            let mut h = 2_166_136_261u64;
663            for b in &t.to_be_bytes() {
664                h ^= u64::from(*b);
665                h = h.wrapping_mul(16_777_619);
666            }
667            (h as usize) % shard_count
668        }
669        None => 0,
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn child_usage_draws_from_every_ancestor_without_oversubscription() {
679        let root = ByteBudget::new("root", 100).unwrap();
680        let query = root.child("query", 80).unwrap();
681        let leaf = query.child("operator", 60).unwrap();
682        let other = root.child("index", 80).unwrap();
683        let a = leaf.try_reserve(60).unwrap();
684        let b = other.try_reserve(40).unwrap();
685        assert_eq!(
686            root.snapshot(),
687            BudgetSnapshot {
688                limit: 100,
689                used: 100,
690                peak: 100
691            }
692        );
693        assert_eq!(query.snapshot().used, 60);
694        assert_eq!(query.available(), 0);
695        assert!(matches!(
696            query.try_reserve(1),
697            Err(ResourceError::Exhausted { budget: "root", .. })
698        ));
699        assert!(leaf.try_reserve(1).is_err());
700        drop(b);
701        assert_eq!(query.available(), 20);
702        drop(a);
703        assert_eq!(root.snapshot().used, 0);
704        assert_eq!(root.snapshot().peak, 100);
705    }
706
707    #[test]
708    fn split_resize_transfer_and_failed_transfer_preserve_ownership() {
709        let root = ByteBudget::new("root", 100).unwrap();
710        let a = root.child("a", 100).unwrap();
711        let b = root.child("b", 30).unwrap();
712        let mut owned = a.try_reserve(60).unwrap();
713        let mut split = owned.split(20).unwrap();
714        assert_eq!(
715            (owned.bytes(), split.bytes(), root.snapshot().used),
716            (40, 20, 60)
717        );
718        assert!(owned.split(41).is_err());
719        assert!(owned.resize(101).is_err());
720        assert_eq!(owned.bytes(), 40);
721        assert!(owned.transfer(&b).is_err());
722        assert_eq!(
723            (a.snapshot().used, b.snapshot().used, root.snapshot().used),
724            (60, 0, 60)
725        );
726        split.transfer(&b).unwrap();
727        assert_eq!(
728            (a.snapshot().used, b.snapshot().used, root.snapshot().used),
729            (40, 20, 60)
730        );
731        split.transfer(&root).unwrap();
732        split.transfer(&a).unwrap();
733        split.transfer(&a).unwrap(); // Same-node transfer is not a double charge.
734        let independent = ByteBudget::new("independent", 100).unwrap();
735        assert_eq!(
736            split.transfer(&independent),
737            Err(ResourceError::DifferentTree)
738        );
739        assert_eq!(independent.snapshot().used, 0);
740        owned.resize(10).unwrap();
741        assert_eq!(root.snapshot().used, 30);
742        drop(owned);
743        drop(split);
744        assert_eq!(root.snapshot().used, 0);
745        assert_eq!(root.snapshot().peak, 60);
746    }
747
748    #[test]
749    fn unwind_and_last_reservation_release_child_metadata() {
750        let root = ByteBudget::new("root", 100).unwrap();
751        let child = root.child("child", 100).unwrap();
752        let weak = Arc::downgrade(&child.0);
753        let token = child.try_reserve(50).unwrap();
754        drop(child);
755        assert!(weak.upgrade().is_some());
756        let panic = std::panic::catch_unwind(move || {
757            let _token = token;
758            panic!("simulated failed work");
759        });
760        assert!(panic.is_err());
761        assert_eq!(root.snapshot().used, 0);
762        assert!(weak.upgrade().is_none());
763    }
764
765    #[test]
766    fn concurrent_siblings_never_exceed_parent_or_child_limits() {
767        let root = ByteBudget::new("root", 64).unwrap();
768        let left = root.child("left", 32).unwrap();
769        let right = root.child("right", 32).unwrap();
770        let ready = std::sync::Barrier::new(16);
771        let release = std::sync::Barrier::new(16);
772        let admitted = AtomicUsize::new(0);
773        std::thread::scope(|scope| {
774            for i in 0..16 {
775                let pool = if i % 2 == 0 { &left } else { &right };
776                let ready = &ready;
777                let release = &release;
778                let admitted = &admitted;
779                scope.spawn(move || {
780                    ready.wait();
781                    let token = pool.try_reserve(8).ok();
782                    if token.is_some() {
783                        admitted.fetch_add(1, Ordering::Relaxed);
784                    }
785                    release.wait();
786                    assert!(pool.snapshot().used <= 32);
787                    drop(token);
788                });
789            }
790        });
791        assert_eq!(admitted.load(Ordering::Relaxed), 8);
792        assert_eq!(
793            root.snapshot(),
794            BudgetSnapshot {
795                limit: 64,
796                used: 0,
797                peak: 64
798            }
799        );
800    }
801
802    #[test]
803    fn invalid_limits_and_overflow_are_rejected_without_charging() {
804        assert!(ByteBudget::new("zero", 0).is_err());
805        let root = ByteBudget::new("root", usize::MAX).unwrap();
806        let all = root.try_reserve(usize::MAX).unwrap();
807        assert!(root.try_reserve(1).is_err());
808        assert!(root.child("zero", 0).is_err());
809        drop(all);
810        assert_eq!(root.snapshot().used, 0);
811        let mut child = root;
812        for _ in 0..32 {
813            child = child.child("depth", 1).unwrap();
814        }
815        assert!(child.child("too-deep", 1).is_err());
816    }
817
818    #[test]
819    fn fair_admission_admits_when_capacity_frees_and_times_out_on_deadline() {
820        let budget = ByteBudget::new("root", 10).unwrap();
821        let gate = FairAdmission::new(budget.clone());
822        // Consume 9 of 10 bytes.
823        let held = budget.try_reserve(9).unwrap();
824        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
825
826        // A request for 5 bytes cannot be admitted now; it must queue. Give it
827        // a short deadline that expires before the holder releases.
828        let expired = now_ms() + 20;
829        let outcome = gate.acquire(5, expired, &cancel);
830        assert_eq!(
831            outcome.err(),
832            Some(AdmissionError::TimedOut),
833            "must time out on deadline"
834        );
835        assert_eq!(
836            budget.snapshot().used,
837            9,
838            "timed-out waiter charges nothing"
839        );
840
841        // Now release the holder; a fresh request admits immediately and the
842        // caller owns the reservation.
843        drop(held);
844        let reservation = gate.acquire(5, now_ms() + 1000, &cancel).unwrap();
845        assert_eq!(
846            budget.snapshot().used,
847            5,
848            "admitted caller holds the charge"
849        );
850        drop(reservation);
851        assert_eq!(budget.snapshot().used, 0, "drop releases the charge");
852    }
853
854    #[test]
855    fn fair_admission_cancelled_waiter_is_removed_without_charging() {
856        let budget = ByteBudget::new("root", 10).unwrap();
857        let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
858        let _held = budget.try_reserve(9).unwrap();
859        // The waiter shares the cancel flag; flipping it must wake and remove
860        // the queued waiter with no charge and no deadlock.
861        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
862        let gate2 = std::sync::Arc::clone(&gate);
863        let cancel2 = std::sync::Arc::clone(&cancel);
864        let handle = std::thread::spawn(move || gate2.acquire(5, now_ms() + 2000, &cancel2));
865        // Give the waiter a moment to enqueue, then cancel it.
866        std::thread::sleep(std::time::Duration::from_millis(30));
867        cancel.store(true, std::sync::atomic::Ordering::Relaxed);
868        let outcome = handle.join().unwrap();
869        assert_eq!(
870            outcome.err(),
871            Some(AdmissionError::Cancelled),
872            "waiter must observe cancellation"
873        );
874        assert_eq!(
875            budget.snapshot().used,
876            9,
877            "cancelled waiter charges nothing"
878        );
879    }
880
881    #[test]
882    fn fair_admission_fifo_and_release_wakes_next_waiter() {
883        let budget = ByteBudget::new("root", 100).unwrap();
884        let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
885        let held = budget.try_reserve(95).unwrap();
886        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
887        let cancel2 = std::sync::Arc::clone(&cancel);
888        // A thread waits for 10 bytes (capacity held). Releasing the holder
889        // must admit the waiting thread (FIFO wake).
890        let gate2 = std::sync::Arc::clone(&gate);
891        let handle = std::thread::spawn(move || gate2.acquire(10, now_ms() + 2000, &cancel2));
892        std::thread::sleep(std::time::Duration::from_millis(30));
893        drop(held);
894        let outcome = handle.join().unwrap();
895        match outcome {
896            Ok(_) => {}
897            Err(e) => panic!("release must wake the waiter: {e:?}"),
898        }
899        assert_eq!(
900            budget.snapshot().used,
901            10,
902            "waiter holds the admitted charge"
903        );
904    }
905}
906
907#[cfg(test)]
908mod shard_tests {
909    use super::*;
910    use rushdb_types::ids::TenantId;
911
912    #[test]
913    fn physical_shard_routing_is_deterministic_and_in_range() {
914        for count in [1usize, 2, 4, 8, 64] {
915            for n in 0..100u64 {
916                let t = TenantId::from_ordinal(n);
917                let s = physical_shard_for(Some(&t), count);
918                assert!(s < count);
919                assert_eq!(physical_shard_for(Some(&t), count), s);
920            }
921        }
922        assert_eq!(physical_shard_for(None, 8), 0);
923    }
924
925    #[test]
926    fn different_tenants_distribute_across_shards() {
927        let count = 4usize;
928        let mut seen = std::collections::BTreeSet::new();
929        for n in 0..256u64 {
930            let t = TenantId::from_ordinal(n);
931            seen.insert(physical_shard_for(Some(&t), count));
932        }
933        assert_eq!(seen.len(), count, "tenants should land on every shard");
934    }
935
936    #[test]
937    fn shard_count_validation() {
938        let mut resources = StorageResources::default();
939        assert!(resources.validate().is_ok());
940        resources.shard_count = 0;
941        assert!(resources.validate().is_err());
942        resources.shard_count = 257;
943        assert!(resources.validate().is_err());
944    }
945
946    #[test]
947    fn multi_shard_requires_explicit_experimental_acknowledgement() {
948        // Multi-shard commit groups are not crash-atomic, so >1 shard must be
949        // an explicit, acknowledged choice.
950        let mut resources = StorageResources {
951            shard_count: 2,
952            ..StorageResources::default()
953        };
954        assert!(
955            resources.validate().is_err(),
956            "shard_count > 1 must be refused without acknowledgement"
957        );
958        resources.experimental_multi_shard = true;
959        assert!(resources.validate().is_ok());
960    }
961}