use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use parking_lot::Mutex;
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bucket {
pub capacity: u64,
pub refill_tokens: u64,
pub refill_interval_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Acquire {
pub allowed: bool,
pub remaining: u64,
pub retry_after_ms: u64,
}
pub trait KvBackend: Send + Sync {
fn get(&self, key: &[u8]) -> Option<Vec<u8>>;
fn set(&self, key: &[u8], value: Vec<u8>);
fn delete(&self, key: &[u8]);
fn increment(&self, key: &[u8], delta: i64) -> i64;
fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire;
}
pub fn apply_bucket(
state: Option<(u64, u64)>,
cost: u64,
spec: Bucket,
now_ms: u64,
) -> ((u64, u64), Acquire) {
let no_refill = spec.refill_interval_ms == 0 || spec.refill_tokens == 0;
let (tokens, last_refill) = match state {
None => (spec.capacity, now_ms),
Some((tokens, last)) if no_refill => (tokens.min(spec.capacity), last),
Some((tokens, last)) => {
let intervals = now_ms.saturating_sub(last) / spec.refill_interval_ms;
let refilled = tokens
.saturating_add(intervals.saturating_mul(spec.refill_tokens))
.min(spec.capacity);
let advanced = last.saturating_add(intervals.saturating_mul(spec.refill_interval_ms));
(refilled, advanced)
}
};
if tokens >= cost {
let remaining = tokens - cost;
(
(remaining, last_refill),
Acquire {
allowed: true,
remaining,
retry_after_ms: 0,
},
)
} else {
let retry_after_ms = if no_refill {
u64::MAX
} else {
let needed = cost - tokens;
needed
.div_ceil(spec.refill_tokens)
.saturating_mul(spec.refill_interval_ms)
};
(
(tokens, last_refill),
Acquire {
allowed: false,
remaining: tokens,
retry_after_ms,
},
)
}
}
#[allow(clippy::indexing_slicing)] fn decode_i64(bytes: &[u8]) -> i64 {
let mut buf = [0u8; 8];
let n = bytes.len().min(8);
buf[..n].copy_from_slice(&bytes[..n]);
i64::from_le_bytes(buf)
}
#[allow(clippy::indexing_slicing)] fn decode_bucket(bytes: &[u8]) -> Option<(u64, u64)> {
if bytes.len() != 16 {
return None;
}
let tokens = u64::from_le_bytes(bytes[0..8].try_into().ok()?);
let last = u64::from_le_bytes(bytes[8..16].try_into().ok()?);
Some((tokens, last))
}
fn encode_bucket(state: (u64, u64)) -> Vec<u8> {
let mut out = Vec::with_capacity(16);
out.extend_from_slice(&state.0.to_le_bytes());
out.extend_from_slice(&state.1.to_le_bytes());
out
}
fn bucket_input(raw: Option<&[u8]>, now_ms: u64) -> Option<(u64, u64)> {
raw.map(|bytes| decode_bucket(bytes).unwrap_or((0, now_ms)))
}
#[derive(Default)]
pub struct MemoryBackend {
map: Mutex<HashMap<Vec<u8>, Vec<u8>>>,
}
impl KvBackend for MemoryBackend {
fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
self.map.lock().get(key).cloned()
}
fn set(&self, key: &[u8], value: Vec<u8>) {
self.map.lock().insert(key.to_vec(), value);
}
fn delete(&self, key: &[u8]) {
self.map.lock().remove(key);
}
fn increment(&self, key: &[u8], delta: i64) -> i64 {
let mut map = self.map.lock();
let cur = decode_i64(map.get(key).map(Vec::as_slice).unwrap_or(&[]));
if delta == 0 {
return cur;
}
let next = cur.saturating_add(delta);
map.insert(key.to_vec(), next.to_le_bytes().to_vec());
next
}
fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire {
let mut map = self.map.lock();
let prev = bucket_input(map.get(key).map(Vec::as_slice), now_ms);
let (next, result) = apply_bucket(prev, cost, spec, now_ms);
map.insert(key.to_vec(), encode_bucket(next));
result
}
}
const STATE_TABLE: TableDefinition<'_, &[u8], &[u8]> = TableDefinition::new("plecto_state");
const REDB_CACHE_BYTES: usize = 64 << 20;
const DURABLE_FLUSH_EVERY: u64 = 1024;
pub struct RedbBackend {
db: Database,
non_durable_run: AtomicU64,
flush_every: u64,
forced_flushes: AtomicU64,
}
impl RedbBackend {
pub fn open(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
Self::open_inner(path, DURABLE_FLUSH_EVERY)
}
#[cfg(test)]
fn open_with_flush_every(
path: impl AsRef<std::path::Path>,
flush_every: u64,
) -> anyhow::Result<Self> {
Self::open_inner(path, flush_every)
}
fn open_inner(path: impl AsRef<std::path::Path>, flush_every: u64) -> anyhow::Result<Self> {
let db = redb::Builder::new()
.set_cache_size(REDB_CACHE_BYTES)
.create(path)?;
Ok(Self {
db,
non_durable_run: AtomicU64::new(0),
flush_every,
forced_flushes: AtomicU64::new(0),
})
}
#[cfg(test)]
fn forced_flushes(&self) -> u64 {
self.forced_flushes.load(Ordering::Relaxed)
}
fn hot_path_durability(&self) -> redb::Durability {
let run = self.non_durable_run.fetch_add(1, Ordering::Relaxed) + 1;
if run >= self.flush_every {
self.non_durable_run.store(0, Ordering::Relaxed);
self.forced_flushes.fetch_add(1, Ordering::Relaxed);
redb::Durability::Immediate
} else {
redb::Durability::None
}
}
fn get_inner(&self, key: &[u8]) -> anyhow::Result<Option<Vec<u8>>> {
let rtxn = self.db.begin_read()?;
let table = match rtxn.open_table(STATE_TABLE) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(e.into()),
};
Ok(table.get(key)?.map(|g| g.value().to_vec()))
}
fn set_inner(&self, key: &[u8], value: &[u8]) -> anyhow::Result<()> {
let wtxn = self.db.begin_write()?;
{
let mut table = wtxn.open_table(STATE_TABLE)?;
table.insert(key, value)?;
}
wtxn.commit()?;
self.non_durable_run.store(0, Ordering::Relaxed);
Ok(())
}
fn delete_inner(&self, key: &[u8]) -> anyhow::Result<()> {
let wtxn = self.db.begin_write()?;
{
let mut table = wtxn.open_table(STATE_TABLE)?;
table.remove(key)?;
}
wtxn.commit()?;
self.non_durable_run.store(0, Ordering::Relaxed);
Ok(())
}
fn increment_inner(&self, key: &[u8], delta: i64) -> anyhow::Result<i64> {
let mut wtxn = self.db.begin_write()?;
wtxn.set_durability(self.hot_path_durability())?;
let next = {
let mut table = wtxn.open_table(STATE_TABLE)?;
let cur = table.get(key)?.map(|g| decode_i64(g.value())).unwrap_or(0);
let next = cur.saturating_add(delta);
table.insert(key, next.to_le_bytes().as_slice())?;
next
};
wtxn.commit()?;
Ok(next)
}
fn try_acquire_inner(
&self,
key: &[u8],
cost: u64,
spec: Bucket,
now_ms: u64,
) -> anyhow::Result<Acquire> {
let mut wtxn = self.db.begin_write()?;
wtxn.set_durability(self.hot_path_durability())?;
let result = {
let mut table = wtxn.open_table(STATE_TABLE)?;
let prev = {
let guard = table.get(key)?;
bucket_input(guard.as_ref().map(|g| g.value()), now_ms)
};
let (next, result) = apply_bucket(prev, cost, spec, now_ms);
table.insert(key, encode_bucket(next).as_slice())?;
result
};
wtxn.commit()?;
Ok(result)
}
}
impl KvBackend for RedbBackend {
fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
match self.get_inner(key) {
Ok(v) => v,
Err(e) => {
tracing::error!(error = %e, "redb get failed; treating key as absent");
None
}
}
}
fn set(&self, key: &[u8], value: Vec<u8>) {
if let Err(e) = self.set_inner(key, &value) {
tracing::error!(error = %e, "redb set failed; value dropped");
}
}
fn delete(&self, key: &[u8]) {
if let Err(e) = self.delete_inner(key) {
tracing::error!(error = %e, "redb delete failed");
}
}
fn increment(&self, key: &[u8], delta: i64) -> i64 {
let r = if delta == 0 {
self.get_inner(key)
.map(|opt| decode_i64(opt.as_deref().unwrap_or_default()))
} else {
self.increment_inner(key, delta)
};
match r {
Ok(v) => v,
Err(e) => {
tracing::error!(error = %e, "redb increment failed; returning 0");
0
}
}
}
fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire {
match self.try_acquire_inner(key, cost, spec, now_ms) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "redb try_acquire failed; denying");
Acquire {
allowed: false,
remaining: 0,
retry_after_ms: spec.refill_interval_ms,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kv_roundtrip(backend: &dyn KvBackend) {
assert_eq!(backend.get(b"k"), None);
backend.set(b"k", b"v".to_vec());
assert_eq!(backend.get(b"k"), Some(b"v".to_vec()));
backend.delete(b"k");
assert_eq!(backend.get(b"k"), None);
}
fn counter_is_atomic_add_and_get(backend: &dyn KvBackend) {
assert_eq!(backend.increment(b"c", 1), 1);
assert_eq!(backend.increment(b"c", 4), 5);
assert_eq!(backend.increment(b"c", -2), 3);
assert_eq!(decode_i64(&backend.get(b"c").unwrap()), 3);
}
fn token_bucket_drains_then_refills(backend: &dyn KvBackend) {
let spec = Bucket {
capacity: 2,
refill_tokens: 1,
refill_interval_ms: 1000,
};
assert!(backend.try_acquire(b"rl", 1, spec, 0).allowed);
assert!(backend.try_acquire(b"rl", 1, spec, 0).allowed);
let denied = backend.try_acquire(b"rl", 1, spec, 0);
assert!(!denied.allowed);
assert_eq!(denied.remaining, 0);
assert_eq!(denied.retry_after_ms, 1000, "1 token needs one interval");
assert!(backend.try_acquire(b"rl", 1, spec, 1000).allowed);
}
fn counter_read_via_zero_delta_does_not_create_key(backend: &dyn KvBackend) {
assert_eq!(backend.increment(b"zc", 0), 0, "unset counter reads as 0");
assert_eq!(
backend.get(b"zc"),
None,
"a zero-delta read must not create the counter key"
);
assert_eq!(backend.increment(b"zc", 5), 5);
assert_eq!(
backend.increment(b"zc", 0),
5,
"zero-delta still reads the live value"
);
}
fn token_bucket_corrupt_state_fails_closed(backend: &dyn KvBackend) {
let spec = Bucket {
capacity: 5,
refill_tokens: 1,
refill_interval_ms: 1000,
};
backend.set(b"cb", vec![0xff; 3]); assert!(
!backend.try_acquire(b"cb", 1, spec, 0).allowed,
"corrupt bucket must fail closed, not start full"
);
assert!(backend.try_acquire(b"cb", 1, spec, 1000).allowed);
}
#[test]
fn memory_backend_behaviour() {
let b = MemoryBackend::default();
kv_roundtrip(&b);
counter_is_atomic_add_and_get(&b);
counter_read_via_zero_delta_does_not_create_key(&b);
token_bucket_drains_then_refills(&b);
token_bucket_corrupt_state_fails_closed(&b);
}
#[test]
fn redb_backend_behaviour() {
let dir = tempfile::tempdir().unwrap();
let b = RedbBackend::open(dir.path().join("state.redb")).unwrap();
kv_roundtrip(&b);
counter_is_atomic_add_and_get(&b);
counter_read_via_zero_delta_does_not_create_key(&b);
token_bucket_drains_then_refills(&b);
token_bucket_corrupt_state_fails_closed(&b);
}
#[test]
fn redb_periodic_durable_flush_caps_a_non_durable_run() {
let dir = tempfile::tempdir().unwrap();
let b = RedbBackend::open_with_flush_every(dir.path().join("state.redb"), 4).unwrap();
let spec = Bucket {
capacity: 1000,
refill_tokens: 0,
refill_interval_ms: 0,
};
b.increment(b"c", 1);
b.increment(b"c", 1);
b.increment(b"c", 0); b.try_acquire(b"rl", 1, spec, 0);
assert_eq!(
b.forced_flushes(),
0,
"three hot-path commits stay non-durable"
);
b.try_acquire(b"rl", 1, spec, 0);
assert_eq!(
b.forced_flushes(),
1,
"the 4th commit in a run is upgraded to durable"
);
for _ in 0..4 {
b.increment(b"c", 1);
}
assert_eq!(b.forced_flushes(), 2, "the cadence repeats");
}
#[test]
fn redb_durable_kv_write_resets_the_flush_cadence() {
let dir = tempfile::tempdir().unwrap();
let b = RedbBackend::open_with_flush_every(dir.path().join("state.redb"), 4).unwrap();
b.increment(b"c", 1);
b.increment(b"c", 1);
b.increment(b"c", 1);
b.set(b"k", b"v".to_vec()); b.increment(b"c", 1);
b.increment(b"c", 1);
b.increment(b"c", 1);
assert_eq!(
b.forced_flushes(),
0,
"a durable set resets the non-durable run"
);
b.increment(b"c", 1);
assert_eq!(b.forced_flushes(), 1);
}
#[test]
fn redb_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.redb");
{
let b = RedbBackend::open(&path).unwrap();
assert_eq!(b.increment(b"hits", 3), 3);
}
let b = RedbBackend::open(&path).unwrap();
assert_eq!(b.increment(b"hits", 1), 4);
}
#[test]
fn token_bucket_cost_zero_always_allowed() {
let spec = Bucket {
capacity: 1,
refill_tokens: 1,
refill_interval_ms: 1000,
};
let (_state, r) = apply_bucket(Some((0, 0)), 0, spec, 0);
assert!(r.allowed, "a zero-cost acquire never blocks");
}
}