Skip to main content

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}