1use crate::error::{RuntimeError, RuntimeResult};
14use crate::ids::validate_identifier;
15use std::collections::HashMap;
16use std::fmt;
17use std::fs::{self, OpenOptions};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23pub const IDEMPOTENCY_FORMAT_V1: &str = "# appcore-idempotency-v1";
25const MAX_IDEMPOTENCY_FILE_BYTES: u64 = 64 * 1024 * 1024;
26static IDEMPOTENCY_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
30pub enum IdempotencyStatus {
31 Pending,
33 Resolved {
35 response_status: u16,
37 response_body: String,
39 },
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
44pub struct IdempotencyRecord {
45 pub key: String,
47 pub request_hash: String,
49 pub status: IdempotencyStatus,
51 pub created_at_ms: u64,
53}
54
55pub trait IdempotencyStore: Send + Sync {
57 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>>;
59 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()>;
61 fn len(&self) -> usize;
63
64 fn remove(&mut self, _key: &str) -> RuntimeResult<()> {
66 Ok(())
67 }
68
69 fn is_empty(&self) -> bool {
71 self.len() == 0
72 }
73}
74
75#[derive(Default)]
77pub struct InMemoryIdempotencyStore {
78 seen: HashMap<String, IdempotencyRecord>,
79}
80
81impl fmt::Debug for InMemoryIdempotencyStore {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.debug_struct("InMemoryIdempotencyStore")
84 .field("entry_count", &self.seen.len())
85 .finish()
86 }
87}
88
89impl InMemoryIdempotencyStore {
90 pub fn new() -> Self {
92 Self::default()
93 }
94}
95
96impl IdempotencyStore for InMemoryIdempotencyStore {
97 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
98 Ok(self.seen.get(key).cloned())
99 }
100
101 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
102 validate_key(&record.key)?;
103 self.seen.insert(record.key.clone(), record);
104 Ok(())
105 }
106
107 fn len(&self) -> usize {
108 self.seen.len()
109 }
110
111 fn remove(&mut self, key: &str) -> RuntimeResult<()> {
112 self.seen.remove(key);
113 Ok(())
114 }
115}
116
117pub struct FileIdempotencyStore {
119 file_path: PathBuf,
120 ttl_ms: Option<u64>,
121 seen: HashMap<String, IdempotencyRecord>,
122}
123
124impl fmt::Debug for FileIdempotencyStore {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.debug_struct("FileIdempotencyStore")
127 .field("file_path", &self.file_path)
128 .field("ttl_ms", &self.ttl_ms)
129 .field("entry_count", &self.seen.len())
130 .finish()
131 }
132}
133
134fn map_idempotency_io(operation: &'static str, err: std::io::Error) -> RuntimeError {
135 RuntimeError::IdempotencyStoreIo {
136 operation,
137 message: err.to_string(),
138 }
139}
140
141impl FileIdempotencyStore {
142 pub fn new(path: impl AsRef<Path>) -> RuntimeResult<Self> {
144 Self::new_with_ttl(path, None)
145 }
146
147 pub fn new_with_ttl(path: impl AsRef<Path>, ttl_ms: Option<u64>) -> RuntimeResult<Self> {
149 let file_path = path.as_ref().to_path_buf();
150 ensure_parent_dir(&file_path)?;
151 if !file_path.exists() {
152 rewrite_entries(&file_path, &HashMap::new())?;
153 }
154 let (seen, needs_rewrite) = load_entries(&file_path)?;
155 if needs_rewrite {
156 rewrite_entries(&file_path, &seen)?;
157 }
158 let ttl_ms = match ttl_ms {
159 Some(0) => None,
160 other => other,
161 };
162
163 Ok(Self {
164 file_path,
165 ttl_ms,
166 seen,
167 })
168 }
169
170 pub fn file_path(&self) -> &Path {
172 &self.file_path
173 }
174
175 pub fn compact(&mut self, now_ms: u64) -> RuntimeResult<usize> {
177 let before = self.seen.len();
178 self.seen
179 .retain(|_, record| !is_expired(record.created_at_ms, self.ttl_ms, now_ms));
180 let removed = before.saturating_sub(self.seen.len());
181
182 rewrite_entries(&self.file_path, &self.seen)?;
183
184 Ok(removed)
185 }
186}
187
188impl IdempotencyStore for FileIdempotencyStore {
189 fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
190 let now_ms = now_ms();
191 if let Some(record) = self.seen.get(key) {
192 if is_expired(record.created_at_ms, self.ttl_ms, now_ms) {
193 Ok(None)
194 } else {
195 Ok(Some(record.clone()))
196 }
197 } else {
198 Ok(None)
199 }
200 }
201
202 fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
203 validate_key(&record.key)?;
204 append_entry(&self.file_path, &record)?;
205 self.seen.insert(record.key.clone(), record);
206 Ok(())
207 }
208
209 fn len(&self) -> usize {
210 let now_ms = now_ms();
211 self.seen
212 .values()
213 .filter(|record| !is_expired(record.created_at_ms, self.ttl_ms, now_ms))
214 .count()
215 }
216
217 fn remove(&mut self, key: &str) -> RuntimeResult<()> {
218 if self.seen.remove(key).is_some() {
219 rewrite_entries(&self.file_path, &self.seen)?;
220 }
221 Ok(())
222 }
223}
224
225fn ensure_parent_dir(file_path: &Path) -> RuntimeResult<()> {
226 if let Some(parent) = file_path.parent() {
227 fs::create_dir_all(parent).map_err(|e| map_idempotency_io("create_store_parent_dir", e))?;
228 }
229 Ok(())
230}
231
232fn load_entries(path: &Path) -> RuntimeResult<(HashMap<String, IdempotencyRecord>, bool)> {
233 reject_symlink(path)?;
234 let metadata =
235 fs::metadata(path).map_err(|error| map_idempotency_io("read_store_metadata", error))?;
236 if metadata.len() > MAX_IDEMPOTENCY_FILE_BYTES {
237 return Err(corrupt_idempotency("store exceeds size limit"));
238 }
239 let text = fs::read_to_string(path).map_err(|error| map_idempotency_io("read_store", error))?;
240 let body = split_idempotency_format(&text)?;
241 let (complete, recovered_tail) = complete_line_prefix(body);
242 let mut seen = HashMap::new();
243
244 for line in complete.lines() {
245 let trimmed = line.trim();
246 if trimmed.is_empty() {
247 continue;
248 }
249 let record = parse_idempotency_record(trimmed)?;
250 validate_key(&record.key)?;
251 if matches!(record.status, IdempotencyStatus::Resolved { .. }) {
252 seen.insert(record.key.clone(), record);
253 }
254 }
255 Ok((seen, recovered_tail))
256}
257
258fn append_entry(path: &Path, record: &IdempotencyRecord) -> RuntimeResult<()> {
259 let mut file = OpenOptions::new()
260 .append(true)
261 .open(path)
262 .map_err(|e| map_idempotency_io("open_store_for_append", e))?;
263 let line = serde_json::to_string(record).map_err(|e| RuntimeError::IdempotencyStoreIo {
264 operation: "serialize_store_entry",
265 message: e.to_string(),
266 })?;
267 writeln!(file, "{}", line).map_err(|e| map_idempotency_io("append_store_entry", e))?;
268 file.sync_data()
269 .map_err(|e| map_idempotency_io("sync_store_entry", e))?;
270 Ok(())
271}
272
273fn rewrite_entries(path: &Path, entries: &HashMap<String, IdempotencyRecord>) -> RuntimeResult<()> {
274 let mut rows: Vec<(&String, &IdempotencyRecord)> = entries.iter().collect();
275 rows.sort_by(|a, b| a.0.cmp(b.0));
276
277 let parent = path.parent().unwrap_or_else(|| Path::new("."));
278 reject_symlink(path)?;
279 let temp_name = format!(
280 ".{}.{}-{}.tmp",
281 path.file_name()
282 .and_then(|n| n.to_str())
283 .unwrap_or("idempotency"),
284 std::process::id(),
285 IDEMPOTENCY_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
286 );
287 let temp_path = parent.join(temp_name);
288
289 let result = (|| {
290 let mut file = OpenOptions::new()
291 .create_new(true)
292 .write(true)
293 .open(&temp_path)
294 .map_err(|e| map_idempotency_io("open_temp_store_for_rewrite", e))?;
295 writeln!(file, "{IDEMPOTENCY_FORMAT_V1}")
296 .map_err(|e| map_idempotency_io("write_store_format", e))?;
297 for (_, record) in rows {
298 let line =
299 serde_json::to_string(record).map_err(|e| RuntimeError::IdempotencyStoreIo {
300 operation: "serialize_store_entry",
301 message: e.to_string(),
302 })?;
303 writeln!(file, "{}", line).map_err(|e| map_idempotency_io("rewrite_store_entry", e))?;
304 }
305 file.sync_all()
306 .map_err(|e| map_idempotency_io("sync_temp_store", e))?;
307 fs::rename(&temp_path, path).map_err(|e| map_idempotency_io("rename_temp_store", e))?;
308 sync_parent_directory(parent)
309 })();
310 if result.is_err() {
311 let _ = fs::remove_file(temp_path);
312 }
313 result
314}
315
316fn split_idempotency_format(text: &str) -> RuntimeResult<&str> {
317 if let Some(body) = text
318 .strip_prefix(IDEMPOTENCY_FORMAT_V1)
319 .and_then(|rest| rest.strip_prefix('\n'))
320 {
321 return Ok(body);
322 }
323 if text == IDEMPOTENCY_FORMAT_V1 {
324 return Ok("");
325 }
326 if text.starts_with("# appcore-") {
327 return Err(corrupt_idempotency("NO MORE SUPPORTED PLEASE UPDATE"));
328 }
329 Err(corrupt_idempotency("NO MORE SUPPORTED PLEASE UPDATE"))
330}
331
332fn complete_line_prefix(body: &str) -> (&str, bool) {
333 if body.is_empty() || body.ends_with('\n') {
334 return (body, false);
335 }
336 match body.rfind('\n') {
337 Some(last_newline) => (&body[..=last_newline], true),
338 None => ("", true),
339 }
340}
341
342fn parse_idempotency_record(line: &str) -> RuntimeResult<IdempotencyRecord> {
343 if !line.starts_with('{') {
344 return Err(corrupt_idempotency("NO MORE SUPPORTED PLEASE UPDATE"));
345 }
346 serde_json::from_str(line).map_err(|_| corrupt_idempotency("invalid JSON record"))
347}
348
349fn reject_symlink(path: &Path) -> RuntimeResult<()> {
350 match fs::symlink_metadata(path) {
351 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
352 Err(corrupt_idempotency("store path is not a regular file"))
353 }
354 Ok(_) => Ok(()),
355 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
356 Err(error) => Err(map_idempotency_io("inspect_store_path", error)),
357 }
358}
359
360fn corrupt_idempotency(message: &str) -> RuntimeError {
361 RuntimeError::IdempotencyStoreIo {
362 operation: "validate_store",
363 message: message.to_string(),
364 }
365}
366
367#[cfg(unix)]
368fn sync_parent_directory(path: &Path) -> RuntimeResult<()> {
369 fs::File::open(path)
370 .and_then(|directory| directory.sync_all())
371 .map_err(|error| map_idempotency_io("sync_store_parent", error))
372}
373
374#[cfg(not(unix))]
375fn sync_parent_directory(_path: &Path) -> RuntimeResult<()> {
376 Ok(())
377}
378
379fn validate_key(key: &str) -> RuntimeResult<()> {
380 match validate_identifier("IdempotencyKey", key) {
381 Ok(()) => Ok(()),
382 Err(RuntimeError::InvalidIdentifier {
383 reason: "empty", ..
384 }) => Err(RuntimeError::InvalidIdempotencyKey { reason: "empty" }),
385 Err(_) => Err(RuntimeError::InvalidIdempotencyKey {
386 reason: "invalid_char",
387 }),
388 }
389}
390
391fn is_expired(created_at_ms: u64, ttl_ms: Option<u64>, now_ms: u64) -> bool {
392 if created_at_ms == 0 {
393 return false;
394 }
395 match ttl_ms {
396 Some(ttl) => now_ms.saturating_sub(created_at_ms) > ttl,
397 None => false,
398 }
399}
400
401fn now_ms() -> u64 {
402 SystemTime::now()
403 .duration_since(UNIX_EPOCH)
404 .map(|d| d.as_millis() as u64)
405 .unwrap_or(0)
406}
407
408#[cfg(test)]
409#[path = "idempotency_tests.rs"]
410mod tests;