Skip to main content

tsoracle_client/
lib.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#![doc = include_str!("../README.md")]
25// Panic policy (see CONTRIBUTING.md). `cfg_attr(not(test), ...)` skips the lint
26// for the lib's own unit tests; integration tests are separate compilation units.
27#![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))]
28
29mod attempt;
30mod budget;
31mod channel_pool;
32mod driver;
33mod driver_supervisor;
34mod error;
35mod leader_hint;
36mod response;
37mod retry;
38mod retry_policy;
39mod seq_attempt;
40mod transport;
41mod worklist;
42
43#[cfg(test)]
44mod test_support;
45
46pub use error::ClientError;
47pub use retry_policy::RetryPolicy;
48pub use seq_attempt::SeqBlock;
49pub use transport::BoxError;
50
51use std::sync::Arc;
52use std::time::Duration;
53use tsoracle_core::{Epoch, LOGICAL_MAX, Timestamp};
54
55/// The server's per-call cap on requested timestamps, fixed by the 18-bit
56/// logical width. Callers asking for more than this can't be served by any
57/// single RPC; the client rejects them up-front rather than burning a queue
58/// slot and round-trip to learn the same thing from the server.
59pub(crate) const MAX_TIMESTAMPS_PER_RPC: u32 = LOGICAL_MAX + 1;
60
61use crate::channel_pool::ChannelPool;
62
63pub struct ClientBuilder {
64    endpoints: Vec<String>,
65    flush_interval: Duration,
66    connector: Option<Arc<crate::transport::ChannelConnector>>,
67    tls_required: bool,
68    retry_policy: RetryPolicy,
69}
70
71impl ClientBuilder {
72    pub fn endpoints(endpoints: Vec<String>) -> Self {
73        ClientBuilder {
74            endpoints,
75            flush_interval: Duration::from_millis(1),
76            connector: None,
77            tls_required: false,
78            retry_policy: RetryPolicy::default(),
79        }
80    }
81
82    pub fn batch_flush_interval(mut self, flush_interval: Duration) -> Self {
83        self.flush_interval = flush_interval;
84        self
85    }
86
87    /// Override the default [`RetryPolicy`].
88    ///
89    /// The policy controls per-attempt deadlines, the overall deadline
90    /// across all candidate endpoints, the cap on attempts, and the
91    /// jittered backoff base. The per-attempt deadline is also pushed
92    /// down to `tonic::transport::Endpoint::connect_timeout` and
93    /// `Endpoint::timeout` for the built-in default and TLS transport
94    /// paths so a blackholed peer fails fast at the transport layer.
95    /// User-supplied [`Self::channel_connector`] closures own their
96    /// own Endpoint config; the policy still bounds the retry loop's
97    /// outer `tokio::time::timeout` around them.
98    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
99        self.retry_policy = policy;
100        self
101    }
102
103    /// Configure the client to dial bare endpoints with TLS. Bare `host:port`
104    /// becomes `https://host:port`; explicit `http://...` endpoints supplied
105    /// in [`Self::endpoints`] remain plaintext; explicit `https://...`
106    /// endpoints use the provided TLS config.
107    ///
108    /// Wire-supplied `http://...` leader-hint trailers are NOT honored under
109    /// `tls_config` — they are dropped to prevent a contacted peer from
110    /// downgrading the transport. Operator-supplied configuration still wins;
111    /// untrusted wire input does not.
112    ///
113    /// Setting both [`Self::channel_connector`] and `tls_config` is allowed;
114    /// the last call wins (standard builder semantics). Calling
115    /// `channel_connector` after `tls_config` also clears the
116    /// reject-plaintext-hint policy, since the caller-owned connector owns
117    /// its own scheme policy.
118    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
119    pub fn tls_config(mut self, cfg: tonic::transport::ClientTlsConfig) -> Self {
120        self.connector = Some(crate::transport::tls_connector(
121            cfg,
122            self.retry_policy.clone(),
123        ));
124        self.tls_required = true;
125        self
126    }
127
128    /// Replace the default plaintext channel construction with a caller-owned
129    /// closure. The closure is invoked on first use of each endpoint —
130    /// configured endpoints and leader-hint redirects alike. Errors returned
131    /// from the closure surface as [`ClientError::Connector`].
132    ///
133    /// See module docs for the interaction with [`Self::tls_config`]
134    /// (last-wins) and the scheme matrix. A caller-owned connector replaces
135    /// the built-in TLS plumbing entirely, including the
136    /// reject-plaintext-leader-hint policy — the closure is responsible for
137    /// whatever scheme policy it wants to enforce.
138    pub fn channel_connector<F, Fut>(mut self, connector: F) -> Self
139    where
140        F: Fn(&str) -> Fut + Send + Sync + 'static,
141        Fut: std::future::Future<Output = Result<tonic::transport::Channel, crate::BoxError>>
142            + Send
143            + 'static,
144    {
145        let wrapped: Arc<crate::transport::ChannelConnector> = Arc::new(move |endpoint: &str| {
146            let fut = connector(endpoint);
147            Box::pin(async move { fut.await.map_err(ClientError::Connector) })
148        });
149        self.connector = Some(wrapped);
150        self.tls_required = false;
151        self
152    }
153
154    pub async fn build(self) -> Result<Client, ClientError> {
155        if self.endpoints.is_empty() {
156            return Err(ClientError::NoReachableEndpoints);
157        }
158        let pool = Arc::new(ChannelPool::new(
159            self.endpoints,
160            self.connector,
161            self.tls_required,
162            self.retry_policy,
163        ));
164        let pool_for_rpc = pool.clone();
165        let driver = driver::Driver::spawn(
166            move |count| {
167                let pool = pool_for_rpc.clone();
168                Box::pin(async move { retry::issue_rpc(&pool, count).await })
169            },
170            self.flush_interval,
171        );
172        Ok(Client { pool, driver })
173    }
174}
175
176pub struct Client {
177    pool: Arc<ChannelPool>,
178    driver: driver::Driver,
179}
180
181impl Client {
182    pub async fn connect(endpoints: Vec<String>) -> Result<Self, ClientError> {
183        ClientBuilder::endpoints(endpoints).build().await
184    }
185
186    /// The endpoint the client currently believes is the leader, or `None`
187    /// if no leader has been observed yet or the cached entry has aged past
188    /// the configured `leader_ttl`.
189    ///
190    /// Read-only diagnostic surface for ops dashboards and integration tests
191    /// asserting that a client has converged to the expected leader. It
192    /// reflects the cache as last updated by a completed RPC — it neither
193    /// triggers nor waits on any network round-trip, and the TTL check is
194    /// lazy (an expired entry reads as `None`).
195    pub fn cached_leader(&self) -> Option<String> {
196        self.pool.cached_leader()
197    }
198
199    pub async fn get_ts(&self) -> Result<Timestamp, ClientError> {
200        Ok(self.driver.request(1).await?[0])
201    }
202
203    pub async fn get_ts_batch(&self, count: u32) -> Result<Vec<Timestamp>, ClientError> {
204        if count == 0 || count > MAX_TIMESTAMPS_PER_RPC {
205            return Err(ClientError::InvalidCount(count));
206        }
207        self.driver.request(count).await
208    }
209
210    /// Request a contiguous block of `count` dense ordinals for the given
211    /// sequence `key`.
212    ///
213    /// **Non-idempotent:** if this call returns `ClientError::SeqUncertain` it
214    /// means a commit may or may not have occurred on the server — do NOT
215    /// silently retry, as that would risk spending a second block. The caller
216    /// must resolve the ambiguity (e.g. by reading back the current counter
217    /// with a coordinator-level read) before re-issuing.
218    ///
219    /// All other errors are pre-commit-certain (the server rejected the request
220    /// before any durable advance) and are safe to retry.
221    ///
222    /// The client only pre-rejects universally-invalid inputs — an empty/oversized
223    /// `key` and a zero `count` (a block always covers at least one ordinal).
224    /// The upper bound on `count` is the server's configured
225    /// `ServerBuilder::max_seq_count`, which is deployment-specific, so the client
226    /// does not second-guess it: an over-cap `count` is forwarded and the server
227    /// rejects it with `INVALID_ARGUMENT` (surfaced as `ClientError::Rpc`). This
228    /// keeps a client built against one cap correct against a server configured
229    /// with another.
230    pub async fn get_seq(&self, key: &str, count: u32) -> Result<SeqBlock, ClientError> {
231        if key.is_empty() || key.len() > tsoracle_core::MAX_SEQ_KEY_LEN {
232            return Err(ClientError::InvalidSeqKey);
233        }
234        if count == 0 {
235            return Err(ClientError::InvalidCount(0));
236        }
237        retry::issue_seq_rpc(&self.pool, key, count).await
238    }
239
240    /// Read the leader's current safe-point in physical-millisecond units.
241    ///
242    /// Targets the cached leader if known, otherwise the first configured
243    /// endpoint. Followers return `MaxSafe::max_safe_physical_ms == 0` rather
244    /// than erroring, matching the proto contract; pollers needing freshness
245    /// should target the leader endpoint.
246    ///
247    /// Single-shot by design — the proto contract is "followers return 0"
248    /// rather than NOT_LEADER, so there is no hint to chase and no worklist
249    /// to drain; a caller polling for freshness retries the next tick rather
250    /// than the next endpoint. The one `(connect, RPC)` pair is bounded by
251    /// [`RetryPolicy::per_attempt_deadline`] (shared across both phases via
252    /// `PairBudget`, exactly like one `get_ts` attempt), and a transport-class
253    /// failure evicts the cached channel so a half-open / black-holing
254    /// connection is dropped before the next poll lands on it.
255    pub async fn get_current_max_safe(&self) -> Result<MaxSafe, ClientError> {
256        let endpoint = self
257            .pool
258            .cached_leader()
259            .or_else(|| self.pool.iter_round_robin().into_iter().next())
260            .ok_or(ClientError::NoReachableEndpoints)?;
261        let budget = self.pool.retry_policy().per_attempt_deadline;
262        // One budget shared across connect + RPC: a slow connect eats into
263        // the RPC's time rather than each phase getting a fresh full budget,
264        // so a single call never runs longer than ~`per_attempt_deadline`.
265        let pair = crate::budget::PairBudget::start(budget);
266        let (mut svc, cell) =
267            match tokio::time::timeout(budget, self.pool.client_with_cell(&endpoint)).await {
268                Ok(Ok(leased)) => leased,
269                // `client_with_cell` already evicts its own cell on a failed
270                // dial, so there is nothing to evict here.
271                Ok(Err(err)) => return Err(err),
272                Err(_) => {
273                    return Err(ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
274                        "connect exceeded per_attempt_deadline of {budget:?}"
275                    ))));
276                }
277            };
278        let rpc_budget = pair.remaining();
279        let rpc = svc.get_current_max_safe(tsoracle_proto::v1::GetCurrentMaxSafeRequest {});
280        let err = match tokio::time::timeout(rpc_budget, rpc).await {
281            Ok(Ok(response)) => {
282                let inner = response.into_inner();
283                return Ok(MaxSafe {
284                    max_safe_physical_ms: inner.max_safe_physical_ms,
285                    epoch: Epoch::from_wire(inner.epoch_hi, inner.epoch_lo),
286                });
287            }
288            Ok(Err(status)) => ClientError::Rpc(status),
289            // A timed-out RPC surfaces as `DeadlineExceeded` (transport-class
290            // per `is_transport_failure`), so the eviction tail below drops the
291            // possibly-half-open channel — matching the `get_ts` attempt path.
292            Err(_) => ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
293                "rpc exceeded its share of per_attempt_deadline \
294                 ({rpc_budget:?} of {budget:?})"
295            ))),
296        };
297        if crate::retry_policy::is_transport_failure(&err) {
298            self.pool.evict_if_current(&endpoint, &cell);
299        }
300        Err(err)
301    }
302}
303
304/// The leader's view of the durable safe-point, returned by
305/// [`Client::get_current_max_safe`].
306#[derive(Copy, Clone, Debug, PartialEq, Eq)]
307pub struct MaxSafe {
308    /// Safe-point in physical-millisecond units; `0` is the cold-start sentinel
309    /// (also the value any follower returns).
310    pub max_safe_physical_ms: u64,
311    /// Leader epoch that issued this view.
312    pub epoch: Epoch,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[tokio::test]
320    async fn cached_leader_is_none_before_any_rpc() {
321        // A freshly built client has issued no RPC, so the channel pool's
322        // leader cache is empty and `cached_leader()` reports `None`. This
323        // pins the "nothing observed yet" branch of the diagnostic accessor
324        // without needing a server; the post-RPC `Some(addr)` case is covered
325        // end-to-end in `tsoracle-tests`.
326        let client = Client::connect(vec!["http://127.0.0.1:1".into()])
327            .await
328            .expect("build with a non-empty endpoint list must succeed");
329        assert_eq!(client.cached_leader(), None);
330    }
331
332    #[tokio::test]
333    async fn build_rejects_empty_endpoint_list() {
334        // Validation prevents a Client whose `pool` has no endpoints to try;
335        // every RPC would fail-fast with `NoReachableEndpoints` and burn no
336        // network roundtrips at all, so reject up-front instead.
337        match ClientBuilder::endpoints(Vec::new()).build().await {
338            Err(ClientError::NoReachableEndpoints) => {}
339            Err(other) => panic!("expected NoReachableEndpoints, got {other:?}"),
340            Ok(_) => panic!("expected Err, got Ok(Client)"),
341        }
342    }
343
344    #[tokio::test]
345    async fn channel_connector_error_surfaces_as_connector_variant() {
346        let builder = ClientBuilder::endpoints(vec!["a:1".into()]).channel_connector(
347            |_endpoint: &str| async move {
348                Err::<tonic::transport::Channel, crate::BoxError>(
349                    std::io::Error::other("boom").into(),
350                )
351            },
352        );
353        let client = builder.build().await.expect("build must not fail");
354        let result = client.get_ts().await;
355        match result {
356            Err(ClientError::Connector(inner)) => {
357                assert!(inner.to_string().contains("boom"));
358            }
359            other => panic!("expected ClientError::Connector, got {other:?}"),
360        }
361    }
362
363    // Marker payload used by both last-wins tests. The free function holds
364    // the body in one source location so coverage credit flows from the test
365    // where the closure DOES run; the test that asserts "this never runs"
366    // just calls the same helper.
367    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
368    async fn marker_connector_failure() -> Result<tonic::transport::Channel, crate::BoxError> {
369        Err("MARKER".into())
370    }
371
372    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
373    #[tokio::test]
374    async fn tls_config_then_channel_connector_last_wins() {
375        // channel_connector is set LAST, so its path runs on get_ts. The
376        // marker error surfaces as `ClientError::Connector`, proving the
377        // builder did not silently keep the prior tls_config.
378        let builder = ClientBuilder::endpoints(vec!["a:1".into()])
379            .tls_config(tonic::transport::ClientTlsConfig::new())
380            .channel_connector(|_endpoint: &str| marker_connector_failure());
381        let client = builder.build().await.expect("build must not fail");
382        match client.get_ts().await {
383            Err(ClientError::Connector(inner)) => {
384                assert!(inner.to_string().contains("MARKER"));
385            }
386            other => panic!("expected Connector(MARKER), got {other:?}"),
387        }
388    }
389
390    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
391    #[tokio::test]
392    async fn channel_connector_then_tls_config_last_wins() {
393        // tls_config is set LAST, so the connector path is replaced and its
394        // marker error must NOT surface. The tls_config path produces a
395        // transport-level failure (or NoReachableEndpoints / Rpc) instead.
396        let builder = ClientBuilder::endpoints(vec!["a:1".into()])
397            .channel_connector(|_endpoint: &str| marker_connector_failure())
398            .tls_config(tonic::transport::ClientTlsConfig::new());
399        let client = builder.build().await.expect("build must not fail");
400        let result = client.get_ts().await;
401        if let Err(ClientError::Connector(inner)) = &result
402            && inner.to_string().contains("MARKER")
403        {
404            panic!("tls_config set last must overwrite the prior channel_connector");
405        }
406    }
407
408    #[tokio::test]
409    async fn batch_flush_interval_overrides_default() {
410        // The builder's `batch_flush_interval` knob feeds the driver's
411        // coalescing window; without a test it could silently revert to the
412        // default and no-one would notice from black-box behavior. We
413        // confirm the override path by reaching into the builder fields.
414        let custom = Duration::from_millis(25);
415        let builder = ClientBuilder::endpoints(vec!["http://127.0.0.1:1".into()])
416            .batch_flush_interval(custom);
417        assert_eq!(builder.flush_interval, custom);
418    }
419
420    #[tokio::test]
421    async fn retry_policy_override_propagates_to_builder() {
422        // The builder field is what `build` hands to the pool and retry
423        // loop. If `retry_policy()` ever silently stops storing the
424        // override, the loop reverts to defaults; this test pins the
425        // override path against that.
426        let policy = RetryPolicy {
427            max_attempts: 7,
428            per_attempt_deadline: Duration::from_millis(11),
429            overall_deadline: Duration::from_millis(13),
430            base_backoff: Duration::from_millis(17),
431            leader_ttl: Duration::from_millis(19),
432        };
433        let builder = ClientBuilder::endpoints(vec!["http://127.0.0.1:1".into()])
434            .retry_policy(policy.clone());
435        assert_eq!(builder.retry_policy.max_attempts, policy.max_attempts);
436        assert_eq!(
437            builder.retry_policy.per_attempt_deadline,
438            policy.per_attempt_deadline
439        );
440        assert_eq!(
441            builder.retry_policy.overall_deadline,
442            policy.overall_deadline
443        );
444        assert_eq!(builder.retry_policy.base_backoff, policy.base_backoff);
445        assert_eq!(builder.retry_policy.leader_ttl, policy.leader_ttl);
446    }
447
448    /// Acceptance criterion for the `get_current_max_safe` deadline fix
449    /// (security finding 8c3ea943): a single-shot call against a
450    /// black-holed endpoint must surface `DeadlineExceeded` within the
451    /// configured `per_attempt_deadline`, not park indefinitely on a
452    /// connector whose future never resolves. Exercised through the
453    /// public `channel_connector` surface — `std::future::pending()`
454    /// guarantees the connect future never completes, so any code path
455    /// without an outer `tokio::time::timeout` hangs until an external
456    /// test timeout kills the runtime. The wall-clock bound is generous
457    /// enough to absorb CI scheduler jitter but well below the OS-level
458    /// TCP timeout, mirroring the structure of the `get_ts`
459    /// equivalent below.
460    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
461    async fn get_current_max_safe_returns_within_per_attempt_deadline_when_connector_hangs() {
462        let policy = RetryPolicy {
463            max_attempts: 1,
464            per_attempt_deadline: Duration::from_millis(100),
465            overall_deadline: Duration::from_millis(300),
466            base_backoff: Duration::ZERO,
467            leader_ttl: Duration::from_secs(30),
468        };
469        let client = ClientBuilder::endpoints(vec!["hang:1".into()])
470            .channel_connector(|_endpoint: &str| async {
471                // The future never resolves; only an outer timeout can end
472                // the await. This is the exact attack shape the finding
473                // describes: a user-supplied connector (or a black-holed
474                // peer behind one) for which the caller relied on
475                // `RetryPolicy` to bound the wait.
476                std::future::pending::<Result<tonic::transport::Channel, crate::BoxError>>().await
477            })
478            .retry_policy(policy)
479            .build()
480            .await
481            .expect("builder must accept the policy");
482        // Outer safety timeout: when the fix is missing, `get_current_max_safe`
483        // awaits a never-resolving connector indefinitely, which would hang the
484        // whole test runner rather than fail this case. The 5s ceiling converts
485        // that hang into a clean panic with a diagnostic message. Under a
486        // correctly-deadlined call this outer guard never fires — the inner
487        // future returns in ~100ms, well below the elapsed-time assertion.
488        let outer_safety = Duration::from_secs(5);
489        let start = std::time::Instant::now();
490        let result = match tokio::time::timeout(outer_safety, client.get_current_max_safe()).await {
491            Ok(r) => r,
492            Err(_) => panic!(
493                "get_current_max_safe failed to honor its own per_attempt_deadline; \
494                 the {outer_safety:?} outer safety net had to fire — this is the \
495                 security finding's exact symptom (channel acquisition or RPC was \
496                 never bounded)",
497            ),
498        };
499        let elapsed = start.elapsed();
500        assert!(
501            result.is_err(),
502            "a hanging connector must surface as Err, got {result:?}",
503        );
504        assert!(
505            elapsed < Duration::from_secs(2),
506            "deadline must short-circuit; took {elapsed:?} (per_attempt_deadline was 100ms)",
507        );
508    }
509
510    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
511    async fn get_ts_returns_within_overall_deadline_when_all_endpoints_unreachable() {
512        // End-to-end test of the issue's acceptance criterion: with no
513        // listener bound at the configured endpoints, a `get_ts` call
514        // must return well before the OS-default TCP timeout (~75 s on
515        // Linux). The bound here is generous enough to absorb CI
516        // scheduler jitter — the assertion is "fast", not "exactly the
517        // configured deadline".
518        let policy = RetryPolicy {
519            max_attempts: 3,
520            per_attempt_deadline: Duration::from_millis(100),
521            overall_deadline: Duration::from_millis(300),
522            base_backoff: Duration::ZERO,
523            leader_ttl: Duration::from_secs(30),
524        };
525        let client = ClientBuilder::endpoints(vec![
526            "http://127.0.0.1:1".into(),
527            "http://127.0.0.1:2".into(),
528            "http://127.0.0.1:3".into(),
529        ])
530        .retry_policy(policy)
531        .build()
532        .await
533        .expect("builder must accept the policy");
534        let start = std::time::Instant::now();
535        let result = client.get_ts().await;
536        let elapsed = start.elapsed();
537        assert!(result.is_err(), "no listener can reply: {result:?}");
538        assert!(
539            elapsed < Duration::from_secs(2),
540            "deadline must short-circuit; took {elapsed:?}"
541        );
542    }
543
544    /// Integration test: `get_seq` against a real loopback gRPC server that
545    /// simulates a file-driver leader. The server uses a fake `TsoService`
546    /// with a real `get_seq` implementation (incrementing an atomic counter).
547    /// Asserts: consecutive blocks are gapless, the epoch is propagated, and
548    /// invalid inputs are rejected up-front.
549    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
550    async fn get_seq_returns_gapless_blocks_and_rejects_invalid_inputs() {
551        use std::sync::Arc;
552        use std::sync::atomic::{AtomicU64, Ordering};
553        const EPOCH: u128 = 42;
554
555        let counter = Arc::new(AtomicU64::new(0));
556        let server_counter = counter.clone();
557        // A leader serving gapless blocks from a single atomic counter, rejecting
558        // empty keys / zero counts as InvalidArgument.
559        let addr = crate::test_support::FakeTso::new()
560            .on_get_seq(move |req| {
561                let counter = server_counter.clone();
562                async move {
563                    if req.key.is_empty() {
564                        return Err(tonic::Status::invalid_argument("invalid sequence key"));
565                    }
566                    if req.count == 0 {
567                        return Err(tonic::Status::invalid_argument(
568                            "count must be between 1 and the maximum",
569                        ));
570                    }
571                    let start = counter.fetch_add(u64::from(req.count), Ordering::SeqCst);
572                    let (hi, lo) = tsoracle_core::Epoch(EPOCH).to_wire();
573                    Ok(tsoracle_proto::v1::GetSeqResponse {
574                        key: req.key,
575                        start,
576                        count: req.count,
577                        epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
578                    })
579                }
580            })
581            .spawn()
582            .await;
583
584        let endpoint = format!("http://{addr}");
585        let client = ClientBuilder::endpoints(vec![endpoint])
586            .retry_policy(RetryPolicy {
587                max_attempts: 3,
588                per_attempt_deadline: Duration::from_secs(2),
589                overall_deadline: Duration::from_secs(5),
590                base_backoff: Duration::ZERO,
591                leader_ttl: Duration::from_secs(30),
592            })
593            .build()
594            .await
595            .expect("client must build");
596
597        // First call: block [0, 5).
598        let block1 = client
599            .get_seq("orders", 5)
600            .await
601            .expect("first get_seq must succeed");
602        assert_eq!(block1.start, 0, "first block must start at 0");
603        assert_eq!(block1.count, 5, "first block must have count 5");
604        assert_eq!(block1.epoch, EPOCH, "epoch must match the server's epoch");
605
606        // Second call: block [5, 8) — gapless continuation.
607        let block2 = client
608            .get_seq("orders", 3)
609            .await
610            .expect("second get_seq must succeed");
611        assert_eq!(block2.start, 5, "second block must start at 5 (gapless)");
612        assert_eq!(block2.count, 3);
613        assert_eq!(block2.epoch, EPOCH);
614
615        // Empty key is rejected up-front by the client, not the server.
616        match client.get_seq("", 1).await {
617            Err(ClientError::InvalidSeqKey) => {}
618            other => panic!("empty key must return InvalidSeqKey, got {other:?}"),
619        }
620
621        // Zero count is rejected up-front by the client.
622        match client.get_seq("orders", 0).await {
623            Err(ClientError::InvalidCount(0)) => {}
624            other => panic!("count=0 must return InvalidCount, got {other:?}"),
625        }
626    }
627
628    /// The upper bound on `count` is the server's configured `max_seq_count`, not
629    /// a fixed client-side constant, so the client must forward a large `count`
630    /// rather than short-circuit it with a local `InvalidCount`. Here a count far
631    /// above the old default reaches a cap-less echo server and is served —
632    /// proving the client dropped its hard-coded ceiling check.
633    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
634    async fn get_seq_forwards_large_count_to_server() {
635        let addr = crate::test_support::FakeTso::new()
636            .on_get_seq(|req| async move {
637                let (hi, lo) = tsoracle_core::Epoch(1).to_wire();
638                Ok(tsoracle_proto::v1::GetSeqResponse {
639                    key: req.key,
640                    start: 0,
641                    count: req.count,
642                    epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
643                })
644            })
645            .spawn()
646            .await;
647
648        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
649            .retry_policy(RetryPolicy {
650                max_attempts: 3,
651                per_attempt_deadline: Duration::from_secs(2),
652                overall_deadline: Duration::from_secs(5),
653                base_backoff: Duration::ZERO,
654                leader_ttl: Duration::from_secs(30),
655            })
656            .build()
657            .await
658            .unwrap();
659
660        // Far above the old hard-coded default — would have been a local
661        // InvalidCount before; now it is forwarded and the echo server serves it.
662        let big = tsoracle_core::DEFAULT_MAX_SEQ_COUNT + 1;
663        let block = client
664            .get_seq("orders", big)
665            .await
666            .expect("a large count must be forwarded to the server, not locally rejected");
667        assert_eq!(block.count, big);
668    }
669
670    /// The rejection companion to `get_seq_forwards_large_count_to_server`: when
671    /// the server's configured `ServerBuilder::max_seq_count` is smaller than the
672    /// requested `count`, it rejects with `INVALID_ARGUMENT`
673    /// (`CoreError::SeqCountTooLarge`). Because the client no longer pre-checks the
674    /// upper bound, that rejection must surface through `Client::get_seq` as
675    /// `ClientError::Rpc` preserving the server's message — exactly as the `get_seq`
676    /// doc contract promises — and must NOT be mislabelled `InvalidSeqKey`: a valid
677    /// key was supplied; only the count was over-cap.
678    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
679    async fn get_seq_over_cap_count_surfaces_rpc_invalid_argument() {
680        use std::sync::Arc;
681        use std::sync::atomic::{AtomicU64, Ordering};
682        let calls = Arc::new(AtomicU64::new(0));
683        let server_calls = calls.clone();
684        // Mirrors the server's SeqCountTooLarge → INVALID_ARGUMENT mapping for a
685        // count above the configured max_seq_count. Counts calls to prove the
686        // definitive-error path stays bounded (no unbounded ride-out).
687        let addr = crate::test_support::FakeTso::new()
688            .on_get_seq(move |_req| {
689                let calls = server_calls.clone();
690                async move {
691                    calls.fetch_add(1, Ordering::SeqCst);
692                    Err(tonic::Status::invalid_argument(
693                        "count must be between 1 and the maximum",
694                    ))
695                }
696            })
697            .spawn()
698            .await;
699
700        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
701            .retry_policy(RetryPolicy {
702                max_attempts: 3,
703                per_attempt_deadline: Duration::from_secs(2),
704                overall_deadline: Duration::from_secs(5),
705                base_backoff: Duration::ZERO,
706                leader_ttl: Duration::from_secs(30),
707            })
708            .build()
709            .await
710            .unwrap();
711
712        // A valid key with an over-cap count: the key is fine, so InvalidSeqKey
713        // would be the wrong label — the docs promise ClientError::Rpc.
714        let big = tsoracle_core::DEFAULT_MAX_SEQ_COUNT + 1;
715        match client.get_seq("orders", big).await {
716            Err(ClientError::Rpc(status)) => {
717                assert_eq!(status.code(), tonic::Code::InvalidArgument);
718                assert!(
719                    status.message().contains("maximum"),
720                    "must surface the server's over-cap message, not a synthetic \
721                     or mislabelled error; got {:?}",
722                    status.message()
723                );
724            }
725            other => panic!(
726                "an over-cap count with a valid key must surface as \
727                 ClientError::Rpc(InvalidArgument), not InvalidSeqKey; got {other:?}"
728            ),
729        }
730        // Definitive-error path: bounded attempts, never an unbounded ride-out.
731        let n = calls.load(Ordering::SeqCst);
732        assert!((1..=3).contains(&n), "expected bounded attempts, got {n}");
733    }
734
735    /// Regression guard for the dense classification bug: a bare
736    /// `FailedPrecondition` from the server (NO leader-hint trailer) — e.g.
737    /// `SeqOverflow` — must surface immediately as that definitive error,
738    /// preserving the server's message, NOT be misread as a NOT_LEADER
739    /// "no leader yet" and ridden out across passes.
740    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
741    async fn get_seq_bare_failed_precondition_surfaces_definitive_error() {
742        use std::sync::Arc;
743        use std::sync::atomic::{AtomicU64, Ordering};
744        let calls = Arc::new(AtomicU64::new(0));
745        let server_calls = calls.clone();
746        // Bare FailedPrecondition with NO leader-hint trailer — mirrors the
747        // server's SeqOverflow mapping. Counts calls to prove bounded attempts.
748        let addr = crate::test_support::FakeTso::new()
749            .on_get_seq(move |_req| {
750                let calls = server_calls.clone();
751                async move {
752                    calls.fetch_add(1, Ordering::SeqCst);
753                    Err(tonic::Status::failed_precondition("dense counter overflow"))
754                }
755            })
756            .spawn()
757            .await;
758
759        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
760            .retry_policy(RetryPolicy {
761                max_attempts: 3,
762                per_attempt_deadline: Duration::from_secs(2),
763                overall_deadline: Duration::from_secs(5),
764                base_backoff: Duration::ZERO,
765                leader_ttl: Duration::from_secs(30),
766            })
767            .build()
768            .await
769            .unwrap();
770
771        match client.get_seq("orders", 1).await {
772            Err(ClientError::Rpc(status)) => {
773                assert_eq!(status.code(), tonic::Code::FailedPrecondition);
774                assert!(
775                    status.message().contains("overflow"),
776                    "must surface the server's overflow message, not a synthetic \
777                     'no leader yet'; got {:?}",
778                    status.message()
779                );
780            }
781            other => panic!("expected the overflow FailedPrecondition, got {other:?}"),
782        }
783        // Definitive-error path: bounded attempts, never an unbounded ride-out.
784        let n = calls.load(Ordering::SeqCst);
785        assert!((1..=3).contains(&n), "expected bounded attempts, got {n}");
786    }
787
788    /// The non-idempotency contract: a post-send transport failure
789    /// (`Unavailable` after the request reached the server) is ambiguous — the
790    /// advance may have committed — so `get_seq` returns `SeqUncertain` and must
791    /// NOT transparently retry (a retry could silently spend a second block).
792    /// Asserts the server is invoked exactly once.
793    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
794    async fn get_seq_ambiguous_post_send_failure_is_uncertain_without_retry() {
795        use std::sync::Arc;
796        use std::sync::atomic::{AtomicU64, Ordering};
797        let calls = Arc::new(AtomicU64::new(0));
798        let server_calls = calls.clone();
799        // A post-send transport failure (Unavailable after the request reached
800        // the server) — ambiguous; counts calls to prove no retry.
801        let addr = crate::test_support::FakeTso::new()
802            .on_get_seq(move |_req| {
803                let calls = server_calls.clone();
804                async move {
805                    calls.fetch_add(1, Ordering::SeqCst);
806                    Err(tonic::Status::unavailable("connection reset mid-call"))
807                }
808            })
809            .spawn()
810            .await;
811
812        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
813            .retry_policy(RetryPolicy {
814                max_attempts: 3,
815                per_attempt_deadline: Duration::from_secs(2),
816                overall_deadline: Duration::from_secs(5),
817                base_backoff: Duration::ZERO,
818                leader_ttl: Duration::from_secs(30),
819            })
820            .build()
821            .await
822            .unwrap();
823
824        match client.get_seq("orders", 1).await {
825            Err(ClientError::SeqUncertain) => {}
826            other => panic!("post-send Unavailable must be SeqUncertain, got {other:?}"),
827        }
828        assert_eq!(
829            calls.load(Ordering::SeqCst),
830            1,
831            "non-idempotent get_seq must NOT retry an ambiguous post-send failure"
832        );
833    }
834
835    /// The non-idempotency contract extended to application faults: a post-send
836    /// `INTERNAL` (the server's mapping for a permanent dense driver fault — the
837    /// file driver emits this for a directory-fsync failure that happens *after*
838    /// the durable rename) is ambiguous, not pre-commit-certain. `get_seq` must
839    /// return `SeqUncertain` and invoke the server exactly once, never surfacing
840    /// it as a definitive (caller-retry-safe) error.
841    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
842    async fn get_seq_post_send_internal_is_uncertain_without_retry() {
843        use std::sync::Arc;
844        use std::sync::atomic::{AtomicU64, Ordering};
845        let calls = Arc::new(AtomicU64::new(0));
846        let server_calls = calls.clone();
847        // A post-send INTERNAL (permanent dense driver fault) — ambiguous; counts
848        // calls to prove no retry.
849        let addr = crate::test_support::FakeTso::new()
850            .on_get_seq(move |_req| {
851                let calls = server_calls.clone();
852                async move {
853                    calls.fetch_add(1, Ordering::SeqCst);
854                    Err(tonic::Status::internal("permanent dense driver fault"))
855                }
856            })
857            .spawn()
858            .await;
859
860        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
861            .retry_policy(RetryPolicy {
862                max_attempts: 3,
863                per_attempt_deadline: Duration::from_secs(2),
864                overall_deadline: Duration::from_secs(5),
865                base_backoff: Duration::ZERO,
866                leader_ttl: Duration::from_secs(30),
867            })
868            .build()
869            .await
870            .unwrap();
871
872        match client.get_seq("orders", 1).await {
873            Err(ClientError::SeqUncertain) => {}
874            other => panic!("post-send INTERNAL must be SeqUncertain, got {other:?}"),
875        }
876        assert_eq!(
877            calls.load(Ordering::SeqCst),
878            1,
879            "post-send INTERNAL must NOT be retried (possible committed advance)"
880        );
881    }
882
883    /// A successful `GetSeqResponse` whose `count` does not match the request is
884    /// a protocol violation: the server committed an advance, but the block it
885    /// describes is not the one the caller asked for. The client must not hand
886    /// back a malformed block — it surfaces `SeqUncertain` (a commit occurred,
887    /// reconcile) and invokes the server exactly once.
888    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
889    async fn get_seq_response_count_mismatch_is_uncertain() {
890        use std::sync::Arc;
891        use std::sync::atomic::{AtomicU64, Ordering};
892        let calls = Arc::new(AtomicU64::new(0));
893        let server_calls = calls.clone();
894        // Honour the key but return a count the caller did NOT request — a
895        // protocol violation; counts calls to prove no (double-spending) retry.
896        let addr = crate::test_support::FakeTso::new()
897            .on_get_seq(move |req| {
898                let calls = server_calls.clone();
899                async move {
900                    calls.fetch_add(1, Ordering::SeqCst);
901                    let (hi, lo) = tsoracle_core::Epoch(7).to_wire();
902                    Ok(tsoracle_proto::v1::GetSeqResponse {
903                        key: req.key,
904                        start: 0,
905                        count: req.count + 1,
906                        epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
907                    })
908                }
909            })
910            .spawn()
911            .await;
912
913        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
914            .retry_policy(RetryPolicy {
915                max_attempts: 3,
916                per_attempt_deadline: Duration::from_secs(2),
917                overall_deadline: Duration::from_secs(5),
918                base_backoff: Duration::ZERO,
919                leader_ttl: Duration::from_secs(30),
920            })
921            .build()
922            .await
923            .unwrap();
924
925        match client.get_seq("orders", 5).await {
926            Err(ClientError::SeqUncertain) => {}
927            other => panic!("count-mismatch success must be SeqUncertain, got {other:?}"),
928        }
929        assert_eq!(
930            calls.load(Ordering::SeqCst),
931            1,
932            "a malformed success must not trigger a (double-spending) retry"
933        );
934    }
935
936    /// A successful `GetSeqResponse` echoing a different `key` than requested is
937    /// likewise a protocol violation → `SeqUncertain`, server invoked once.
938    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
939    async fn get_seq_response_key_mismatch_is_uncertain() {
940        use std::sync::Arc;
941        use std::sync::atomic::{AtomicU64, Ordering};
942        let calls = Arc::new(AtomicU64::new(0));
943        let server_calls = calls.clone();
944        // Echo a DIFFERENT key than requested — a protocol violation; counts
945        // calls to prove the malformed success is not retried.
946        let addr = crate::test_support::FakeTso::new()
947            .on_get_seq(move |req| {
948                let calls = server_calls.clone();
949                async move {
950                    calls.fetch_add(1, Ordering::SeqCst);
951                    let (hi, lo) = tsoracle_core::Epoch(7).to_wire();
952                    Ok(tsoracle_proto::v1::GetSeqResponse {
953                        key: format!("{}-tampered", req.key),
954                        start: 0,
955                        count: req.count,
956                        epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
957                    })
958                }
959            })
960            .spawn()
961            .await;
962
963        let client = ClientBuilder::endpoints(vec![format!("http://{addr}")])
964            .retry_policy(RetryPolicy {
965                max_attempts: 3,
966                per_attempt_deadline: Duration::from_secs(2),
967                overall_deadline: Duration::from_secs(5),
968                base_backoff: Duration::ZERO,
969                leader_ttl: Duration::from_secs(30),
970            })
971            .build()
972            .await
973            .unwrap();
974
975        match client.get_seq("orders", 5).await {
976            Err(ClientError::SeqUncertain) => {}
977            other => panic!("key-mismatch success must be SeqUncertain, got {other:?}"),
978        }
979        assert_eq!(
980            calls.load(Ordering::SeqCst),
981            1,
982            "malformed success: one call"
983        );
984    }
985}