Skip to main content

minco_plugin_idempotency/
lib.rs

1//! Atomic idempotency claims, request fingerprints, and deterministic replay primitives.
2#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, TimeDelta, Utc};
6use minco_core::{
7    CapabilityProvision, ConfigurationField, ConfigurationValueKind, DataClass, Plugin,
8    PluginContext, PluginDescriptor, PluginError, PluginId, PluginStability,
9};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::{collections::BTreeMap, sync::Arc};
14use tokio::sync::RwLock;
15use uuid::Uuid;
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct IdempotencyKey(String);
20
21impl IdempotencyKey {
22    pub fn parse(value: impl Into<String>) -> Result<Self, IdempotencyError> {
23        let value = value.into();
24        if value.trim().is_empty() || value.len() > 200 || value.chars().any(char::is_control) {
25            return Err(IdempotencyError::InvalidKey);
26        }
27        Ok(Self(value))
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(transparent)]
37pub struct RequestFingerprint(String);
38
39impl RequestFingerprint {
40    pub fn from_serializable<T: Serialize>(value: &T) -> Result<Self, IdempotencyError> {
41        let bytes = serde_json::to_vec(value).map_err(IdempotencyError::Serialization)?;
42        Ok(Self(format!("{:x}", Sha256::digest(bytes))))
43    }
44
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48
49    pub fn parse(value: impl Into<String>) -> Result<Self, IdempotencyError> {
50        let value = value.into();
51        if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
52            return Err(IdempotencyError::InvalidFingerprint);
53        }
54        Ok(Self(value.to_ascii_lowercase()))
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct IdempotencyRecord {
60    pub fingerprint: RequestFingerprint,
61    pub response: serde_json::Value,
62    pub created_at: DateTime<Utc>,
63}
64
65/// Exclusive claim returned before a caller performs the side effect.
66///
67/// Stores must compare the lease identifier during completion and abort so an expired worker
68/// cannot overwrite the result produced by a newer claim.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct IdempotencyLease {
71    pub key: IdempotencyKey,
72    pub fingerprint: RequestFingerprint,
73    pub lease_id: Uuid,
74    pub started_at: DateTime<Utc>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum BeginOutcome {
79    Started(IdempotencyLease),
80    Replay(IdempotencyRecord),
81    Conflict,
82    InProgress { started_at: DateTime<Utc> },
83}
84
85#[async_trait]
86pub trait IdempotencyStore: Send + Sync + std::fmt::Debug {
87    async fn get(
88        &self,
89        key: &IdempotencyKey,
90    ) -> Result<Option<IdempotencyRecord>, IdempotencyError>;
91
92    /// Atomically acquires an execution lease or returns the existing state.
93    async fn begin(
94        &self,
95        key: IdempotencyKey,
96        fingerprint: RequestFingerprint,
97        now: DateTime<Utc>,
98        stale_after: TimeDelta,
99    ) -> Result<BeginOutcome, IdempotencyError>;
100
101    /// Atomically replaces the matching in-progress lease with a completed response.
102    async fn complete(
103        &self,
104        lease: IdempotencyLease,
105        response: serde_json::Value,
106        completed_at: DateTime<Utc>,
107    ) -> Result<IdempotencyRecord, IdempotencyError>;
108
109    /// Releases only the matching in-progress lease after the application side effect failed.
110    async fn abort(&self, lease: &IdempotencyLease) -> Result<bool, IdempotencyError>;
111}
112
113#[derive(Debug, Clone)]
114pub struct IdempotencyService {
115    store: Arc<dyn IdempotencyStore>,
116    stale_after: TimeDelta,
117}
118
119impl IdempotencyService {
120    pub fn new(
121        store: Arc<dyn IdempotencyStore>,
122        stale_after: TimeDelta,
123    ) -> Result<Self, IdempotencyError> {
124        validate_claim_timeout(stale_after)?;
125        Ok(Self { store, stale_after })
126    }
127
128    pub async fn get(
129        &self,
130        key: &IdempotencyKey,
131    ) -> Result<Option<IdempotencyRecord>, IdempotencyError> {
132        self.store.get(key).await
133    }
134
135    pub async fn begin(
136        &self,
137        key: IdempotencyKey,
138        fingerprint: RequestFingerprint,
139    ) -> Result<BeginOutcome, IdempotencyError> {
140        self.store
141            .begin(key, fingerprint, Utc::now(), self.stale_after)
142            .await
143    }
144
145    pub async fn complete(
146        &self,
147        lease: IdempotencyLease,
148        response: serde_json::Value,
149    ) -> Result<IdempotencyRecord, IdempotencyError> {
150        self.store.complete(lease, response, Utc::now()).await
151    }
152
153    pub async fn abort(&self, lease: &IdempotencyLease) -> Result<bool, IdempotencyError> {
154        self.store.abort(lease).await
155    }
156}
157
158#[derive(Debug, Clone)]
159enum MemoryEntry {
160    InProgress(IdempotencyLease),
161    Completed(IdempotencyRecord),
162}
163
164#[derive(Debug, Default)]
165pub struct MemoryIdempotencyStore {
166    records: RwLock<BTreeMap<IdempotencyKey, MemoryEntry>>,
167}
168
169#[async_trait]
170impl IdempotencyStore for MemoryIdempotencyStore {
171    async fn get(
172        &self,
173        key: &IdempotencyKey,
174    ) -> Result<Option<IdempotencyRecord>, IdempotencyError> {
175        Ok(match self.records.read().await.get(key) {
176            Some(MemoryEntry::Completed(record)) => Some(record.clone()),
177            Some(MemoryEntry::InProgress(_)) | None => None,
178        })
179    }
180
181    async fn begin(
182        &self,
183        key: IdempotencyKey,
184        fingerprint: RequestFingerprint,
185        now: DateTime<Utc>,
186        stale_after: TimeDelta,
187    ) -> Result<BeginOutcome, IdempotencyError> {
188        validate_claim_timeout(stale_after)?;
189        let mut records = self.records.write().await;
190        let existing = records.get(&key).cloned();
191        let outcome = match existing {
192            Some(MemoryEntry::Completed(record)) => {
193                if record.fingerprint == fingerprint {
194                    BeginOutcome::Replay(record)
195                } else {
196                    BeginOutcome::Conflict
197                }
198            }
199            Some(MemoryEntry::InProgress(existing)) => {
200                if existing.fingerprint != fingerprint {
201                    BeginOutcome::Conflict
202                } else if existing.started_at + stale_after > now {
203                    BeginOutcome::InProgress {
204                        started_at: existing.started_at,
205                    }
206                } else {
207                    let lease = IdempotencyLease {
208                        key: key.clone(),
209                        fingerprint,
210                        lease_id: Uuid::new_v4(),
211                        started_at: now,
212                    };
213                    records.insert(key, MemoryEntry::InProgress(lease.clone()));
214                    BeginOutcome::Started(lease)
215                }
216            }
217            None => {
218                let lease = IdempotencyLease {
219                    key: key.clone(),
220                    fingerprint,
221                    lease_id: Uuid::new_v4(),
222                    started_at: now,
223                };
224                records.insert(key, MemoryEntry::InProgress(lease.clone()));
225                BeginOutcome::Started(lease)
226            }
227        };
228        drop(records);
229        Ok(outcome)
230    }
231
232    async fn complete(
233        &self,
234        lease: IdempotencyLease,
235        response: serde_json::Value,
236        completed_at: DateTime<Utc>,
237    ) -> Result<IdempotencyRecord, IdempotencyError> {
238        let mut records = self.records.write().await;
239        let Some(MemoryEntry::InProgress(current)) = records.get(&lease.key) else {
240            return Err(IdempotencyError::InvalidLease);
241        };
242        if current.lease_id != lease.lease_id || current.fingerprint != lease.fingerprint {
243            return Err(IdempotencyError::InvalidLease);
244        }
245        let record = IdempotencyRecord {
246            fingerprint: lease.fingerprint,
247            response,
248            created_at: completed_at,
249        };
250        records.insert(lease.key, MemoryEntry::Completed(record.clone()));
251        drop(records);
252        Ok(record)
253    }
254
255    async fn abort(&self, lease: &IdempotencyLease) -> Result<bool, IdempotencyError> {
256        let mut records = self.records.write().await;
257        let matching = matches!(
258            records.get(&lease.key),
259            Some(MemoryEntry::InProgress(current))
260                if current.lease_id == lease.lease_id && current.fingerprint == lease.fingerprint
261        );
262        if matching {
263            records.remove(&lease.key);
264        }
265        drop(records);
266        Ok(matching)
267    }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271struct IdempotencyPluginConfig {
272    #[serde(default = "default_claim_timeout_seconds")]
273    claim_timeout_seconds: i64,
274}
275
276impl Default for IdempotencyPluginConfig {
277    fn default() -> Self {
278        Self {
279            claim_timeout_seconds: default_claim_timeout_seconds(),
280        }
281    }
282}
283
284const fn default_claim_timeout_seconds() -> i64 {
285    300
286}
287
288#[derive(Debug, Clone)]
289pub struct IdempotencyPlugin {
290    store: Arc<dyn IdempotencyStore>,
291}
292
293impl IdempotencyPlugin {
294    pub fn new(store: Arc<dyn IdempotencyStore>) -> Self {
295        Self { store }
296    }
297
298    pub fn memory() -> Self {
299        Self::new(Arc::new(MemoryIdempotencyStore::default()))
300    }
301}
302
303impl Default for IdempotencyPlugin {
304    fn default() -> Self {
305        Self::memory()
306    }
307}
308
309impl Plugin for IdempotencyPlugin {
310    fn descriptor(&self) -> PluginDescriptor {
311        let mut descriptor = PluginDescriptor::new(
312            PluginId::new("idempotency").expect("static ID"),
313            Version::new(1, 0, 0),
314            "Atomic idempotency claims, conflict detection, replay, and storage port",
315        );
316        descriptor.core_compatibility =
317            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
318        descriptor.stability = PluginStability::Stable;
319        descriptor.documentation = Some("https://docs.rs/minco-plugin-idempotency".into());
320        descriptor.default_enabled = true;
321        descriptor.data_classes.push(DataClass::Internal);
322        descriptor.provides.extend([
323            CapabilityProvision {
324                name: "http.idempotency".into(),
325                version: Version::new(1, 0, 0),
326            },
327            CapabilityProvision {
328                name: "idempotency.store".into(),
329                version: Version::new(1, 0, 0),
330            },
331            CapabilityProvision {
332                name: "idempotency.claim".into(),
333                version: Version::new(1, 0, 0),
334            },
335        ]);
336        descriptor.configuration.push(ConfigurationField {
337            key: "claim_timeout_seconds".into(),
338            kind: ConfigurationValueKind::Integer,
339            required: false,
340            secret: false,
341            description: "Time after which an abandoned in-progress claim may be recovered".into(),
342            default: Some(serde_json::json!(default_claim_timeout_seconds())),
343        });
344        descriptor
345    }
346
347    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
348        let config = context.configuration::<IdempotencyPluginConfig>()?;
349        let service = IdempotencyService::new(
350            Arc::clone(&self.store),
351            TimeDelta::seconds(config.claim_timeout_seconds),
352        )
353        .map_err(|error| PluginError::Installation(error.to_string()))?;
354        context.services().insert(Arc::new(service))?;
355        Ok(())
356    }
357}
358
359pub fn validate_claim_timeout(stale_after: TimeDelta) -> Result<(), IdempotencyError> {
360    if stale_after <= TimeDelta::zero() || stale_after > TimeDelta::hours(24) {
361        Err(IdempotencyError::InvalidClaimTimeout)
362    } else {
363        Ok(())
364    }
365}
366
367#[derive(Debug, thiserror::Error)]
368pub enum IdempotencyError {
369    #[error("idempotency key must contain 1-200 visible characters")]
370    InvalidKey,
371    #[error("request fingerprint must be a 64-character hexadecimal SHA-256 digest")]
372    InvalidFingerprint,
373    #[error("idempotency claim timeout must be greater than zero and no more than 24 hours")]
374    InvalidClaimTimeout,
375    #[error("idempotency lease is stale, missing, or belongs to another worker")]
376    InvalidLease,
377    #[error("failed to serialize request fingerprint: {0}")]
378    Serialization(serde_json::Error),
379    #[error("idempotency store failed: {0}")]
380    Store(String),
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use minco_core::{PluginManager, PluginSelection};
387
388    #[test]
389    fn claim_timeout_boundary_is_shared_by_services_and_stores() {
390        assert!(validate_claim_timeout(TimeDelta::seconds(1)).is_ok());
391        assert!(validate_claim_timeout(TimeDelta::zero()).is_err());
392        assert!(validate_claim_timeout(TimeDelta::hours(24) + TimeDelta::seconds(1)).is_err());
393    }
394
395    #[tokio::test]
396    async fn claim_protocol_prevents_concurrent_side_effects_and_replays_completion() {
397        let service = IdempotencyService::new(
398            Arc::new(MemoryIdempotencyStore::default()),
399            TimeDelta::minutes(5),
400        )
401        .unwrap();
402        let key = IdempotencyKey::parse("request-1").unwrap();
403        let fingerprint =
404            RequestFingerprint::from_serializable(&serde_json::json!({"a": 1})).unwrap();
405        let lease = match service
406            .begin(key.clone(), fingerprint.clone())
407            .await
408            .unwrap()
409        {
410            BeginOutcome::Started(lease) => lease,
411            other => panic!("expected a new lease, got {other:?}"),
412        };
413        assert!(matches!(
414            service
415                .begin(key.clone(), fingerprint.clone())
416                .await
417                .unwrap(),
418            BeginOutcome::InProgress { .. }
419        ));
420        let other = RequestFingerprint::from_serializable(&serde_json::json!({"a": 2})).unwrap();
421        assert_eq!(
422            service.begin(key.clone(), other).await.unwrap(),
423            BeginOutcome::Conflict
424        );
425        let record = service
426            .complete(lease, serde_json::json!({"ok": true}))
427            .await
428            .unwrap();
429        assert_eq!(
430            service.begin(key, fingerprint).await.unwrap(),
431            BeginOutcome::Replay(record)
432        );
433    }
434
435    #[tokio::test]
436    async fn abort_releases_only_the_matching_lease() {
437        let service = IdempotencyService::new(
438            Arc::new(MemoryIdempotencyStore::default()),
439            TimeDelta::minutes(5),
440        )
441        .unwrap();
442        let key = IdempotencyKey::parse("request-2").unwrap();
443        let fingerprint = RequestFingerprint::from_serializable(&"payload").unwrap();
444        let BeginOutcome::Started(lease) = service
445            .begin(key.clone(), fingerprint.clone())
446            .await
447            .unwrap()
448        else {
449            unreachable!()
450        };
451        assert!(service.abort(&lease).await.unwrap());
452        assert!(matches!(
453            service.begin(key, fingerprint).await.unwrap(),
454            BeginOutcome::Started(_)
455        ));
456    }
457
458    #[tokio::test]
459    async fn plugin_exposes_an_injectable_claim_service() {
460        let mut manager = PluginManager::default();
461        manager.register(IdempotencyPlugin::memory()).unwrap();
462        let application = manager.compose(&PluginSelection::default()).unwrap();
463        let service = application.services.get::<IdempotencyService>().unwrap();
464        let key = IdempotencyKey::parse("request-3").unwrap();
465        assert!(service.get(&key).await.unwrap().is_none());
466    }
467
468    #[test]
469    fn descriptor_matches_the_stable_catalog_contract() {
470        assert_eq!(
471            IdempotencyPlugin::memory().descriptor().stability,
472            PluginStability::Stable
473        );
474    }
475}