Skip to main content

arkhe_forge_platform/
dedup.rs

1//! L2 idempotency-key dedup service.
2//!
3//! Primary production path: PG UNIQUE INDEX on the `idempotency_keys`
4//! projection table. This module ships the trait surface plus an
5//! in-memory implementation suitable for Tier-0 dev and unit tests.
6//! The PG-backed implementation and a potential L0 kernel WAL-scan
7//! fallback plug in under the same [`IdempotencyIndex`] contract.
8
9use std::collections::HashMap;
10
11use arkhe_forge_core::context::IdempotencyIndex;
12use arkhe_kernel::abi::{EntityId, Tick};
13
14/// Insert-side failure taxonomy for dedup stores. Lookup is always
15/// infallible (returns `Option`).
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum DedupError {
19    /// The same key was inserted twice with different bindings.
20    #[error("idempotency key conflict: {0:x?}")]
21    Conflict([u8; 16]),
22}
23
24/// In-memory [`IdempotencyIndex`] — `HashMap`-backed. Suitable for tests
25/// and Tier-0 dev; the PG-backed production implementation lands alongside
26/// the L2 service layer.
27#[derive(Debug, Default)]
28pub struct InMemoryIdempotencyIndex {
29    inner: HashMap<[u8; 16], (EntityId, Tick)>,
30}
31
32impl InMemoryIdempotencyIndex {
33    /// Construct an empty store.
34    #[inline]
35    #[must_use]
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Insert a new `(key, entity, tick)` binding. Rejects with
41    /// [`DedupError::Conflict`] if the key is already bound to a different
42    /// `(entity, tick)` pair. Idempotent re-insert of the same binding is
43    /// a no-op success.
44    pub fn insert(
45        &mut self,
46        key: [u8; 16],
47        entity: EntityId,
48        tick: Tick,
49    ) -> Result<(), DedupError> {
50        if let Some(existing) = self.inner.get(&key) {
51            if *existing == (entity, tick) {
52                return Ok(());
53            }
54            return Err(DedupError::Conflict(key));
55        }
56        self.inner.insert(key, (entity, tick));
57        Ok(())
58    }
59
60    /// Number of keys currently held.
61    #[inline]
62    #[must_use]
63    pub fn len(&self) -> usize {
64        self.inner.len()
65    }
66
67    /// `true` iff no keys are held.
68    #[inline]
69    #[must_use]
70    pub fn is_empty(&self) -> bool {
71        self.inner.is_empty()
72    }
73}
74
75impl IdempotencyIndex for InMemoryIdempotencyIndex {
76    fn lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)> {
77        self.inner.get(key).copied()
78    }
79}
80
81#[cfg(test)]
82#[allow(clippy::unwrap_used, clippy::expect_used)]
83mod tests {
84    use super::*;
85
86    fn ent(v: u64) -> EntityId {
87        EntityId::new(v).unwrap()
88    }
89
90    #[test]
91    fn empty_store_reports_no_hit() {
92        let idx = InMemoryIdempotencyIndex::new();
93        assert!(idx.is_empty());
94        assert!(idx.lookup(&[0u8; 16]).is_none());
95    }
96
97    #[test]
98    fn insert_then_lookup_returns_same_binding() {
99        let mut idx = InMemoryIdempotencyIndex::new();
100        let key = [0x11u8; 16];
101        idx.insert(key, ent(42), Tick(100)).unwrap();
102        assert_eq!(idx.lookup(&key), Some((ent(42), Tick(100))));
103    }
104
105    #[test]
106    fn reinsert_same_binding_is_noop_success() {
107        let mut idx = InMemoryIdempotencyIndex::new();
108        let key = [0x22u8; 16];
109        idx.insert(key, ent(7), Tick(1)).unwrap();
110        idx.insert(key, ent(7), Tick(1)).unwrap();
111        assert_eq!(idx.len(), 1);
112    }
113
114    #[test]
115    fn conflict_returns_error() {
116        let mut idx = InMemoryIdempotencyIndex::new();
117        let key = [0x33u8; 16];
118        idx.insert(key, ent(1), Tick(1)).unwrap();
119        let err = idx.insert(key, ent(2), Tick(1)).unwrap_err();
120        assert!(matches!(err, DedupError::Conflict(k) if k == key));
121    }
122
123    #[test]
124    fn trait_object_usable_for_action_context() {
125        let mut idx = InMemoryIdempotencyIndex::new();
126        idx.insert([0x44u8; 16], ent(99), Tick(5)).unwrap();
127        let trait_ref: &dyn IdempotencyIndex = &idx;
128        assert_eq!(trait_ref.lookup(&[0x44u8; 16]), Some((ent(99), Tick(5))),);
129    }
130}