1#![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#[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 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 async fn complete(
103 &self,
104 lease: IdempotencyLease,
105 response: serde_json::Value,
106 completed_at: DateTime<Utc>,
107 ) -> Result<IdempotencyRecord, IdempotencyError>;
108
109 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}