tsoracle_client/seq_attempt.rs
1//
2// ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3// ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4// ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6// tsoracle — Distributed Timestamp Oracle
7// https://www.tsoracle.rs
8//
9// Copyright (c) 2026 Prisma Risk
10//
11// Licensed under the Apache License, Version 2.0 (the "License");
12// you may not use this file except in compliance with the License.
13// You may obtain a copy of the License at
14//
15// https://www.apache.org/licenses/LICENSE-2.0
16//
17// Unless required by applicable law or agreed to in writing, software
18// distributed under the License is distributed on an "AS IS" BASIS,
19// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20// See the License for the specific language governing permissions and
21// limitations under the License.
22//
23
24//! Single-attempt dense GetSeq call and outcome classification. The dense path
25//! is non-idempotent, so ambiguous post-send failures are surfaced (SeqUncertain)
26//! rather than retried.
27
28use crate::error::ClientError;
29
30/// One contiguous dense block returned to the caller.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct SeqBlock {
33 pub start: u64,
34 pub count: u32,
35 pub epoch: u128,
36}
37
38/// Classification of a single GetSeq attempt.
39/// Note: on success the retry loop returns the `SeqBlock` directly;
40/// this enum only describes the non-success outcomes.
41#[cfg_attr(test, derive(Debug))]
42pub(crate) enum SeqAttemptOutcome {
43 /// Pre-commit-certain: the leader refused before any durable advance. Safe to
44 /// retry / follow the hint.
45 LeaderHint {
46 endpoint: String,
47 epoch: Option<u128>,
48 },
49 /// Ambiguous post-send failure: surfaced, never silently retried.
50 Uncertain,
51 Err(ClientError),
52}
53
54/// Classify a failed GetSeq RPC. `sent` indicates the request reached the wire,
55/// making a commit possible. A NOT_LEADER hint is decoded by the caller via the
56/// shared leader_hint helper before this is reached; this handles the non-hint
57/// statuses.
58///
59/// The dense path is non-idempotent, so the post-send (`sent == true`) default
60/// is **fail-uncertain**: any status that is not *explicitly* known to be
61/// pre-commit-certain is surfaced as [`SeqAttemptOutcome::Uncertain`] (never a
62/// definitive, retry-safe error). The server can only emit a post-send
63/// `INTERNAL` after attempting the durable fetch-add — the file driver maps a
64/// post-rename directory-fsync failure to a permanent fault → `INTERNAL` — so
65/// the advance may already be committed; treating it as retry-safe would let
66/// the caller double-spend a block. Only these codes are pre-commit-certain by
67/// construction and stay definitive even post-send (each is surfaced as
68/// [`ClientError::Rpc`], preserving the server's status and message):
69/// * `InvalidArgument` — request validation, before any state is touched
70/// (e.g. an over-cap `count` the server rejects as `SeqCountTooLarge`).
71/// * `ResourceExhausted` — the per-key cardinality cap is checked before the
72/// durable write.
73/// * `Unimplemented` — the driver has no dense support and never advances.
74///
75/// `FailedPrecondition` (overflow / NOT_LEADER) is intercepted by the caller
76/// before this function and is likewise pre-commit; it only reaches here with
77/// `sent == false`, where it is surfaced as a definitive error. Connect-phase
78/// transport failures never reach this function — the retry loop handles them
79/// in its connect arm — so there is no pre-send "ride-out" outcome here.
80pub(crate) fn classify_seq_status(status: tonic::Status, sent: bool) -> SeqAttemptOutcome {
81 use tonic::Code;
82 match status.code() {
83 // Pre-commit-certain regardless of send → definitive. The server's
84 // status is preserved (surfaced as `ClientError::Rpc`) so the caller
85 // sees the real reason — e.g. an over-cap `count` rejected as
86 // INVALID_ARGUMENT (`SeqCountTooLarge`) — rather than a lossy
87 // reclassification. `InvalidSeqKey` stays reserved for the client-side
88 // pre-check in `Client::get_seq`, not server-reported validation.
89 Code::InvalidArgument | Code::ResourceExhausted | Code::Unimplemented => {
90 SeqAttemptOutcome::Err(ClientError::from(status))
91 }
92 // Post-send and not provably pre-commit → ambiguous (incl. INTERNAL,
93 // UNKNOWN, ABORTED, and transport-class Unavailable/DeadlineExceeded).
94 _ if sent => SeqAttemptOutcome::Uncertain,
95 // Pre-send (only the bare-FailedPrecondition path passes `sent == false`)
96 // → definitive.
97 _ => SeqAttemptOutcome::Err(ClientError::from(status)),
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn classify_unavailable_after_send_is_uncertain() {
107 let status = tonic::Status::unavailable("connection reset");
108 // `sent` = the request was put on the wire (post-send ambiguity).
109 let outcome = classify_seq_status(status, true);
110 assert!(matches!(outcome, SeqAttemptOutcome::Uncertain));
111 }
112
113 #[test]
114 fn classify_unavailable_before_send_is_definitive() {
115 // Connect-phase transport failures are handled by the retry loop's
116 // connect arm, never here, so a transport status reaching this function
117 // with `sent == false` (an unusual case) is surfaced as a definitive
118 // error rather than a ride-out signal.
119 let status = tonic::Status::unavailable("no connection established");
120 let outcome = classify_seq_status(status, false);
121 assert!(matches!(
122 outcome,
123 SeqAttemptOutcome::Err(ClientError::Rpc(_))
124 ));
125 }
126
127 #[test]
128 fn classify_internal_after_send_is_uncertain() {
129 // INTERNAL is the server's mapping for a permanent driver fault, which
130 // the file driver can emit *after* the durable rename (a failed dir
131 // fsync) — the advance may already be committed. Post-send it is
132 // therefore ambiguous and MUST surface as Uncertain, never a definitive
133 // (retry-safe) error, or the caller could double-spend a block.
134 let status = tonic::Status::internal("permanent driver fault");
135 let outcome = classify_seq_status(status, true);
136 assert!(
137 matches!(outcome, SeqAttemptOutcome::Uncertain),
138 "post-send INTERNAL must be Uncertain",
139 );
140 }
141
142 #[test]
143 fn classify_resource_exhausted_after_send_is_definitive() {
144 // The cardinality cap is checked before any durable write, so
145 // ResourceExhausted is pre-commit-certain even once the request reached
146 // the wire — a definitive error, not Uncertain.
147 let status = tonic::Status::resource_exhausted("cardinality cap reached");
148 let outcome = classify_seq_status(status, true);
149 assert!(
150 matches!(outcome, SeqAttemptOutcome::Err(_)),
151 "post-send ResourceExhausted is pre-commit-certain → definitive Err",
152 );
153 }
154
155 #[test]
156 fn classify_unimplemented_after_send_is_definitive() {
157 // UNIMPLEMENTED means the driver has no dense support and rejected the
158 // request before any durable advance — pre-commit-certain → definitive.
159 let status = tonic::Status::unimplemented("dense sequences unsupported");
160 let outcome = classify_seq_status(status, true);
161 assert!(
162 matches!(outcome, SeqAttemptOutcome::Err(_)),
163 "post-send UNIMPLEMENTED is pre-commit-certain → definitive Err",
164 );
165 }
166}