confium_coordinator/coordinator/
idempotency.rs1use crate::coordinator::coordinator::Coordinator;
13use crate::coordinator::session::{SessionError, SessionId, SessionRequest};
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17pub trait IdempotencyStore: Send + Sync {
19 fn lookup(&self, key: &str) -> Option<SessionId>;
22
23 fn record(&self, key: &str, session_id: &SessionId);
25}
26
27#[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
46pub trait IdempotentCoordinator {
48 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}