1use crate::durability::settle::append_settled;
2use std::fmt;
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use super::head_checkpoint::READ_BATCH_SIZE;
7use super::{DurabilityError, DurableStore, ProcessingReceipt, StoredEntry};
8use codec::DedupRecord;
9pub use head::{DedupHeadCounts, dedup_head_counts};
10use in_flight::InFlightIndex;
11pub use sweep::{DedupSweepReport, DedupSweeper};
12
13#[cfg(test)]
14mod ceiling_in_flight;
15mod codec;
16mod head;
17#[cfg(test)]
18mod head_tests;
19mod in_flight;
20#[cfg(test)]
21mod in_flight_tests;
22#[cfg(test)]
23mod lookup_tests;
24#[cfg(test)]
25mod start_count_tests;
26mod sweep;
27#[cfg(test)]
28mod tests;
29mod unresolved;
30
31#[must_use]
33pub fn key_hash(idempotency_key: &str) -> String {
34 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
35 for byte in idempotency_key.as_bytes() {
36 hash ^= u64::from(*byte);
37 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
38 }
39 format!("{hash:016x}")
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ClaimEvidence {
50 channel: String,
51 positions: Vec<u64>,
52}
53
54impl ClaimEvidence {
55 #[must_use]
58 pub fn new(channel: impl Into<String>, positions: Vec<u64>) -> Self {
59 Self {
60 channel: channel.into(),
61 positions,
62 }
63 }
64
65 #[must_use]
67 pub fn channel(&self) -> &str {
68 &self.channel
69 }
70
71 #[must_use]
73 pub fn positions(&self) -> &[u64] {
74 &self.positions
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct InFlightClaim {
88 pub idempotency_key: String,
90 pub claimed_at_millis: u64,
92 pub evidence: Option<ClaimEvidence>,
94}
95
96impl InFlightClaim {
97 fn of(idempotency_key: String, entry: &DedupEntry) -> Self {
98 Self {
99 idempotency_key,
100 claimed_at_millis: entry.timestamp_millis(),
101 evidence: entry.evidence().cloned(),
102 }
103 }
104}
105
106#[derive(Clone, PartialEq, Eq)]
108pub struct DedupEntry {
109 idempotency_key: String,
110 receipt: Option<Vec<u8>>,
111 timestamp_millis: u64,
112 evidence: Option<ClaimEvidence>,
113}
114
115impl DedupEntry {
116 #[must_use]
118 pub fn new(
119 idempotency_key: impl Into<String>,
120 receipt: Option<Vec<u8>>,
121 timestamp_millis: u64,
122 ) -> Self {
123 Self {
124 idempotency_key: idempotency_key.into(),
125 receipt,
126 timestamp_millis,
127 evidence: None,
128 }
129 }
130
131 #[must_use]
133 pub fn idempotency_key(&self) -> &str {
134 &self.idempotency_key
135 }
136
137 #[must_use]
139 pub fn evidence_channel(&self) -> Option<&str> {
140 self.evidence.as_ref().map(ClaimEvidence::channel)
141 }
142
143 #[must_use]
145 pub const fn evidence(&self) -> Option<&ClaimEvidence> {
146 self.evidence.as_ref()
147 }
148
149 #[must_use]
151 pub fn receipt(&self) -> Option<&[u8]> {
152 self.receipt.as_deref()
153 }
154
155 #[must_use]
157 pub const fn timestamp_millis(&self) -> u64 {
158 self.timestamp_millis
159 }
160
161 pub fn serialize(&self) -> Result<Vec<u8>, DurabilityError> {
167 DedupRecord::Active(self.clone()).serialize()
168 }
169
170 pub fn deserialize(bytes: &[u8]) -> Result<Self, DurabilityError> {
176 match DedupRecord::deserialize(bytes)? {
177 DedupRecord::Active(entry) => Ok(entry),
178 DedupRecord::Tombstone { .. } => Err(DurabilityError::EnvelopeError(
179 "dedup tombstone is not an active entry".to_owned(),
180 )),
181 }
182 }
183}
184
185impl fmt::Debug for DedupEntry {
186 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187 formatter
188 .debug_struct("DedupEntry")
189 .field("idempotency_key", &self.idempotency_key)
190 .field("receipt_bytes", &self.receipt.as_ref().map(Vec::len))
191 .field("timestamp_millis", &self.timestamp_millis)
192 .field("evidence", &self.evidence)
193 .finish()
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Eq)]
199pub enum DedupDecision {
200 Claimed,
202 Completed(ProcessingReceipt),
204 InFlight,
206}
207
208#[derive(Clone)]
215pub struct DedupCache {
216 store: Arc<dyn DurableStore>,
217 namespace: String,
218 in_flight: Arc<InFlightIndex>,
219}
220
221impl DedupCache {
222 #[must_use]
224 pub fn new(store: Arc<dyn DurableStore>, namespace: impl Into<String>) -> Self {
225 Self {
226 store,
227 namespace: namespace.into(),
228 in_flight: Arc::new(InFlightIndex::default()),
229 }
230 }
231
232 #[must_use]
243 pub fn new_empty(store: Arc<dyn DurableStore>, namespace: impl Into<String>) -> Self {
244 Self {
245 store,
246 namespace: namespace.into(),
247 in_flight: Arc::new(InFlightIndex::empty()),
248 }
249 }
250
251 #[must_use]
253 pub fn namespace(&self) -> &str {
254 &self.namespace
255 }
256
257 #[must_use]
259 pub fn stream_key_for(&self, idempotency_key: &str) -> String {
260 format!("{}:{}", self.namespace, key_hash(idempotency_key))
261 }
262
263 pub async fn lookup(
273 &self,
274 idempotency_key: &str,
275 ) -> Result<Option<DedupDecision>, DurabilityError> {
276 let stream_key = self.stream_key_for(idempotency_key);
277 let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
278 Ok(snapshot.current.as_ref().map(decision_for_entry))
279 }
280
281 pub async fn claim_or_get(
290 &self,
291 idempotency_key: &str,
292 timestamp_millis: u64,
293 ) -> Result<DedupDecision, DurabilityError> {
294 let stream_key = self.stream_key_for(idempotency_key);
295 let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
296 if let Some(entry) = snapshot.current.as_ref() {
297 return Ok(decision_for_entry(entry));
298 }
299
300 let entry = DedupEntry::new(idempotency_key, None, timestamp_millis);
301 self.record_empty_start().await?;
302 match append_settled(
303 self.store.as_ref(),
304 &stream_key,
305 entry.serialize()?,
306 snapshot.next_seq,
307 )
308 .await
309 {
310 Ok(_) => Ok(DedupDecision::Claimed),
311 Err(DurabilityError::SequenceConflict { expected, actual }) => {
312 self.decision_after_conflict(&stream_key, idempotency_key, expected, actual)
313 .await
314 }
315 Err(error) => Err(error),
316 }
317 }
318
319 pub async fn complete_receipt(
327 &self,
328 idempotency_key: &str,
329 receipt: ProcessingReceipt,
330 ) -> Result<(), DurabilityError> {
331 self.complete_receipt_at(idempotency_key, receipt, current_epoch_millis()?)
332 .await
333 }
334
335 async fn complete_receipt_at(
346 &self,
347 idempotency_key: &str,
348 receipt: ProcessingReceipt,
349 timestamp_millis: u64,
350 ) -> Result<(), DurabilityError> {
351 let stream_key = self.stream_key_for(idempotency_key);
352 let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
353 let Some(entry) = snapshot.current.as_ref() else {
354 return Err(DurabilityError::DedupCollision {
355 key: idempotency_key.to_owned(),
356 });
357 };
358
359 let receipt_bytes = receipt.into_bytes();
360 if let Some(existing_receipt) = entry.receipt() {
361 if existing_receipt == receipt_bytes.as_slice() {
362 self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
363 .await;
364 return Ok(());
365 }
366 return Err(DurabilityError::DedupCollision {
367 key: idempotency_key.to_owned(),
368 });
369 }
370
371 let completed = DedupEntry::new(
372 entry.idempotency_key().to_owned(),
373 Some(receipt_bytes.clone()),
374 timestamp_millis,
375 );
376 match append_settled(
377 self.store.as_ref(),
378 &stream_key,
379 completed.serialize()?,
380 snapshot.next_seq,
381 )
382 .await
383 {
384 Ok(_) => {}
385 Err(DurabilityError::SequenceConflict { expected, actual }) => {
386 self.confirm_matching_receipt(
387 &stream_key,
388 idempotency_key,
389 &receipt_bytes,
390 expected,
391 actual,
392 )
393 .await?;
394 }
395 Err(error) => return Err(error),
396 }
397 self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
398 .await;
399 Ok(())
400 }
401
402 pub async fn release_claim(&self, idempotency_key: &str) -> Result<(), DurabilityError> {
421 self.release_claim_at(idempotency_key, current_epoch_millis()?)
422 .await
423 }
424
425 async fn release_claim_at(
426 &self,
427 idempotency_key: &str,
428 timestamp_millis: u64,
429 ) -> Result<(), DurabilityError> {
430 let stream_key = self.stream_key_for(idempotency_key);
431 let snapshot = self.load_snapshot(&stream_key, idempotency_key).await?;
432 let Some(entry) = snapshot.current.as_ref() else {
435 return Ok(());
436 };
437 if entry.receipt().is_some() {
438 return Ok(());
439 }
440
441 let tombstone = DedupRecord::tombstone(idempotency_key.to_owned(), timestamp_millis);
442 match append_settled(
443 self.store.as_ref(),
444 &stream_key,
445 tombstone.serialize()?,
446 snapshot.next_seq,
447 )
448 .await
449 {
450 Ok(_) => {}
451 Err(DurabilityError::SequenceConflict { expected, actual }) => {
452 self.confirm_release_after_conflict(&stream_key, idempotency_key, expected, actual)
453 .await?;
454 }
455 Err(error) => return Err(error),
456 }
457 self.leave_in_flight(idempotency_key, snapshot.claim_sequence()?)
458 .await;
459 Ok(())
460 }
461
462 async fn confirm_release_after_conflict(
463 &self,
464 stream_key: &str,
465 idempotency_key: &str,
466 expected: u64,
467 actual: u64,
468 ) -> Result<(), DurabilityError> {
469 let latest = self.latest_record(stream_key, idempotency_key).await?;
476 match latest {
477 Some(DedupRecord::Tombstone { .. }) => Ok(()),
478 Some(DedupRecord::Active(entry)) if entry.receipt().is_some() => Ok(()),
479 _ => Err(DurabilityError::SequenceConflict { expected, actual }),
480 }
481 }
482
483 async fn latest_record(
484 &self,
485 stream_key: &str,
486 idempotency_key: &str,
487 ) -> Result<Option<DedupRecord>, DurabilityError> {
488 Ok(self.latest(stream_key, idempotency_key).await?.0)
489 }
490
491 async fn latest(
501 &self,
502 stream_key: &str,
503 idempotency_key: &str,
504 ) -> Result<(Option<DedupRecord>, u64), DurabilityError> {
505 let page = self.store.read_from(stream_key, 0, READ_BATCH_SIZE).await?;
507 if page.len() < READ_BATCH_SIZE {
508 let latest = fold_page(stream_key, idempotency_key, &page, 0)?;
509 return Ok((latest, len_to_u64(page.len())?));
510 }
511 self.latest_past_first_page(stream_key, idempotency_key, &page)
512 .await
513 }
514
515 fn scan_prefix(&self) -> String {
516 format!("{}:", self.namespace)
517 }
518
519 async fn decision_after_conflict(
520 &self,
521 stream_key: &str,
522 idempotency_key: &str,
523 expected: u64,
524 actual: u64,
525 ) -> Result<DedupDecision, DurabilityError> {
526 let snapshot = self.load_snapshot(stream_key, idempotency_key).await?;
527 snapshot.current.as_ref().map_or(
528 Err(DurabilityError::SequenceConflict { expected, actual }),
529 |entry| Ok(decision_for_entry(entry)),
530 )
531 }
532
533 async fn confirm_matching_receipt(
534 &self,
535 stream_key: &str,
536 idempotency_key: &str,
537 receipt_bytes: &[u8],
538 expected: u64,
539 actual: u64,
540 ) -> Result<(), DurabilityError> {
541 let snapshot = self.load_snapshot(stream_key, idempotency_key).await?;
542 if snapshot
543 .current
544 .as_ref()
545 .and_then(DedupEntry::receipt)
546 .is_some_and(|bytes| bytes == receipt_bytes)
547 {
548 Ok(())
549 } else {
550 Err(DurabilityError::SequenceConflict { expected, actual })
551 }
552 }
553
554 async fn load_snapshot(
555 &self,
556 stream_key: &str,
557 idempotency_key: &str,
558 ) -> Result<StreamSnapshot, DurabilityError> {
559 let (latest, next_seq) = self.latest(stream_key, idempotency_key).await?;
560 Ok(StreamSnapshot {
561 current: latest.and_then(DedupRecord::into_active),
562 next_seq,
563 })
564 }
565}
566
567impl fmt::Debug for DedupCache {
568 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
569 formatter
570 .debug_struct("DedupCache")
571 .field("namespace", &self.namespace)
572 .field("store", &self.store)
573 .field("in_flight", &self.in_flight)
574 .finish()
575 }
576}
577
578struct StreamSnapshot {
579 current: Option<DedupEntry>,
580 next_seq: u64,
581}
582
583impl StreamSnapshot {
584 fn claim_sequence(&self) -> Result<u64, DurabilityError> {
587 self.next_seq.checked_sub(1).ok_or_else(|| {
588 DurabilityError::ConfigError("dedup stream has no record to settle".to_owned())
589 })
590 }
591}
592
593fn decision_for_entry(entry: &DedupEntry) -> DedupDecision {
594 entry.receipt().map_or(DedupDecision::InFlight, |bytes| {
595 DedupDecision::Completed(ProcessingReceipt::new(bytes.to_vec()))
596 })
597}
598
599fn checked_record(payload: &[u8], idempotency_key: &str) -> Result<DedupRecord, DurabilityError> {
602 let record = DedupRecord::deserialize(payload)?;
603 if record.idempotency_key() != idempotency_key {
604 return Err(DurabilityError::DedupCollision {
605 key: idempotency_key.to_owned(),
606 });
607 }
608 Ok(record)
609}
610
611fn fold_page(
615 stream_key: &str,
616 idempotency_key: &str,
617 page: &[StoredEntry],
618 offset: u64,
619) -> Result<Option<DedupRecord>, DurabilityError> {
620 let mut latest = None;
621 let mut expected = offset;
622 for stored in page {
623 if stored.sequence != expected {
624 return Err(DurabilityError::ConfigError(format!(
625 "dedup stream {stream_key} answered sequence {} where {expected} was read",
626 stored.sequence
627 )));
628 }
629 latest = Some(checked_record(&stored.payload, idempotency_key)?);
630 expected = expected.checked_add(1).ok_or_else(|| {
631 DurabilityError::ConfigError(format!("dedup stream {stream_key} sequence overflow"))
632 })?;
633 }
634 Ok(latest)
635}
636
637fn len_to_u64(len: usize) -> Result<u64, DurabilityError> {
638 u64::try_from(len).map_err(|error| {
639 DurabilityError::ConfigError(format!("dedup entry count cannot fit u64: {error}"))
640 })
641}
642
643fn current_epoch_millis() -> Result<u64, DurabilityError> {
644 let duration = SystemTime::now()
645 .duration_since(UNIX_EPOCH)
646 .map_err(|error| {
647 DurabilityError::ConfigError(format!("system clock is before Unix epoch: {error}"))
648 })?;
649 u64::try_from(duration.as_millis()).map_err(|error| {
650 DurabilityError::ConfigError(format!(
651 "current epoch millis cannot fit u64 for dedup receipt: {error}"
652 ))
653 })
654}