Skip to main content

confium_coordinator/coordinator/
idempotency.rs

1//! Idempotency keys — retry-safe session creation.
2//!
3//! When a client provides an idempotency key, the coordinator stores
4//! the key → session_id mapping. A retried request with the same key
5//! returns the original session instead of creating a duplicate.
6//!
7//! ## OCP design
8//!
9//! The [`IdempotencyStore`] trait allows different backends (in-memory,
10//! Redis, database). New backends are added by implementing the trait.
11
12use crate::coordinator::coordinator::Coordinator;
13use crate::coordinator::session::{SessionError, SessionId, SessionRequest};
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17/// Trait for idempotency key stores.
18pub trait IdempotencyStore: Send + Sync {
19    /// Look up a session by idempotency key. Returns `Some(session_id)`
20    /// if the key was previously used.
21    fn lookup(&self, key: &str) -> Option<SessionId>;
22
23    /// Record a key → session mapping.
24    fn record(&self, key: &str, session_id: &SessionId);
25}
26
27/// In-memory idempotency store. Thread-safe via Mutex.
28#[derive(Default)]
29pub struct InMemoryIdempotencyStore {
30    entries: Mutex<HashMap<String, SessionId>>,
31}
32
33impl IdempotencyStore for InMemoryIdempotencyStore {
34    fn lookup(&self, key: &str) -> Option<SessionId> {
35        self.entries.lock().unwrap().get(key).cloned()
36    }
37
38    fn record(&self, key: &str, session_id: &SessionId) {
39        self.entries
40            .lock()
41            .unwrap()
42            .insert(key.to_string(), session_id.clone());
43    }
44}
45
46/// Extension trait for [`Coordinator`] adding idempotent session creation.
47pub trait IdempotentCoordinator {
48    /// Create a session with an idempotency key. If the key was
49    /// previously used, returns the original session_id.
50    fn create_session_with_idempotacy(
51        &mut self,
52        request: SessionRequest,
53        key: &str,
54        store: &dyn IdempotencyStore,
55    ) -> Result<SessionId, SessionError>;
56}
57
58impl IdempotentCoordinator for Coordinator {
59    fn create_session_with_idempotacy(
60        &mut self,
61        request: SessionRequest,
62        key: &str,
63        store: &dyn IdempotencyStore,
64    ) -> Result<SessionId, SessionError> {
65        if let Some(existing) = store.lookup(key) {
66            tracing::debug!(idempotency_key = %key, session = %existing, "idempotent hit");
67            return Ok(existing);
68        }
69        let session_id = self.create_session(request)?;
70        store.record(key, &session_id);
71        tracing::debug!(idempotency_key = %key, session = %session_id, "idempotent miss → stored");
72        Ok(session_id)
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn make_request() -> SessionRequest {
81        SessionRequest {
82            quorum_id: "q".into(),
83            scheme: "CMP20".into(),
84            message: vec![0; 32],
85            threshold: 2,
86            num_parties: 3,
87            unlock_window_minutes: 60,
88            requested_by: "test".into(),
89        }
90    }
91
92    #[test]
93    fn first_call_creates_session() {
94        let store = InMemoryIdempotencyStore::default();
95        let mut coord = Coordinator::new();
96        let sid = coord
97            .create_session_with_idempotacy(make_request(), "key-1", &store)
98            .unwrap();
99        assert!(sid.starts_with("session-"));
100    }
101
102    #[test]
103    fn retry_returns_same_session() {
104        let store = InMemoryIdempotencyStore::default();
105        let mut coord = Coordinator::new();
106        let sid1 = coord
107            .create_session_with_idempotacy(make_request(), "key-1", &store)
108            .unwrap();
109        let sid2 = coord
110            .create_session_with_idempotacy(make_request(), "key-1", &store)
111            .unwrap();
112        assert_eq!(sid1, sid2);
113        assert_eq!(coord.session_count(), 1);
114    }
115
116    #[test]
117    fn different_keys_create_different_sessions() {
118        let store = InMemoryIdempotencyStore::default();
119        let mut coord = Coordinator::new();
120        let sid1 = coord
121            .create_session_with_idempotacy(make_request(), "key-A", &store)
122            .unwrap();
123        let sid2 = coord
124            .create_session_with_idempotacy(make_request(), "key-B", &store)
125            .unwrap();
126        assert_ne!(sid1, sid2);
127        assert_eq!(coord.session_count(), 2);
128    }
129
130    #[test]
131    fn store_lookup_returns_recorded() {
132        let store = InMemoryIdempotencyStore::default();
133        store.record("k1", &"session-42".to_string());
134        assert_eq!(store.lookup("k1"), Some("session-42".into()));
135    }
136
137    #[test]
138    fn store_lookup_unknown_returns_none() {
139        let store = InMemoryIdempotencyStore::default();
140        assert!(store.lookup("unknown").is_none());
141    }
142
143    #[test]
144    fn store_overwrite_updates_value() {
145        let store = InMemoryIdempotencyStore::default();
146        store.record("k1", &"session-A".to_string());
147        store.record("k1", &"session-B".to_string());
148        assert_eq!(store.lookup("k1"), Some("session-B".into()));
149    }
150
151    #[test]
152    fn many_keys_dont_interfere() {
153        let store = InMemoryIdempotencyStore::default();
154        let mut coord = Coordinator::new();
155        for i in 0..10 {
156            let key = format!("key-{i}");
157            coord
158                .create_session_with_idempotacy(make_request(), &key, &store)
159                .unwrap();
160        }
161        assert_eq!(coord.session_count(), 10);
162        for i in 0..10 {
163            let key = format!("key-{i}");
164            coord
165                .create_session_with_idempotacy(make_request(), &key, &store)
166                .unwrap();
167        }
168        assert_eq!(coord.session_count(), 10);
169    }
170
171    #[test]
172    fn idempotent_with_repeated_failures() {
173        let store = InMemoryIdempotencyStore::default();
174        let mut coord = Coordinator::new();
175        let mut last_sid = String::new();
176        for _ in 0..5 {
177            last_sid = coord
178                .create_session_with_idempotacy(make_request(), "retry-key", &store)
179                .unwrap();
180        }
181        assert_eq!(coord.session_count(), 1);
182        assert!(store.lookup("retry-key").is_some());
183        let _ = last_sid;
184    }
185}