Skip to main content

objects/
operation_dedup.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Portable idempotency receipt vocabulary.
3
4use crate::object::OperationId;
5use serde::{Deserialize, Serialize};
6
7/// Default retention for completed receipts. Pending reservations do not expire.
8pub const DEFAULT_RETENTION_SECS: i64 = 7 * 24 * 60 * 60;
9
10/// Hash the caller's canonical request bytes for deduplication.
11pub fn hash_request_body(bytes: &[u8]) -> [u8; 32] {
12    *blake3::hash(bytes).as_bytes()
13}
14
15/// One persisted dedup entry. Identity is `(operation_id, verb)`.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DedupEntry {
18    pub operation_id: OperationId,
19    /// Hosted method name or CLI verb name, including the replay encoding
20    /// generation when relevant. Operation IDs remain unique across the store;
21    /// reusing one under a different verb is a conflict.
22    pub verb: String,
23    /// BLAKE3-256 of the request body bytes. The caller is responsible for
24    /// choosing a deterministic encoding and including its generation in the
25    /// verb whenever an encoding change would make cached data incompatible.
26    pub request_hash: [u8; 32],
27    /// Cached response bytes in the caller-owned encoding for this verb.
28    /// Empty (`Vec::new()`) when [`pending`](Self::pending) is `true` —
29    /// i.e. the slot is reserved but the response hasn't been recorded yet.
30    pub response: Vec<u8>,
31    /// Unix epoch seconds when this entry was created. Used by compaction.
32    pub created_at_secs: i64,
33    /// `true` when the entry is reserved but not yet completed.
34    /// Concurrent retries with the same
35    /// `(operation_id, verb)` see [`DedupOutcome::InFlight`] while the
36    /// reservation is held. Cleared when the response is persisted or
37    /// the reservation is canceled after execution fails.
38    ///
39    pub pending: bool,
40}
41
42/// Result of a dedup reservation call.
43///
44/// - [`DedupOutcome::Reserved`]: this id has not been seen, and the store
45///   has atomically claimed the slot for the caller. The caller MUST
46///   either complete the request or release the reservation. While
47///   the reservation is held, concurrent identical requests see
48///   [`DedupOutcome::InFlight`].
49/// - [`DedupOutcome::Replay`]: a completed entry exists with a matching
50///   body hash; the cached response is returned and the request must
51///   *not* be re-executed.
52/// - [`DedupOutcome::InFlight`]: a reservation for the same
53///   `(operation_id, verb)` is currently held by another caller (with
54///   the same body hash). The caller should surface a transient error
55///   (`Status::aborted`) so the client can retry once the original
56///   completes.
57/// - [`DedupOutcome::Conflict`]: same id, different body. Caller should
58///   surface a `FailedPrecondition` to the client.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum DedupOutcome {
61    Reserved,
62    Replay { response: Vec<u8> },
63    InFlight,
64    Conflict,
65}
66
67/// Safe-to-report metadata for an existing op-id slot. This deliberately
68/// omits cached response bytes; callers use it to explain conflicts without
69/// leaking command output.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct DedupConflictMetadata {
72    pub operation_id: OperationId,
73    pub verb: String,
74    pub request_hash: [u8; 32],
75    pub created_at_secs: i64,
76    pub pending: bool,
77}