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}