Skip to main content

plecto_host/
backend.rs

1//! Host-held state backend (ADR 000004 / 000011).
2//!
3//! A stateless filter (Fork 4) keeps its *mutable* business state here: raw KV bytes,
4//! atomic counters, and token-bucket rate limiters. `KvBackend` is the **seam** ADR
5//! 000011 asks for: the host-API impls and the lifecycle never name a concrete store,
6//! so swapping in-memory ↔ redb is local, and when wasmtime 46 makes host calls async
7//! only the redb impl moves behind a blocking pool — callers stay put.
8//!
9//! Sync today (wasmtime 45 sync path). Locks are **non-poisoning** (`parking_lot`): a
10//! panicking filter must not cascade a poisoned lock across every later request.
11//!
12//! Keys arrive already namespaced by filter identity + primitive tag (done in
13//! `HostState`, ADR 000011); a backend treats them as opaque bytes and never inspects
14//! the namespace.
15
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use parking_lot::Mutex;
20use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
21
22/// A token-bucket specification (mirrors the WIT `host-ratelimit.bucket`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Bucket {
25    pub capacity: u64,
26    pub refill_tokens: u64,
27    pub refill_interval_ms: u64,
28}
29
30/// The outcome of a token-bucket acquire (mirrors the WIT `host-ratelimit.acquire`).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Acquire {
33    pub allowed: bool,
34    pub remaining: u64,
35    pub retry_after_ms: u64,
36}
37
38/// The place a stateless filter's mutable state lives. Object-safe so the host can hold
39/// `Arc<dyn KvBackend>` and pick the backend at construction. Every method is internally
40/// synchronized and infallible from the filter's view — a backend error is logged and
41/// resolved **fail-closed** (reads vanish, rate limits deny), never a panic on the data
42/// plane (bp-rust).
43pub trait KvBackend: Send + Sync {
44    fn get(&self, key: &[u8]) -> Option<Vec<u8>>;
45    fn set(&self, key: &[u8], value: Vec<u8>);
46    fn delete(&self, key: &[u8]);
47    /// Atomic add-and-get. An unset counter starts at 0; `delta` is signed.
48    fn increment(&self, key: &[u8], delta: i64) -> i64;
49    /// Atomic token-bucket acquire against the `now_ms` request-clock snapshot. The
50    /// refill + counting stay host-native (ADR 000005) — they never cross the WASM
51    /// boundary; the filter only decided to consult the limiter.
52    fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire;
53}
54
55// --- pure token-bucket math (host-native, deterministic against `now_ms`) ---
56
57/// Refill then consume. State is `(tokens, last_refill_ms)`; the host advances `last`
58/// by whole intervals only, so no fractional tokens are lost between calls. Returns the
59/// new state to persist and the acquire outcome.
60///
61/// Pure and storage-agnostic: it owns no state and does no I/O, so the same math drives both
62/// the per-filter `host-ratelimit` capability (this module's backends) and the fast path's
63/// native per-route rate limiter (ADR 000033, `plecto-control`). A zero state `(0, 0)` refills
64/// from epoch and therefore reads as a full bucket on first use — a caller backing the state
65/// with a zero-initialised table needs no separate "first sight" sentinel.
66pub fn apply_bucket(
67    state: Option<(u64, u64)>,
68    cost: u64,
69    spec: Bucket,
70    now_ms: u64,
71) -> ((u64, u64), Acquire) {
72    let no_refill = spec.refill_interval_ms == 0 || spec.refill_tokens == 0;
73    let (tokens, last_refill) = match state {
74        // first sight of this bucket: start full as of now
75        None => (spec.capacity, now_ms),
76        Some((tokens, last)) if no_refill => (tokens.min(spec.capacity), last),
77        Some((tokens, last)) => {
78            let intervals = now_ms.saturating_sub(last) / spec.refill_interval_ms;
79            let refilled = tokens
80                .saturating_add(intervals.saturating_mul(spec.refill_tokens))
81                .min(spec.capacity);
82            let advanced = last.saturating_add(intervals.saturating_mul(spec.refill_interval_ms));
83            (refilled, advanced)
84        }
85    };
86
87    if tokens >= cost {
88        let remaining = tokens - cost;
89        (
90            (remaining, last_refill),
91            Acquire {
92                allowed: true,
93                remaining,
94                retry_after_ms: 0,
95            },
96        )
97    } else {
98        // Retry-After is a deliberate over-estimate: it counts whole refill intervals and
99        // ignores the fraction of the current interval already elapsed, so it can be late by up
100        // to one interval. That is the conservative side for an advisory hint — it never invites
101        // a retry that is too early. Tightening it would mean persisting sub-interval phase,
102        // not worth it for an advisory value.
103        let retry_after_ms = if no_refill {
104            u64::MAX
105        } else {
106            let needed = cost - tokens;
107            needed
108                .div_ceil(spec.refill_tokens)
109                .saturating_mul(spec.refill_interval_ms)
110        };
111        (
112            (tokens, last_refill),
113            Acquire {
114                allowed: false,
115                remaining: tokens,
116                retry_after_ms,
117            },
118        )
119    }
120}
121
122/// Decode a stored counter (tolerant of short/empty values: missing == 0).
123#[allow(clippy::indexing_slicing)] // n = bytes.len().min(8), so buf[..n]/bytes[..n] are always in-bounds
124fn decode_i64(bytes: &[u8]) -> i64 {
125    let mut buf = [0u8; 8];
126    let n = bytes.len().min(8);
127    buf[..n].copy_from_slice(&bytes[..n]);
128    i64::from_le_bytes(buf)
129}
130
131/// Decode bucket state `(tokens, last_refill_ms)` from 16 LE bytes (None if malformed).
132#[allow(clippy::indexing_slicing)] // length is checked (== 16) three lines above
133fn decode_bucket(bytes: &[u8]) -> Option<(u64, u64)> {
134    if bytes.len() != 16 {
135        return None;
136    }
137    let tokens = u64::from_le_bytes(bytes[0..8].try_into().ok()?);
138    let last = u64::from_le_bytes(bytes[8..16].try_into().ok()?);
139    Some((tokens, last))
140}
141
142fn encode_bucket(state: (u64, u64)) -> Vec<u8> {
143    let mut out = Vec::with_capacity(16);
144    out.extend_from_slice(&state.0.to_le_bytes());
145    out.extend_from_slice(&state.1.to_le_bytes());
146    out
147}
148
149/// Map stored bucket bytes to the state `apply_bucket` consumes, **fail-closed on corruption**.
150/// `None` (key absent) is a legitimate first sight → start full. Present-but-malformed bytes must
151/// NOT decode to a full bucket (that is fail-OPEN, inconsistent with the limiter's fail-closed
152/// stance); treat corruption as an empty bucket so the call is denied and the limiter self-heals
153/// via refill.
154fn bucket_input(raw: Option<&[u8]>, now_ms: u64) -> Option<(u64, u64)> {
155    raw.map(|bytes| decode_bucket(bytes).unwrap_or((0, now_ms)))
156}
157
158// --- in-memory backend (default; tests and single-process runs) ---
159
160/// Process-lifetime, in-memory backend. The default until a durable store is configured.
161#[derive(Default)]
162pub struct MemoryBackend {
163    map: Mutex<HashMap<Vec<u8>, Vec<u8>>>,
164}
165
166impl KvBackend for MemoryBackend {
167    fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
168        self.map.lock().get(key).cloned()
169    }
170
171    fn set(&self, key: &[u8], value: Vec<u8>) {
172        self.map.lock().insert(key.to_vec(), value);
173    }
174
175    fn delete(&self, key: &[u8]) {
176        self.map.lock().remove(key);
177    }
178
179    fn increment(&self, key: &[u8], delta: i64) -> i64 {
180        let mut map = self.map.lock();
181        let cur = decode_i64(map.get(key).map(Vec::as_slice).unwrap_or(&[]));
182        // delta == 0 is a counter read (host-counter.get): return the current value without
183        // creating or rewriting the key (mirrors the redb read-txn branch).
184        if delta == 0 {
185            return cur;
186        }
187        let next = cur.saturating_add(delta);
188        map.insert(key.to_vec(), next.to_le_bytes().to_vec());
189        next
190    }
191
192    fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire {
193        let mut map = self.map.lock();
194        let prev = bucket_input(map.get(key).map(Vec::as_slice), now_ms);
195        let (next, result) = apply_bucket(prev, cost, spec, now_ms);
196        map.insert(key.to_vec(), encode_bucket(next));
197        result
198    }
199}
200
201// --- redb backend (durable; ADR 000004) ---
202
203const STATE_TABLE: TableDefinition<'_, &[u8], &[u8]> = TableDefinition::new("plecto_state");
204
205/// Cap on redb's lazily-filled page cache. The library default (1 GiB) is sized for a
206/// process that IS the database; embedded stores conventionally cap far lower (SQLite ~2 MiB,
207/// RocksDB 32 MiB, Kafka Streams 50 MiB per store). The cache only evicts at the cap, so a
208/// long-lived proxy would otherwise grow toward the full gigabyte; 64 MiB still dwarfs the
209/// working set of 8–16-byte counters and bucket states.
210const REDB_CACHE_BYTES: usize = 64 << 20;
211
212/// Every `DURABLE_FLUSH_EVERY`th hot-path commit is upgraded from `Durability::None` to
213/// `Immediate`. redb frees pages only at a durable commit, so an unbounded None-only run
214/// (a counter/ratelimit-heavy workload with no kv writes) would grow the file without bound.
215/// 1024 amortises the fsync to noise on the hot path while bounding both the file growth
216/// between durable commits and the window of updates a crash can lose.
217const DURABLE_FLUSH_EVERY: u64 = 1024;
218
219/// redb-backed durable state. Atomicity comes from redb's single-writer write
220/// transaction: each `increment` / `try_acquire` does its read-modify-write inside one
221/// transaction (ADR 000004). redb is fully synchronous; ADR 000011's async-aware seam
222/// is this `KvBackend` impl — when host calls go async, the commits move to a blocking
223/// pool here without touching callers.
224pub struct RedbBackend {
225    db: Database,
226    /// Hot-path (non-durable) commits since the last durable one. Only touched while
227    /// holding redb's single-writer transaction, so relaxed atomics suffice.
228    non_durable_run: AtomicU64,
229    flush_every: u64,
230    /// Durable commits forced by the cadence (observability for the flush tests).
231    forced_flushes: AtomicU64,
232}
233
234impl RedbBackend {
235    /// Open (or create) the redb database at `path`.
236    pub fn open(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
237        Self::open_inner(path, DURABLE_FLUSH_EVERY)
238    }
239
240    #[cfg(test)]
241    fn open_with_flush_every(
242        path: impl AsRef<std::path::Path>,
243        flush_every: u64,
244    ) -> anyhow::Result<Self> {
245        Self::open_inner(path, flush_every)
246    }
247
248    fn open_inner(path: impl AsRef<std::path::Path>, flush_every: u64) -> anyhow::Result<Self> {
249        let db = redb::Builder::new()
250            .set_cache_size(REDB_CACHE_BYTES)
251            .create(path)?;
252        Ok(Self {
253            db,
254            non_durable_run: AtomicU64::new(0),
255            flush_every,
256            forced_flushes: AtomicU64::new(0),
257        })
258    }
259
260    #[cfg(test)]
261    fn forced_flushes(&self) -> u64 {
262        self.forced_flushes.load(Ordering::Relaxed)
263    }
264
265    /// The durability of the next hot-path commit: `None` (skip the per-commit fsync) until
266    /// the run reaches `flush_every`, then one `Immediate` commit — which also makes every
267    /// earlier commit in the run durable and lets redb free their copied-on-write pages.
268    /// Called while holding the single-writer write transaction, so the run is exact.
269    fn hot_path_durability(&self) -> redb::Durability {
270        let run = self.non_durable_run.fetch_add(1, Ordering::Relaxed) + 1;
271        if run >= self.flush_every {
272            self.non_durable_run.store(0, Ordering::Relaxed);
273            self.forced_flushes.fetch_add(1, Ordering::Relaxed);
274            redb::Durability::Immediate
275        } else {
276            redb::Durability::None
277        }
278    }
279
280    fn get_inner(&self, key: &[u8]) -> anyhow::Result<Option<Vec<u8>>> {
281        let rtxn = self.db.begin_read()?;
282        let table = match rtxn.open_table(STATE_TABLE) {
283            Ok(t) => t,
284            // no writer has created the table yet → empty
285            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
286            Err(e) => return Err(e.into()),
287        };
288        Ok(table.get(key)?.map(|g| g.value().to_vec()))
289    }
290
291    fn set_inner(&self, key: &[u8], value: &[u8]) -> anyhow::Result<()> {
292        let wtxn = self.db.begin_write()?;
293        {
294            let mut table = wtxn.open_table(STATE_TABLE)?;
295            table.insert(key, value)?;
296        }
297        wtxn.commit()?;
298        // This commit was durable (the `Immediate` default), so every earlier hot-path commit
299        // is durable too — the flush cadence restarts.
300        self.non_durable_run.store(0, Ordering::Relaxed);
301        Ok(())
302    }
303
304    fn delete_inner(&self, key: &[u8]) -> anyhow::Result<()> {
305        let wtxn = self.db.begin_write()?;
306        {
307            let mut table = wtxn.open_table(STATE_TABLE)?;
308            table.remove(key)?;
309        }
310        wtxn.commit()?;
311        // Durable commit (see `set_inner`): the flush cadence restarts.
312        self.non_durable_run.store(0, Ordering::Relaxed);
313        Ok(())
314    }
315
316    fn increment_inner(&self, key: &[u8], delta: i64) -> anyhow::Result<i64> {
317        let mut wtxn = self.db.begin_write()?;
318        // Counters are ephemeral hot-path state (ADR 000005): a crash losing the last few
319        // increments is harmless, so skip the per-commit fsync of the default
320        // `Durability::Immediate` — except every `flush_every`th commit, which stays durable
321        // so redb can free the run's pages (`hot_path_durability`). Atomicity is unaffected —
322        // the read-modify-write is still one write txn. Durable KV (`set` / `delete`) keeps
323        // `Immediate`. set_durability only errors if a persistent savepoint changed in this
324        // txn; we never use savepoints.
325        wtxn.set_durability(self.hot_path_durability())?;
326        let next = {
327            let mut table = wtxn.open_table(STATE_TABLE)?;
328            let cur = table.get(key)?.map(|g| decode_i64(g.value())).unwrap_or(0);
329            let next = cur.saturating_add(delta);
330            table.insert(key, next.to_le_bytes().as_slice())?;
331            next
332        };
333        wtxn.commit()?;
334        Ok(next)
335    }
336
337    fn try_acquire_inner(
338        &self,
339        key: &[u8],
340        cost: u64,
341        spec: Bucket,
342        now_ms: u64,
343    ) -> anyhow::Result<Acquire> {
344        let mut wtxn = self.db.begin_write()?;
345        // Rate-limit buckets are ephemeral hot-path state (ADR 000005): same reasoning as
346        // counters — skip the per-commit fsync, durable every `flush_every`th commit;
347        // atomicity stays (one write txn).
348        wtxn.set_durability(self.hot_path_durability())?;
349        let result = {
350            let mut table = wtxn.open_table(STATE_TABLE)?;
351            let prev = {
352                let guard = table.get(key)?;
353                bucket_input(guard.as_ref().map(|g| g.value()), now_ms)
354            };
355            let (next, result) = apply_bucket(prev, cost, spec, now_ms);
356            table.insert(key, encode_bucket(next).as_slice())?;
357            result
358        };
359        wtxn.commit()?;
360        Ok(result)
361    }
362}
363
364impl KvBackend for RedbBackend {
365    fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
366        match self.get_inner(key) {
367            Ok(v) => v,
368            Err(e) => {
369                tracing::error!(error = %e, "redb get failed; treating key as absent");
370                None
371            }
372        }
373    }
374
375    fn set(&self, key: &[u8], value: Vec<u8>) {
376        if let Err(e) = self.set_inner(key, &value) {
377            tracing::error!(error = %e, "redb set failed; value dropped");
378        }
379    }
380
381    fn delete(&self, key: &[u8]) {
382        if let Err(e) = self.delete_inner(key) {
383            tracing::error!(error = %e, "redb delete failed");
384        }
385    }
386
387    fn increment(&self, key: &[u8], delta: i64) -> i64 {
388        // delta == 0 is the canonical counter READ (host-counter.get). Serve it from a read txn
389        // so it never joins the single-writer queue or pays a commit (ADR 000004 / 000005 hot
390        // path). Only a real mutation takes the write path.
391        let r = if delta == 0 {
392            self.get_inner(key)
393                .map(|opt| decode_i64(opt.as_deref().unwrap_or_default()))
394        } else {
395            self.increment_inner(key, delta)
396        };
397        match r {
398            Ok(v) => v,
399            Err(e) => {
400                tracing::error!(error = %e, "redb increment failed; returning 0");
401                0
402            }
403        }
404    }
405
406    fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire {
407        match self.try_acquire_inner(key, cost, spec, now_ms) {
408            Ok(r) => r,
409            Err(e) => {
410                // fail-closed: a limiter that cannot read its state denies (ADR 000004).
411                tracing::error!(error = %e, "redb try_acquire failed; denying");
412                Acquire {
413                    allowed: false,
414                    remaining: 0,
415                    retry_after_ms: spec.refill_interval_ms,
416                }
417            }
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    /// The same behaviour suite must hold for every backend (the seam, ADR 000011).
427    fn kv_roundtrip(backend: &dyn KvBackend) {
428        assert_eq!(backend.get(b"k"), None);
429        backend.set(b"k", b"v".to_vec());
430        assert_eq!(backend.get(b"k"), Some(b"v".to_vec()));
431        backend.delete(b"k");
432        assert_eq!(backend.get(b"k"), None);
433    }
434
435    fn counter_is_atomic_add_and_get(backend: &dyn KvBackend) {
436        assert_eq!(backend.increment(b"c", 1), 1);
437        assert_eq!(backend.increment(b"c", 4), 5);
438        assert_eq!(backend.increment(b"c", -2), 3);
439        // get reads the same encoding the counter wrote
440        assert_eq!(decode_i64(&backend.get(b"c").unwrap()), 3);
441    }
442
443    fn token_bucket_drains_then_refills(backend: &dyn KvBackend) {
444        let spec = Bucket {
445            capacity: 2,
446            refill_tokens: 1,
447            refill_interval_ms: 1000,
448        };
449        // capacity 2 → two acquires allowed, third denied (no time passed)
450        assert!(backend.try_acquire(b"rl", 1, spec, 0).allowed);
451        assert!(backend.try_acquire(b"rl", 1, spec, 0).allowed);
452        let denied = backend.try_acquire(b"rl", 1, spec, 0);
453        assert!(!denied.allowed);
454        assert_eq!(denied.remaining, 0);
455        assert_eq!(denied.retry_after_ms, 1000, "1 token needs one interval");
456        // after one interval, one token refills → allowed again
457        assert!(backend.try_acquire(b"rl", 1, spec, 1000).allowed);
458    }
459
460    fn counter_read_via_zero_delta_does_not_create_key(backend: &dyn KvBackend) {
461        // host-counter.get maps to increment(key, 0); a pure read must not create the key — it
462        // takes the redb read-txn / memory read branch, off the single-writer write path.
463        assert_eq!(backend.increment(b"zc", 0), 0, "unset counter reads as 0");
464        assert_eq!(
465            backend.get(b"zc"),
466            None,
467            "a zero-delta read must not create the counter key"
468        );
469        assert_eq!(backend.increment(b"zc", 5), 5);
470        assert_eq!(
471            backend.increment(b"zc", 0),
472            5,
473            "zero-delta still reads the live value"
474        );
475    }
476
477    fn token_bucket_corrupt_state_fails_closed(backend: &dyn KvBackend) {
478        // A malformed stored bucket must DENY (fail-closed), never reset to full (fail-open).
479        let spec = Bucket {
480            capacity: 5,
481            refill_tokens: 1,
482            refill_interval_ms: 1000,
483        };
484        backend.set(b"cb", vec![0xff; 3]); // not 16 bytes → corrupt
485        assert!(
486            !backend.try_acquire(b"cb", 1, spec, 0).allowed,
487            "corrupt bucket must fail closed, not start full"
488        );
489        // and it self-heals: after one interval a refilled token is granted
490        assert!(backend.try_acquire(b"cb", 1, spec, 1000).allowed);
491    }
492
493    #[test]
494    fn memory_backend_behaviour() {
495        let b = MemoryBackend::default();
496        kv_roundtrip(&b);
497        counter_is_atomic_add_and_get(&b);
498        counter_read_via_zero_delta_does_not_create_key(&b);
499        token_bucket_drains_then_refills(&b);
500        token_bucket_corrupt_state_fails_closed(&b);
501    }
502
503    #[test]
504    fn redb_backend_behaviour() {
505        let dir = tempfile::tempdir().unwrap();
506        let b = RedbBackend::open(dir.path().join("state.redb")).unwrap();
507        kv_roundtrip(&b);
508        counter_is_atomic_add_and_get(&b);
509        counter_read_via_zero_delta_does_not_create_key(&b);
510        token_bucket_drains_then_refills(&b);
511        token_bucket_corrupt_state_fails_closed(&b);
512    }
513
514    #[test]
515    fn redb_periodic_durable_flush_caps_a_non_durable_run() {
516        // Hot-path commits (increment / try_acquire) skip the per-commit fsync
517        // (Durability::None), but redb frees pages only at a durable commit — an unbounded
518        // None-only run (a counter/ratelimit-heavy workload with no kv writes) would grow the
519        // file without bound. Every `flush_every`th hot-path commit must be durable.
520        let dir = tempfile::tempdir().unwrap();
521        let b = RedbBackend::open_with_flush_every(dir.path().join("state.redb"), 4).unwrap();
522
523        let spec = Bucket {
524            capacity: 1000,
525            refill_tokens: 0,
526            refill_interval_ms: 0,
527        };
528        b.increment(b"c", 1);
529        b.increment(b"c", 1);
530        b.increment(b"c", 0); // a read (delta 0) commits nothing and must not advance the run
531        b.try_acquire(b"rl", 1, spec, 0);
532        assert_eq!(
533            b.forced_flushes(),
534            0,
535            "three hot-path commits stay non-durable"
536        );
537
538        b.try_acquire(b"rl", 1, spec, 0);
539        assert_eq!(
540            b.forced_flushes(),
541            1,
542            "the 4th commit in a run is upgraded to durable"
543        );
544
545        for _ in 0..4 {
546            b.increment(b"c", 1);
547        }
548        assert_eq!(b.forced_flushes(), 2, "the cadence repeats");
549    }
550
551    #[test]
552    fn redb_durable_kv_write_resets_the_flush_cadence() {
553        // set/delete commit with Durability::Immediate, which already makes every earlier
554        // non-durable commit durable (and frees its pages) — the flush cadence restarts
555        // instead of forcing a redundant flush shortly after.
556        let dir = tempfile::tempdir().unwrap();
557        let b = RedbBackend::open_with_flush_every(dir.path().join("state.redb"), 4).unwrap();
558
559        b.increment(b"c", 1);
560        b.increment(b"c", 1);
561        b.increment(b"c", 1);
562        b.set(b"k", b"v".to_vec()); // durable; the run restarts
563        b.increment(b"c", 1);
564        b.increment(b"c", 1);
565        b.increment(b"c", 1);
566        assert_eq!(
567            b.forced_flushes(),
568            0,
569            "a durable set resets the non-durable run"
570        );
571        b.increment(b"c", 1);
572        assert_eq!(b.forced_flushes(), 1);
573    }
574
575    #[test]
576    fn redb_persists_across_reopen() {
577        let dir = tempfile::tempdir().unwrap();
578        let path = dir.path().join("state.redb");
579        {
580            let b = RedbBackend::open(&path).unwrap();
581            assert_eq!(b.increment(b"hits", 3), 3);
582        }
583        // reopening the same file recovers durable state (ADR 000004 durability)
584        let b = RedbBackend::open(&path).unwrap();
585        assert_eq!(b.increment(b"hits", 1), 4);
586    }
587
588    #[test]
589    fn token_bucket_cost_zero_always_allowed() {
590        let spec = Bucket {
591            capacity: 1,
592            refill_tokens: 1,
593            refill_interval_ms: 1000,
594        };
595        let (_state, r) = apply_bucket(Some((0, 0)), 0, spec, 0);
596        assert!(r.allowed, "a zero-cost acquire never blocks");
597    }
598}