1use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use parking_lot::Mutex;
20use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
21
22#[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#[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
38pub 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 fn increment(&self, key: &[u8], delta: i64) -> i64;
49 fn try_acquire(&self, key: &[u8], cost: u64, spec: Bucket, now_ms: u64) -> Acquire;
53}
54
55pub 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 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 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#[allow(clippy::indexing_slicing)] fn 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#[allow(clippy::indexing_slicing)] fn 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
149fn 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#[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 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
201const STATE_TABLE: TableDefinition<'_, &[u8], &[u8]> = TableDefinition::new("plecto_state");
204
205const REDB_CACHE_BYTES: usize = 64 << 20;
211
212const DURABLE_FLUSH_EVERY: u64 = 1024;
218
219pub struct RedbBackend {
225 db: Database,
226 non_durable_run: AtomicU64,
229 flush_every: u64,
230 forced_flushes: AtomicU64,
232}
233
234impl RedbBackend {
235 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let spec = Bucket {
480 capacity: 5,
481 refill_tokens: 1,
482 refill_interval_ms: 1000,
483 };
484 backend.set(b"cb", vec![0xff; 3]); assert!(
486 !backend.try_acquire(b"cb", 1, spec, 0).allowed,
487 "corrupt bucket must fail closed, not start full"
488 );
489 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 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); 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 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()); 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 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}