# Task 02: IdempotencyKey + IdempotencyStore ## Delivers `IdempotencyKey(u128)` BLAKE3-derived key per session operation, and `IdempotencyStore` (bounded LRU, 100K capacity) in `tidal/src/replication/idempotency.rs`. Duplicate session writes arriving via replication are detected in O(1) time and silently discarded. ## Complexity: S ## Dependencies - Task 01 (SessionSeqNo) ## Technical Design ```rust // tidal/src/replication/idempotency.rs use blake3::Hasher; /// Per-operation idempotency key derived from session context. /// /// Derived as: BLAKE3(session_id_bytes || seqno_bytes || operation_bytes) /// /// Using u128 (128 bits) gives 2^64 expected collisions at 2^64 operations, /// which is astronomically unlikely in practice. Cheaper than storing the /// full BLAKE3 hash (32 bytes) with no practical security difference for /// our use case. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct IdempotencyKey(pub u128); impl IdempotencyKey { /// Derive an idempotency key for a session operation. /// /// - `session_id`: the session this operation belongs to /// - `seqno`: monotonic sequence number (see `SessionSeqNo`) /// - `operation_bytes`: serialized operation payload (canonically ordered) pub fn derive( session_id: SessionId, seqno: SessionSeqNo, operation_bytes: &[u8], ) -> Self { let mut hasher = Hasher::new(); hasher.update(&session_id.as_bytes()); hasher.update(&seqno.0.to_le_bytes()); hasher.update(operation_bytes); let hash = hasher.finalize(); // Take first 16 bytes as u128 (little-endian). let bytes: [u8; 16] = hash.as_bytes()[..16].try_into().unwrap(); Self(u128::from_le_bytes(bytes)) } } /// Bounded LRU store for idempotency keys. /// /// Capacity: 100K entries ≈ 1.6 MB (u128 key + u8 metadata). /// When capacity is reached, the least-recently-seen key is evicted. /// This means idempotency is guaranteed for the last 100K distinct operations. /// /// Older operations fall back to the SessionSeqNo HWM check, which is /// unbounded and always monotonic (a write with seqno <= hwm is never re-applied). /// /// Thread-safe: uses a `Mutex`. pub struct IdempotencyStore { cache: Mutex>, capacity: usize, } impl IdempotencyStore { /// Create a new store with the given capacity. pub fn new(capacity: usize) -> Self { Self { cache: Mutex::new(LruCache::new( NonZeroUsize::new(capacity).expect("capacity must be > 0"), )), capacity, } } /// Create a store with the default capacity (100K). pub fn default_capacity() -> Self { Self::new(100_000) } /// Check if a key has been seen before and record it if not. /// /// Returns `true` if the key is new (should apply the operation). /// Returns `false` if the key was already seen (duplicate; skip). pub fn check_and_record(&self, key: IdempotencyKey) -> bool { let mut cache = self.cache.lock().unwrap(); if cache.contains(&key) { false } else { cache.put(key, ()); true } } /// Current number of tracked keys. pub fn len(&self) -> usize { self.cache.lock().unwrap().len() } /// Returns the configured capacity. pub fn capacity(&self) -> usize { self.capacity } } ``` ### Integration in SegmentReceiver ```rust // In tidal/src/replication/receive.rs (additions) impl SegmentReceiver { fn apply_session_event( &self, event: &SessionWalEvent, idempotency_store: &IdempotencyStore, seqno_tracker: &SessionSeqNoTracker, ) -> Result<()> { // Layer 1: SeqNo HWM check (fast, unbounded). if let Some(seqno) = event.session_seqno { if !seqno_tracker.should_apply(event.session_id, seqno) { return Ok(()); // duplicate — skip } } // Layer 2: Idempotency key check (bounded LRU, catches within-window dupes). if let Some(key_int) = event.idempotency_key { let key = IdempotencyKey(key_int); if !idempotency_store.check_and_record(key) { return Ok(()); // duplicate — skip } } // Apply the event. self.session_manager.apply_wal_event(event) } } ``` ## Acceptance Criteria - [ ] `IdempotencyKey::derive(session_id, seqno, bytes)` produces a deterministic `u128` for the same inputs - [ ] Different inputs produce different keys with overwhelming probability (no test for this -- mathematical guarantee from BLAKE3) - [ ] `IdempotencyStore::check_and_record(key)` returns `true` on first call, `false` on any subsequent call with the same key - [ ] LRU eviction: when store exceeds `capacity` distinct keys, oldest entries are evicted; evicted keys return `true` on re-insert (they look new again; fallback to SeqNo HWM handles correctness) - [ ] `IdempotencyStore::len()` returns 0 after initialization and grows up to `capacity` - [ ] Memory bound: 100K-entry store consumes < 10 MB (verify with `std::mem::size_of`) - [ ] `cargo clippy -D warnings` and `cargo fmt` pass