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