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 transport;
40mod worklist;
41
42#[cfg(test)]
43mod test_support;
44
45pub use error::ClientError;
46pub use retry_policy::RetryPolicy;
47pub use transport::BoxError;
48
49use std::sync::Arc;
50use std::time::Duration;
51use tsoracle_core::{Epoch, LOGICAL_MAX, Timestamp};
52
53/// The server's per-call cap on requested timestamps, fixed by the 18-bit
54/// logical width. Callers asking for more than this can't be served by any
55/// single RPC; the client rejects them up-front rather than burning a queue
56/// slot and round-trip to learn the same thing from the server.
57pub(crate) const MAX_TIMESTAMPS_PER_RPC: u32 = LOGICAL_MAX + 1;
58
59use crate::channel_pool::ChannelPool;
60
61pub struct ClientBuilder {
62 endpoints: Vec<String>,
63 flush_interval: Duration,
64 connector: Option<Arc<crate::transport::ChannelConnector>>,
65 tls_required: bool,
66 retry_policy: RetryPolicy,
67}
68
69impl ClientBuilder {
70 pub fn endpoints(endpoints: Vec<String>) -> Self {
71 ClientBuilder {
72 endpoints,
73 flush_interval: Duration::from_millis(1),
74 connector: None,
75 tls_required: false,
76 retry_policy: RetryPolicy::default(),
77 }
78 }
79
80 pub fn batch_flush_interval(mut self, flush_interval: Duration) -> Self {
81 self.flush_interval = flush_interval;
82 self
83 }
84
85 /// Override the default [`RetryPolicy`].
86 ///
87 /// The policy controls per-attempt deadlines, the overall deadline
88 /// across all candidate endpoints, the cap on attempts, and the
89 /// jittered backoff base. The per-attempt deadline is also pushed
90 /// down to `tonic::transport::Endpoint::connect_timeout` and
91 /// `Endpoint::timeout` for the built-in default and TLS transport
92 /// paths so a blackholed peer fails fast at the transport layer.
93 /// User-supplied [`Self::channel_connector`] closures own their
94 /// own Endpoint config; the policy still bounds the retry loop's
95 /// outer `tokio::time::timeout` around them.
96 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
97 self.retry_policy = policy;
98 self
99 }
100
101 /// Configure the client to dial bare endpoints with TLS. Bare `host:port`
102 /// becomes `https://host:port`; explicit `http://...` endpoints supplied
103 /// in [`Self::endpoints`] remain plaintext; explicit `https://...`
104 /// endpoints use the provided TLS config.
105 ///
106 /// Wire-supplied `http://...` leader-hint trailers are NOT honored under
107 /// `tls_config` — they are dropped to prevent a contacted peer from
108 /// downgrading the transport. Operator-supplied configuration still wins;
109 /// untrusted wire input does not.
110 ///
111 /// Setting both [`Self::channel_connector`] and `tls_config` is allowed;
112 /// the last call wins (standard builder semantics). Calling
113 /// `channel_connector` after `tls_config` also clears the
114 /// reject-plaintext-hint policy, since the caller-owned connector owns
115 /// its own scheme policy.
116 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
117 pub fn tls_config(mut self, cfg: tonic::transport::ClientTlsConfig) -> Self {
118 self.connector = Some(crate::transport::tls_connector(
119 cfg,
120 self.retry_policy.clone(),
121 ));
122 self.tls_required = true;
123 self
124 }
125
126 /// Replace the default plaintext channel construction with a caller-owned
127 /// closure. The closure is invoked on first use of each endpoint —
128 /// configured endpoints and leader-hint redirects alike. Errors returned
129 /// from the closure surface as [`ClientError::Connector`].
130 ///
131 /// See module docs for the interaction with [`Self::tls_config`]
132 /// (last-wins) and the scheme matrix. A caller-owned connector replaces
133 /// the built-in TLS plumbing entirely, including the
134 /// reject-plaintext-leader-hint policy — the closure is responsible for
135 /// whatever scheme policy it wants to enforce.
136 pub fn channel_connector<F, Fut>(mut self, connector: F) -> Self
137 where
138 F: Fn(&str) -> Fut + Send + Sync + 'static,
139 Fut: std::future::Future<Output = Result<tonic::transport::Channel, crate::BoxError>>
140 + Send
141 + 'static,
142 {
143 let wrapped: Arc<crate::transport::ChannelConnector> = Arc::new(move |endpoint: &str| {
144 let fut = connector(endpoint);
145 Box::pin(async move { fut.await.map_err(ClientError::Connector) })
146 });
147 self.connector = Some(wrapped);
148 self.tls_required = false;
149 self
150 }
151
152 pub async fn build(self) -> Result<Client, ClientError> {
153 if self.endpoints.is_empty() {
154 return Err(ClientError::NoReachableEndpoints);
155 }
156 let pool = Arc::new(ChannelPool::new(
157 self.endpoints,
158 self.connector,
159 self.tls_required,
160 self.retry_policy,
161 ));
162 let pool_for_rpc = pool.clone();
163 let driver = driver::Driver::spawn(
164 move |count| {
165 let pool = pool_for_rpc.clone();
166 Box::pin(async move { retry::issue_rpc(&pool, count).await })
167 },
168 self.flush_interval,
169 );
170 Ok(Client { pool, driver })
171 }
172}
173
174pub struct Client {
175 pool: Arc<ChannelPool>,
176 driver: driver::Driver,
177}
178
179impl Client {
180 pub async fn connect(endpoints: Vec<String>) -> Result<Self, ClientError> {
181 ClientBuilder::endpoints(endpoints).build().await
182 }
183
184 /// The endpoint the client currently believes is the leader, or `None`
185 /// if no leader has been observed yet or the cached entry has aged past
186 /// the configured `leader_ttl`.
187 ///
188 /// Read-only diagnostic surface for ops dashboards and integration tests
189 /// asserting that a client has converged to the expected leader. It
190 /// reflects the cache as last updated by a completed RPC — it neither
191 /// triggers nor waits on any network round-trip, and the TTL check is
192 /// lazy (an expired entry reads as `None`).
193 pub fn cached_leader(&self) -> Option<String> {
194 self.pool.cached_leader()
195 }
196
197 pub async fn get_ts(&self) -> Result<Timestamp, ClientError> {
198 Ok(self.driver.request(1).await?[0])
199 }
200
201 pub async fn get_ts_batch(&self, count: u32) -> Result<Vec<Timestamp>, ClientError> {
202 if count == 0 || count > MAX_TIMESTAMPS_PER_RPC {
203 return Err(ClientError::InvalidCount(count));
204 }
205 self.driver.request(count).await
206 }
207
208 /// Read the leader's current safe-point in physical-millisecond units.
209 ///
210 /// Targets the cached leader if known, otherwise the first configured
211 /// endpoint. Followers return `MaxSafe::max_safe_physical_ms == 0` rather
212 /// than erroring, matching the proto contract; pollers needing freshness
213 /// should target the leader endpoint.
214 ///
215 /// Single-shot by design — the proto contract is "followers return 0"
216 /// rather than NOT_LEADER, so there is no hint to chase and no worklist
217 /// to drain; a caller polling for freshness retries the next tick rather
218 /// than the next endpoint. The one `(connect, RPC)` pair is bounded by
219 /// [`RetryPolicy::per_attempt_deadline`] (shared across both phases via
220 /// `PairBudget`, exactly like one `get_ts` attempt), and a transport-class
221 /// failure evicts the cached channel so a half-open / black-holing
222 /// connection is dropped before the next poll lands on it.
223 pub async fn get_current_max_safe(&self) -> Result<MaxSafe, ClientError> {
224 let endpoint = self
225 .pool
226 .cached_leader()
227 .or_else(|| self.pool.iter_round_robin().into_iter().next())
228 .ok_or(ClientError::NoReachableEndpoints)?;
229 let budget = self.pool.retry_policy().per_attempt_deadline;
230 // One budget shared across connect + RPC: a slow connect eats into
231 // the RPC's time rather than each phase getting a fresh full budget,
232 // so a single call never runs longer than ~`per_attempt_deadline`.
233 let pair = crate::budget::PairBudget::start(budget);
234 let (mut svc, cell) =
235 match tokio::time::timeout(budget, self.pool.client_with_cell(&endpoint)).await {
236 Ok(Ok(leased)) => leased,
237 // `client_with_cell` already evicts its own cell on a failed
238 // dial, so there is nothing to evict here.
239 Ok(Err(err)) => return Err(err),
240 Err(_) => {
241 return Err(ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
242 "connect exceeded per_attempt_deadline of {budget:?}"
243 ))));
244 }
245 };
246 let rpc_budget = pair.remaining();
247 let rpc = svc.get_current_max_safe(tsoracle_proto::v1::GetCurrentMaxSafeRequest {});
248 let err = match tokio::time::timeout(rpc_budget, rpc).await {
249 Ok(Ok(response)) => {
250 let inner = response.into_inner();
251 return Ok(MaxSafe {
252 max_safe_physical_ms: inner.max_safe_physical_ms,
253 epoch: Epoch::from_wire(inner.epoch_hi, inner.epoch_lo),
254 });
255 }
256 Ok(Err(status)) => ClientError::Rpc(status),
257 // A timed-out RPC surfaces as `DeadlineExceeded` (transport-class
258 // per `is_transport_failure`), so the eviction tail below drops the
259 // possibly-half-open channel — matching the `get_ts` attempt path.
260 Err(_) => ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
261 "rpc exceeded its share of per_attempt_deadline \
262 ({rpc_budget:?} of {budget:?})"
263 ))),
264 };
265 if crate::retry_policy::is_transport_failure(&err) {
266 self.pool.evict_if_current(&endpoint, &cell);
267 }
268 Err(err)
269 }
270}
271
272/// The leader's view of the durable safe-point, returned by
273/// [`Client::get_current_max_safe`].
274#[derive(Copy, Clone, Debug, PartialEq, Eq)]
275pub struct MaxSafe {
276 /// Safe-point in physical-millisecond units; `0` is the cold-start sentinel
277 /// (also the value any follower returns).
278 pub max_safe_physical_ms: u64,
279 /// Leader epoch that issued this view.
280 pub epoch: Epoch,
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[tokio::test]
288 async fn cached_leader_is_none_before_any_rpc() {
289 // A freshly built client has issued no RPC, so the channel pool's
290 // leader cache is empty and `cached_leader()` reports `None`. This
291 // pins the "nothing observed yet" branch of the diagnostic accessor
292 // without needing a server; the post-RPC `Some(addr)` case is covered
293 // end-to-end in `tsoracle-tests`.
294 let client = Client::connect(vec!["http://127.0.0.1:1".into()])
295 .await
296 .expect("build with a non-empty endpoint list must succeed");
297 assert_eq!(client.cached_leader(), None);
298 }
299
300 #[tokio::test]
301 async fn build_rejects_empty_endpoint_list() {
302 // Validation prevents a Client whose `pool` has no endpoints to try;
303 // every RPC would fail-fast with `NoReachableEndpoints` and burn no
304 // network roundtrips at all, so reject up-front instead.
305 match ClientBuilder::endpoints(Vec::new()).build().await {
306 Err(ClientError::NoReachableEndpoints) => {}
307 Err(other) => panic!("expected NoReachableEndpoints, got {other:?}"),
308 Ok(_) => panic!("expected Err, got Ok(Client)"),
309 }
310 }
311
312 #[tokio::test]
313 async fn channel_connector_error_surfaces_as_connector_variant() {
314 let builder = ClientBuilder::endpoints(vec!["a:1".into()]).channel_connector(
315 |_endpoint: &str| async move {
316 Err::<tonic::transport::Channel, crate::BoxError>(
317 std::io::Error::other("boom").into(),
318 )
319 },
320 );
321 let client = builder.build().await.expect("build must not fail");
322 let result = client.get_ts().await;
323 match result {
324 Err(ClientError::Connector(inner)) => {
325 assert!(inner.to_string().contains("boom"));
326 }
327 other => panic!("expected ClientError::Connector, got {other:?}"),
328 }
329 }
330
331 // Marker payload used by both last-wins tests. The free function holds
332 // the body in one source location so coverage credit flows from the test
333 // where the closure DOES run; the test that asserts "this never runs"
334 // just calls the same helper.
335 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
336 async fn marker_connector_failure() -> Result<tonic::transport::Channel, crate::BoxError> {
337 Err("MARKER".into())
338 }
339
340 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
341 #[tokio::test]
342 async fn tls_config_then_channel_connector_last_wins() {
343 // channel_connector is set LAST, so its path runs on get_ts. The
344 // marker error surfaces as `ClientError::Connector`, proving the
345 // builder did not silently keep the prior tls_config.
346 let builder = ClientBuilder::endpoints(vec!["a:1".into()])
347 .tls_config(tonic::transport::ClientTlsConfig::new())
348 .channel_connector(|_endpoint: &str| marker_connector_failure());
349 let client = builder.build().await.expect("build must not fail");
350 match client.get_ts().await {
351 Err(ClientError::Connector(inner)) => {
352 assert!(inner.to_string().contains("MARKER"));
353 }
354 other => panic!("expected Connector(MARKER), got {other:?}"),
355 }
356 }
357
358 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
359 #[tokio::test]
360 async fn channel_connector_then_tls_config_last_wins() {
361 // tls_config is set LAST, so the connector path is replaced and its
362 // marker error must NOT surface. The tls_config path produces a
363 // transport-level failure (or NoReachableEndpoints / Rpc) instead.
364 let builder = ClientBuilder::endpoints(vec!["a:1".into()])
365 .channel_connector(|_endpoint: &str| marker_connector_failure())
366 .tls_config(tonic::transport::ClientTlsConfig::new());
367 let client = builder.build().await.expect("build must not fail");
368 let result = client.get_ts().await;
369 if let Err(ClientError::Connector(inner)) = &result
370 && inner.to_string().contains("MARKER")
371 {
372 panic!("tls_config set last must overwrite the prior channel_connector");
373 }
374 }
375
376 #[tokio::test]
377 async fn batch_flush_interval_overrides_default() {
378 // The builder's `batch_flush_interval` knob feeds the driver's
379 // coalescing window; without a test it could silently revert to the
380 // default and no-one would notice from black-box behavior. We
381 // confirm the override path by reaching into the builder fields.
382 let custom = Duration::from_millis(25);
383 let builder = ClientBuilder::endpoints(vec!["http://127.0.0.1:1".into()])
384 .batch_flush_interval(custom);
385 assert_eq!(builder.flush_interval, custom);
386 }
387
388 #[tokio::test]
389 async fn retry_policy_override_propagates_to_builder() {
390 // The builder field is what `build` hands to the pool and retry
391 // loop. If `retry_policy()` ever silently stops storing the
392 // override, the loop reverts to defaults; this test pins the
393 // override path against that.
394 let policy = RetryPolicy {
395 max_attempts: 7,
396 per_attempt_deadline: Duration::from_millis(11),
397 overall_deadline: Duration::from_millis(13),
398 base_backoff: Duration::from_millis(17),
399 leader_ttl: Duration::from_millis(19),
400 };
401 let builder = ClientBuilder::endpoints(vec!["http://127.0.0.1:1".into()])
402 .retry_policy(policy.clone());
403 assert_eq!(builder.retry_policy.max_attempts, policy.max_attempts);
404 assert_eq!(
405 builder.retry_policy.per_attempt_deadline,
406 policy.per_attempt_deadline
407 );
408 assert_eq!(
409 builder.retry_policy.overall_deadline,
410 policy.overall_deadline
411 );
412 assert_eq!(builder.retry_policy.base_backoff, policy.base_backoff);
413 assert_eq!(builder.retry_policy.leader_ttl, policy.leader_ttl);
414 }
415
416 /// Acceptance criterion for the `get_current_max_safe` deadline fix
417 /// (security finding 8c3ea943): a single-shot call against a
418 /// black-holed endpoint must surface `DeadlineExceeded` within the
419 /// configured `per_attempt_deadline`, not park indefinitely on a
420 /// connector whose future never resolves. Exercised through the
421 /// public `channel_connector` surface — `std::future::pending()`
422 /// guarantees the connect future never completes, so any code path
423 /// without an outer `tokio::time::timeout` hangs until an external
424 /// test timeout kills the runtime. The wall-clock bound is generous
425 /// enough to absorb CI scheduler jitter but well below the OS-level
426 /// TCP timeout, mirroring the structure of the `get_ts`
427 /// equivalent below.
428 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
429 async fn get_current_max_safe_returns_within_per_attempt_deadline_when_connector_hangs() {
430 let policy = RetryPolicy {
431 max_attempts: 1,
432 per_attempt_deadline: Duration::from_millis(100),
433 overall_deadline: Duration::from_millis(300),
434 base_backoff: Duration::ZERO,
435 leader_ttl: Duration::from_secs(30),
436 };
437 let client = ClientBuilder::endpoints(vec!["hang:1".into()])
438 .channel_connector(|_endpoint: &str| async {
439 // The future never resolves; only an outer timeout can end
440 // the await. This is the exact attack shape the finding
441 // describes: a user-supplied connector (or a black-holed
442 // peer behind one) for which the caller relied on
443 // `RetryPolicy` to bound the wait.
444 std::future::pending::<Result<tonic::transport::Channel, crate::BoxError>>().await
445 })
446 .retry_policy(policy)
447 .build()
448 .await
449 .expect("builder must accept the policy");
450 // Outer safety timeout: when the fix is missing, `get_current_max_safe`
451 // awaits a never-resolving connector indefinitely, which would hang the
452 // whole test runner rather than fail this case. The 5s ceiling converts
453 // that hang into a clean panic with a diagnostic message. Under a
454 // correctly-deadlined call this outer guard never fires — the inner
455 // future returns in ~100ms, well below the elapsed-time assertion.
456 let outer_safety = Duration::from_secs(5);
457 let start = std::time::Instant::now();
458 let result = match tokio::time::timeout(outer_safety, client.get_current_max_safe()).await {
459 Ok(r) => r,
460 Err(_) => panic!(
461 "get_current_max_safe failed to honor its own per_attempt_deadline; \
462 the {outer_safety:?} outer safety net had to fire — this is the \
463 security finding's exact symptom (channel acquisition or RPC was \
464 never bounded)",
465 ),
466 };
467 let elapsed = start.elapsed();
468 assert!(
469 result.is_err(),
470 "a hanging connector must surface as Err, got {result:?}",
471 );
472 assert!(
473 elapsed < Duration::from_secs(2),
474 "deadline must short-circuit; took {elapsed:?} (per_attempt_deadline was 100ms)",
475 );
476 }
477
478 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
479 async fn get_ts_returns_within_overall_deadline_when_all_endpoints_unreachable() {
480 // End-to-end test of the issue's acceptance criterion: with no
481 // listener bound at the configured endpoints, a `get_ts` call
482 // must return well before the OS-default TCP timeout (~75 s on
483 // Linux). The bound here is generous enough to absorb CI
484 // scheduler jitter — the assertion is "fast", not "exactly the
485 // configured deadline".
486 let policy = RetryPolicy {
487 max_attempts: 3,
488 per_attempt_deadline: Duration::from_millis(100),
489 overall_deadline: Duration::from_millis(300),
490 base_backoff: Duration::ZERO,
491 leader_ttl: Duration::from_secs(30),
492 };
493 let client = ClientBuilder::endpoints(vec![
494 "http://127.0.0.1:1".into(),
495 "http://127.0.0.1:2".into(),
496 "http://127.0.0.1:3".into(),
497 ])
498 .retry_policy(policy)
499 .build()
500 .await
501 .expect("builder must accept the policy");
502 let start = std::time::Instant::now();
503 let result = client.get_ts().await;
504 let elapsed = start.elapsed();
505 assert!(result.is_err(), "no listener can reply: {result:?}");
506 assert!(
507 elapsed < Duration::from_secs(2),
508 "deadline must short-circuit; took {elapsed:?}"
509 );
510 }
511}