1use crate::error::{RuntimeError, RuntimeResult};
14use crate::idempotency_file::{
15 append_entry, encoded_record_bytes, load_entries, read_entry, rewrite_entries, RecordLocation,
16 StoreFileState, MAX_ACTIVE_IDEMPOTENCY_RECORDS, MAX_IDEMPOTENCY_FILE_BYTES,
17 MAX_PERSISTED_IDEMPOTENCY_RECORDS,
18};
19use crate::ids::validate_identifier;
20use std::collections::HashMap;
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26pub const IDEMPOTENCY_FORMAT_V1: &str = "# appcore-idempotency-v1";
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
31pub enum IdempotencyStatus {
32 Pending,
34 Resolved {
36 response_status: u16,
38 response_body: String,
40 },
41}
42
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
45pub struct IdempotencyRecord {
46 pub key: String,
48 pub request_hash: String,
50 pub status: IdempotencyStatus,
52 pub created_at_ms: u64,
54}
55
56pub trait IdempotencyStore: Send + Sync {
58 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>>;
60 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()>;
62 fn len(&self) -> usize;
64
65 fn remove(&mut self, _key: &str) -> RuntimeResult<()> {
67 Ok(())
68 }
69
70 fn is_empty(&self) -> bool {
72 self.len() == 0
73 }
74}
75
76#[derive(Default)]
78pub struct InMemoryIdempotencyStore {
79 seen: HashMap<String, IdempotencyRecord>,
80}
81
82impl fmt::Debug for InMemoryIdempotencyStore {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.debug_struct("InMemoryIdempotencyStore")
85 .field("entry_count", &self.seen.len())
86 .finish()
87 }
88}
89
90impl InMemoryIdempotencyStore {
91 pub fn new() -> Self {
93 Self::default()
94 }
95}
96
97impl IdempotencyStore for InMemoryIdempotencyStore {
98 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
99 Ok(self.seen.get(key).cloned())
100 }
101
102 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
103 validate_key(&record.key)?;
104 ensure_active_capacity(&self.seen, &record.key)?;
105 self.seen.insert(record.key.clone(), record);
106 Ok(())
107 }
108
109 fn len(&self) -> usize {
110 self.seen.len()
111 }
112
113 fn remove(&mut self, key: &str) -> RuntimeResult<()> {
114 self.seen.remove(key);
115 Ok(())
116 }
117}
118
119pub struct FileIdempotencyStore {
121 file_path: PathBuf,
122 ttl_ms: Option<u64>,
123 seen: HashMap<String, RecordLocation>,
124 file_state: StoreFileState,
125}
126
127impl fmt::Debug for FileIdempotencyStore {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 f.debug_struct("FileIdempotencyStore")
130 .field("file_path", &self.file_path)
131 .field("ttl_ms", &self.ttl_ms)
132 .field("entry_count", &self.seen.len())
133 .finish()
134 }
135}
136
137fn map_idempotency_io(operation: &'static str, err: std::io::Error) -> RuntimeError {
138 RuntimeError::IdempotencyStoreIo {
139 operation,
140 message: err.to_string(),
141 }
142}
143
144impl FileIdempotencyStore {
145 pub fn new(path: impl AsRef<Path>) -> RuntimeResult<Self> {
147 Self::new_with_ttl(path, None)
148 }
149
150 pub fn new_with_ttl(path: impl AsRef<Path>, ttl_ms: Option<u64>) -> RuntimeResult<Self> {
153 let file_path = path.as_ref().to_path_buf();
154 ensure_parent_dir(&file_path)?;
155 if !file_path.exists() {
156 rewrite_entries(&file_path, &HashMap::new(), None, |_, _| true)?;
157 }
158 let loaded = load_entries(&file_path)?;
159 let (seen, file_state) = if loaded.needs_rewrite {
160 let rewritten = rewrite_entries(&file_path, &loaded.entries, None, |_, _| true)?;
161 (rewritten.entries, rewritten.file_state)
162 } else {
163 (loaded.entries, loaded.file_state)
164 };
165 let ttl_ms = match ttl_ms {
166 Some(0) => None,
167 other => other,
168 };
169
170 Ok(Self {
171 file_path,
172 ttl_ms,
173 seen,
174 file_state,
175 })
176 }
177
178 pub fn file_path(&self) -> &Path {
180 &self.file_path
181 }
182
183 pub fn compact(&mut self, now_ms: u64) -> RuntimeResult<usize> {
185 let before = self.seen.len();
186 let ttl_ms = self.ttl_ms;
187 let rewritten = rewrite_entries(&self.file_path, &self.seen, None, |_, location| {
188 location.pending || !is_expired(location.created_at_ms, ttl_ms, now_ms)
189 })?;
190 let removed = before.saturating_sub(rewritten.entries.len());
191 self.seen = rewritten.entries;
192 self.file_state = rewritten.file_state;
193
194 Ok(removed)
195 }
196}
197
198impl IdempotencyStore for FileIdempotencyStore {
199 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
200 let now_ms = now_ms();
201 if let Some(location) = self.seen.get(key) {
202 if !location.pending && is_expired(location.created_at_ms, self.ttl_ms, now_ms) {
203 Ok(None)
204 } else {
205 read_entry(&self.file_path, key, location).map(Some)
206 }
207 } else {
208 Ok(None)
209 }
210 }
211
212 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
213 validate_key(&record.key)?;
214 ensure_active_capacity(&self.seen, &record.key)?;
215 let record_bytes = encoded_record_bytes(&record)?;
216 if self.requires_rewrite(record_bytes) {
217 return self.replace_and_rewrite(record);
218 }
219 let appended = append_entry(&self.file_path, &record, self.file_state.bytes)?;
220 self.file_state.bytes = self.file_state.bytes.saturating_add(appended.written_bytes);
221 self.file_state.records = self.file_state.records.saturating_add(1);
222 self.seen.insert(record.key, appended.location);
223 Ok(())
224 }
225
226 fn len(&self) -> usize {
227 let now_ms = now_ms();
228 self.seen
229 .values()
230 .filter(|location| {
231 location.pending || !is_expired(location.created_at_ms, self.ttl_ms, now_ms)
232 })
233 .count()
234 }
235
236 fn remove(&mut self, key: &str) -> RuntimeResult<()> {
237 if self.seen.contains_key(key) {
238 let rewritten = rewrite_entries(&self.file_path, &self.seen, None, |candidate, _| {
239 candidate != key
240 })?;
241 self.seen = rewritten.entries;
242 self.file_state = rewritten.file_state;
243 }
244 Ok(())
245 }
246}
247
248impl FileIdempotencyStore {
249 fn requires_rewrite(&self, record_bytes: u64) -> bool {
250 self.file_state.records >= MAX_PERSISTED_IDEMPOTENCY_RECORDS
251 || self
252 .file_state
253 .bytes
254 .checked_add(record_bytes.saturating_add(1))
255 .is_none_or(|bytes| bytes > MAX_IDEMPOTENCY_FILE_BYTES)
256 }
257
258 fn replace_and_rewrite(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
259 let rewritten = rewrite_entries(&self.file_path, &self.seen, Some(&record), |_, _| true)?;
260 self.seen = rewritten.entries;
261 self.file_state = rewritten.file_state;
262 Ok(())
263 }
264}
265
266fn ensure_parent_dir(file_path: &Path) -> RuntimeResult<()> {
267 if let Some(parent) = file_path.parent() {
268 fs::create_dir_all(parent).map_err(|e| map_idempotency_io("create_store_parent_dir", e))?;
269 }
270 Ok(())
271}
272
273fn validate_key(key: &str) -> RuntimeResult<()> {
274 match validate_identifier("IdempotencyKey", key) {
275 Ok(()) => Ok(()),
276 Err(RuntimeError::InvalidIdentifier {
277 reason: "empty", ..
278 }) => Err(RuntimeError::InvalidIdempotencyKey { reason: "empty" }),
279 Err(_) => Err(RuntimeError::InvalidIdempotencyKey {
280 reason: "invalid_char",
281 }),
282 }
283}
284
285fn ensure_active_capacity<T>(entries: &HashMap<String, T>, key: &str) -> RuntimeResult<()> {
286 if !entries.contains_key(key) && entries.len() >= MAX_ACTIVE_IDEMPOTENCY_RECORDS {
287 return Err(RuntimeError::IdempotencyStoreIo {
288 operation: "validate_store",
289 message: "active record limit exceeded".to_string(),
290 });
291 }
292 Ok(())
293}
294
295fn is_expired(created_at_ms: u64, ttl_ms: Option<u64>, now_ms: u64) -> bool {
296 if created_at_ms == 0 {
297 return false;
298 }
299 match ttl_ms {
300 Some(ttl) => now_ms.saturating_sub(created_at_ms) > ttl,
301 None => false,
302 }
303}
304
305fn now_ms() -> u64 {
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .map(|d| d.as_millis() as u64)
309 .unwrap_or(0)
310}
311
312#[cfg(test)]
313#[path = "idempotency_tests.rs"]
314mod tests;