tsoracle_server/server.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
24use core::time::Duration;
25use std::future::Future;
26use std::net::SocketAddr;
27use std::sync::Arc;
28use tokio::sync::watch;
29use tonic::service::Routes;
30use tonic::transport::Server as TonicServer;
31use tsoracle_consensus::ConsensusDriver;
32#[cfg(any(test, feature = "test-fakes"))]
33use tsoracle_core::{CoreError, WindowGrant};
34use tsoracle_core::{
35 DEFAULT_LEASE_TTL_CEILING_MS, DEFAULT_LEASE_TTL_FLOOR_MS, Epoch, PeerEndpoint,
36};
37use tsoracle_proto::v1::tso_service_server::TsoServiceServer;
38
39use crate::bt::Bt;
40use crate::clock::{Clock, SystemClock};
41use crate::service::TsoServiceImpl;
42use crate::serving_core::ServingCore;
43
44#[derive(Debug, thiserror::Error)]
45pub enum BuildError {
46 #[error("consensus_driver is required")]
47 MissingConsensusDriver,
48 /// `max_seq_count` was set to 0. Every positive `count` would then be
49 /// rejected as `SeqCountTooLarge` (the `count >= 1` floor leaves no valid
50 /// value), silently disabling `GetSeq`. Rejected at build time so the
51 /// misconfiguration surfaces immediately rather than at first request.
52 #[error("max_seq_count must be >= 1 (0 would reject every GetSeq request)")]
53 ZeroMaxSeqCount,
54 /// `max_seq_batch_keys` was set to 0. Every `GetSeqBatch` request would
55 /// then be rejected as exceeding the batch-key cap (the minimum is 1
56 /// entry), silently disabling `GetSeqBatch`. Rejected at build time so the
57 /// misconfiguration surfaces immediately rather than at first request.
58 #[error("max_seq_batch_keys must be >= 1 (0 would reject every GetSeqBatch request)")]
59 ZeroMaxSeqBatchKeys,
60 #[error("lease_ttl_floor must be non-zero and no greater than lease_ttl_ceiling")]
61 LeaseTtlBoundsInvalid,
62}
63
64#[derive(Debug, thiserror::Error)]
65pub enum ServerError {
66 #[error("transport: {0}")]
67 Transport(#[from] tonic::transport::Error),
68 #[error("consensus: {0}")]
69 Consensus(#[from] tsoracle_consensus::ConsensusError),
70 #[error("core: {0}")]
71 Core(#[from] tsoracle_core::CoreError),
72 /// The leader-watch task panicked. Distinct from a clean error return so
73 /// operators can tell "driver returned Err" (recoverable design) from
74 /// "task panicked" (programming bug).
75 #[error("leader-watch task panicked: {payload}{bt}")]
76 WatchPanic { payload: String, bt: Bt },
77 /// The consensus driver's `leadership_events()` stream ended cleanly while
78 /// the leader-watch task was running. The stream is contracted to live for
79 /// the life of the server, so its end is anomalous (driver shutdown, lost
80 /// session, etc.) — distinct from a `Consensus` error returned mid-fence.
81 /// The watch task publishes `ServingState::NotServing` before returning
82 /// this variant so embedders who never observe the [`WatchGuard`] still get
83 /// the documented fail-safe behavior.
84 #[error("consensus driver leadership stream closed")]
85 WatchStreamClosed,
86 /// The embedded protobuf descriptor set failed to decode while building the
87 /// gRPC reflection service. `tsoracle-proto`'s `build.rs` emits these bytes
88 /// from checked-in `.proto` sources, so a failure here signals build-artifact
89 /// drift (a corrupt or stale descriptor) rather than a runtime condition —
90 /// surfaced as a diagnosable startup error instead of a process panic.
91 #[cfg(feature = "reflection")]
92 #[error("failed to build gRPC reflection service from embedded descriptor set: {0}")]
93 ReflectionInit(#[source] tonic_reflection::server::Error),
94}
95
96#[derive(Clone, Debug)]
97pub enum ServingState {
98 NotServing {
99 leader_endpoint: Option<PeerEndpoint>,
100 leader_epoch: Option<Epoch>,
101 },
102 Serving,
103}
104
105/// Default bound on how long a graceful shutdown waits for the leader-watch
106/// task to stop cooperatively before forcibly aborting it. The abort is a
107/// last-resort safety net for a consensus driver whose `load_high_water` /
108/// `persist_high_water` is wedged (the trait places no latency bound; see
109/// [`ConsensusDriver`]). Chosen to sit comfortably under a typical Kubernetes
110/// `terminationGracePeriodSeconds` (30s) so the abort, the tonic drain, and
111/// process exit all complete before the kubelet escalates to SIGKILL.
112const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
113
114pub struct ServerBuilder {
115 consensus: Option<Arc<dyn ConsensusDriver>>,
116 clock: Option<Arc<dyn Clock>>,
117 window_ahead: Duration,
118 failover_advance: Duration,
119 shutdown_grace: Duration,
120 heartbeat_interval: Duration,
121 max_seq_count: u32,
122 max_seq_batch_keys: u32,
123 lease_ttl_floor: Duration,
124 lease_ttl_ceiling: Duration,
125 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
126 tls_config: Option<tonic::transport::ServerTlsConfig>,
127}
128
129impl Default for ServerBuilder {
130 fn default() -> Self {
131 ServerBuilder {
132 consensus: None,
133 clock: None,
134 window_ahead: Duration::from_secs(3),
135 failover_advance: Duration::from_secs(1),
136 shutdown_grace: DEFAULT_SHUTDOWN_GRACE,
137 heartbeat_interval: Duration::from_secs(10),
138 max_seq_count: tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
139 max_seq_batch_keys: tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
140 lease_ttl_floor: Duration::from_millis(DEFAULT_LEASE_TTL_FLOOR_MS),
141 lease_ttl_ceiling: Duration::from_millis(DEFAULT_LEASE_TTL_CEILING_MS),
142 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
143 tls_config: None,
144 }
145 }
146}
147
148impl ServerBuilder {
149 pub fn consensus_driver(mut self, driver: Arc<dyn ConsensusDriver>) -> Self {
150 self.consensus = Some(driver);
151 self
152 }
153 pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
154 self.clock = Some(clock);
155 self
156 }
157 pub fn window_ahead(mut self, window_ahead: Duration) -> Self {
158 self.window_ahead = window_ahead;
159 self
160 }
161 pub fn failover_advance(mut self, failover_advance: Duration) -> Self {
162 self.failover_advance = failover_advance;
163 self
164 }
165
166 /// Server-enforced lower bound on requested lease TTLs.
167 pub fn lease_ttl_floor(mut self, lease_ttl_floor: Duration) -> Self {
168 self.lease_ttl_floor = lease_ttl_floor;
169 self
170 }
171
172 /// Server-enforced upper bound on requested lease TTLs.
173 pub fn lease_ttl_ceiling(mut self, lease_ttl_ceiling: Duration) -> Self {
174 self.lease_ttl_ceiling = lease_ttl_ceiling;
175 self
176 }
177
178 /// Bound on how long a graceful shutdown waits for the leader-watch task to
179 /// stop cooperatively before forcibly aborting it.
180 ///
181 /// On shutdown the server drops the watch task's cancel signal and waits for
182 /// it to publish `NotServing` and return. That wait is normally
183 /// near-instant, but the task observes cancellation only at its `select!`
184 /// boundaries — never inside a fence attempt. A consensus driver whose
185 /// `load_high_water` / `persist_high_water` never returns (the trait places
186 /// no latency bound) would otherwise park the task mid-fence and block
187 /// process exit indefinitely, leading to a SIGKILL on a Kubernetes drain.
188 /// Once `shutdown_grace` elapses the server aborts the task so exit always
189 /// makes progress. Set this comfortably below your deployment's
190 /// `terminationGracePeriodSeconds`. Defaults to 10s. A value of zero aborts
191 /// immediately without waiting for a cooperative stop.
192 pub fn shutdown_grace(mut self, shutdown_grace: Duration) -> Self {
193 self.shutdown_grace = shutdown_grace;
194 self
195 }
196
197 /// Interval between heartbeat log lines emitted at `target = "tsoracle::heartbeat"`.
198 /// Defaults to 10 seconds. Pass `Duration::ZERO` to disable the heartbeat task entirely.
199 ///
200 /// The heartbeat surfaces serving role, current epoch, requests served,
201 /// timestamps issued, and key error counters every interval — proof-of-life
202 /// for production deployments that may not have a metrics exporter installed.
203 ///
204 /// Requires `feature = "tracing"` to emit output; with `tracing` off the
205 /// setter is accepted but no task is spawned (no subscriber to log to).
206 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
207 self.heartbeat_interval = interval;
208 self
209 }
210
211 /// Configure TLS termination for this server. Applied inside
212 /// [`Server::serve`], [`Server::serve_with_shutdown`], and
213 /// [`Server::serve_with_listener`]. Not applied to [`Server::into_router`] —
214 /// embedders mounting tsoracle alongside their own services control TLS
215 /// on their own tonic builder.
216 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
217 pub fn tls_config(mut self, cfg: tonic::transport::ServerTlsConfig) -> Self {
218 self.tls_config = Some(cfg);
219 self
220 }
221
222 /// Per-call ceiling on `GetSeq`'s `count` — the largest contiguous block a
223 /// single dense-sequence request may reserve. Defaults to
224 /// [`tsoracle_core::DEFAULT_MAX_SEQ_COUNT`] (`65_536`).
225 ///
226 /// This is a soft anti-abuse guardrail, not a representational limit: a
227 /// dense block is permanently consumed (the gapless counter only moves
228 /// forward), so the cap bounds how much one call can irrevocably reserve.
229 /// Raise it for batch-allocation workloads or lower it to tighten abuse
230 /// control. The server is the sole authority — clients no longer pre-check
231 /// `count` against a fixed constant, so changing this needs no client
232 /// rebuild; an over-cap request is rejected with `INVALID_ARGUMENT`. The
233 /// `count >= 1` floor is always enforced regardless of this value.
234 ///
235 /// A value of `0` is rejected by [`Self::build`] with
236 /// [`BuildError::ZeroMaxSeqCount`]: it would leave no valid `count` (the
237 /// floor is 1), silently disabling `GetSeq`, so the misconfiguration is
238 /// surfaced at build time rather than at first request.
239 pub fn max_seq_count(mut self, max_seq_count: u32) -> Self {
240 self.max_seq_count = max_seq_count;
241 self
242 }
243
244 /// Cap on the number of `(key, count)` entries accepted in one
245 /// `GetSeqBatch` request. Defaults to
246 /// [`tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS`] (`128`).
247 ///
248 /// This is a soft anti-abuse guardrail bounding fan-out and the size of
249 /// one atomic consensus entry. A value of `0` is rejected by
250 /// [`Self::build`] with [`BuildError::ZeroMaxSeqBatchKeys`]: it would
251 /// make every `GetSeqBatch` call fail, silently disabling the RPC, so the
252 /// misconfiguration is surfaced at build time. Deployment-specific tuning;
253 /// clients need no rebuild after changing this.
254 pub fn max_seq_batch_keys(mut self, max_seq_batch_keys: u32) -> Self {
255 self.max_seq_batch_keys = max_seq_batch_keys;
256 self
257 }
258
259 pub fn build(self) -> Result<Server, BuildError> {
260 let consensus = self.consensus.ok_or(BuildError::MissingConsensusDriver)?;
261 if self.max_seq_count == 0 {
262 return Err(BuildError::ZeroMaxSeqCount);
263 }
264 if self.max_seq_batch_keys == 0 {
265 return Err(BuildError::ZeroMaxSeqBatchKeys);
266 }
267 if self.lease_ttl_floor.is_zero() || self.lease_ttl_floor > self.lease_ttl_ceiling {
268 return Err(BuildError::LeaseTtlBoundsInvalid);
269 }
270 let clock = self.clock.unwrap_or_else(|| Arc::new(SystemClock));
271 Ok(Server {
272 consensus,
273 clock,
274 window_ahead: self.window_ahead,
275 failover_advance: self.failover_advance,
276 shutdown_grace: self.shutdown_grace,
277 heartbeat_interval: self.heartbeat_interval,
278 lease_ttl_floor: self.lease_ttl_floor,
279 lease_ttl_ceiling: self.lease_ttl_ceiling,
280 core: Arc::new(ServingCore::new(
281 self.window_ahead,
282 self.max_seq_count,
283 self.max_seq_batch_keys,
284 )),
285 reporter: Arc::new(crate::reporter::Reporter::new()),
286 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
287 tls_config: self.tls_config,
288 })
289 }
290}
291
292pub struct Server {
293 pub(crate) consensus: Arc<dyn ConsensusDriver>,
294 pub(crate) clock: Arc<dyn Clock>,
295 pub(crate) window_ahead: Duration,
296 pub(crate) failover_advance: Duration,
297 pub(crate) lease_ttl_floor: Duration,
298 pub(crate) lease_ttl_ceiling: Duration,
299 /// Bound on the graceful-shutdown wait for the leader-watch task before a
300 /// forced abort. See [`ServerBuilder::shutdown_grace`].
301 pub(crate) shutdown_grace: Duration,
302 /// Interval between periodic heartbeat log lines. See [`ServerBuilder::heartbeat_interval`].
303 ///
304 /// The only reader is the `cfg(feature = "tracing")` spawn block in
305 /// `into_router_parts`; without `tracing` there is no subscriber to log to
306 /// and the spawn arm is compiled out, so the field is genuinely unread.
307 #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
308 pub(crate) heartbeat_interval: Duration,
309 /// Owns the allocator, serving-state channel, and both extension locks, with
310 /// the lock-ordering and step-down invariants private behind its methods.
311 ///
312 /// Held behind an `Arc` so the leader-watch task, the gRPC service, and the
313 /// [`WatchGuard`] / [`serve_inner`] shutdown paths can all reach the same
314 /// core. The guard and the serve loop use their clone to close the serving
315 /// gate *synchronously* at shutdown, leaving the watch task's later
316 /// `step_down` a harmless idempotent repeat.
317 pub(crate) core: Arc<ServingCore>,
318 pub(crate) reporter: Arc<crate::reporter::Reporter>,
319 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
320 pub(crate) tls_config: Option<tonic::transport::ServerTlsConfig>,
321}
322
323/// Raw parts produced by [`Server::into_router_parts`]: the gRPC `Routes`, the
324/// leader-watch task's cooperative-cancel sender (dropping it stops the task),
325/// the task's join handle, and the optional heartbeat task's cancel sender /
326/// join handle. [`Server::into_router`] wraps these into a [`WatchGuard`]; the
327/// `serve_*` methods consume them directly via [`serve_inner`].
328///
329/// The heartbeat fields are `None` when the heartbeat is disabled — either by
330/// `ServerBuilder::heartbeat_interval(Duration::ZERO)` or by building without
331/// the `tracing` feature (there is no subscriber to log to).
332pub(crate) struct RouterParts {
333 pub routes: Routes,
334 pub cancel_tx: tokio::sync::oneshot::Sender<()>,
335 pub watch_handle: tokio::task::JoinHandle<Result<(), ServerError>>,
336 pub heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
337 pub heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
338}
339
340impl Server {
341 pub fn builder() -> ServerBuilder {
342 ServerBuilder::default()
343 }
344
345 /// Subscribe to serving-state transitions.
346 ///
347 /// Returns a fresh `watch::Receiver` observing the same `ServingState`
348 /// the server publishes as leadership comes and goes. Embedders use this
349 /// to gate their own startup on `ServingState::Serving` (see the
350 /// `embedded_router` and piggyback examples). Because `into_router`
351 /// consumes the `Server`, capture the receiver before mounting.
352 ///
353 /// This method is the stable observation API: the receiver is minted from
354 /// the server's `watch::Sender`, so the receiver's type can evolve (e.g. a
355 /// future newtype around `ServingState`) without breaking embedders that
356 /// go through it.
357 pub fn subscribe(&self) -> watch::Receiver<ServingState> {
358 self.core.subscribe()
359 }
360}
361
362impl Server {
363 /// Return the configured `TsoServiceServer<TsoServiceImpl>` as a tonic
364 /// `Routes` value plus a [`WatchGuard`] for the spawned leader-watch task,
365 /// so callers can mount tsoracle's service alongside their own services
366 /// on a shared tonic listener instead of binding a dedicated port.
367 ///
368 /// The returned [`WatchGuard`] owns the leader-watch task. **Keep it alive
369 /// for as long as the mounted `Routes` should serve**: the watch task holds
370 /// an `Arc<Server>` (and the consensus driver) and maintains serving state
371 /// across leadership transitions. Dropping the guard — or calling
372 /// [`WatchGuard::shutdown`] — cooperatively stops the task at the embedder's
373 /// own shutdown. Without the guard the task would keep `Arc<Server>` alive
374 /// until the leadership stream happened to close.
375 ///
376 /// Every termination of the task — cooperative cancellation, driver error,
377 /// panic, or clean EOF on the leadership stream (surfaced as
378 /// `ServerError::WatchStreamClosed`) — publishes
379 /// `ServingState::NotServing { leader_endpoint: None }` before returning, so
380 /// all subsequent RPCs fail fast with `FAILED_PRECONDITION`. Embedders who
381 /// drop the guard without awaiting still get fail-safe behavior.
382 ///
383 /// The `Server::serve()` method is a thin wrapper over this — it calls
384 /// `into_router`, builds a tonic `Server`, and binds a listener.
385 ///
386 /// Returns `Err(ServerError::ReflectionInit)` (only reachable under the
387 /// `reflection` feature) if the embedded descriptor set fails to decode.
388 /// That decode happens before the leader-watch task is spawned, so a failure
389 /// leaves nothing running to clean up.
390 pub fn into_router(self) -> Result<(Routes, WatchGuard), ServerError> {
391 // Read the `Copy` grace before `into_router_parts` consumes `self`, so
392 // the returned guard can bound its own shutdown wait identically to the
393 // `serve_*` paths.
394 let shutdown_grace = self.shutdown_grace;
395 // Clone the shared core and reporter before `into_router_parts` consumes
396 // `self`, so the guard can close the serving gate synchronously on drop /
397 // shutdown rather than relying on the watch task's later publish, and can
398 // record the shutdown_watch_aborted counter if the grace-bounded reap fires.
399 let core = self.core.clone();
400 let reporter = self.reporter.clone();
401 let parts = self.into_router_parts()?;
402 Ok((
403 parts.routes,
404 WatchGuard {
405 cancel_tx: Some(parts.cancel_tx),
406 handle: Some(parts.watch_handle),
407 shutdown_grace,
408 core,
409 reporter,
410 heartbeat_cancel_tx: parts.heartbeat_cancel_tx,
411 heartbeat_handle: parts.heartbeat_handle,
412 },
413 ))
414 }
415
416 /// Spawn the leader-watch task and assemble the gRPC `Routes`, returning
417 /// the raw parts: the routes, the task's cooperative-cancel sender, and its
418 /// `JoinHandle`. [`Self::into_router`] wraps these into a [`WatchGuard`] for
419 /// embedders; the `serve_*` methods drive the parts directly via
420 /// [`serve_inner`], so neither path needs to unwrap the guard's `Option`
421 /// fields.
422 fn into_router_parts(self) -> Result<RouterParts, ServerError> {
423 // Build the reflection service first: a descriptor-decode failure must
424 // surface before we spawn the leader-watch task below, so an error path
425 // never leaks a running task.
426 #[cfg(feature = "reflection")]
427 let reflection = build_reflection_service(tsoracle_proto::FILE_DESCRIPTOR_SET)?;
428
429 let server = Arc::new(self);
430
431 // Cooperative cancellation channel. The `WatchGuard` holds the sender;
432 // the task's `cancel` future resolves on either an explicit send or a
433 // sender drop, so dropping the guard is sufficient to stop the task.
434 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
435
436 let watch_server = server.clone();
437 let watch_handle = tokio::spawn(async move {
438 use futures::FutureExt;
439 // Resolves when the WatchGuard signals cancellation or is dropped.
440 let cancel = async move {
441 let _ = cancel_rx.await;
442 };
443 // catch_unwind so a panic in run_leader_watch still routes through
444 // the poisoning path. Without this, embedders who mount into_router
445 // directly and never observe the guard would see
446 // ServingState::Serving remain published while the watch task is
447 // dead — the inverse of the fail-safe guarantee documented above.
448 // The panic is re-raised after poisoning so serve / serve_with_*
449 // continue to translate it into ServerError::WatchPanic via
450 // join_to_server_result.
451 let outcome = std::panic::AssertUnwindSafe(crate::fence::run_leader_watch(
452 watch_server.clone(),
453 cancel,
454 ))
455 .catch_unwind()
456 .await;
457 match outcome {
458 Ok(result) => {
459 if let Err(ref _e) = result {
460 // Poison BEFORE returning so embedders who do not observe
461 // the guard still get fail-safe behavior.
462 watch_server.core.step_down(None, None);
463 #[cfg(feature = "tracing")]
464 tracing::error!(error = %_e, "leader-watch terminated; serving disabled");
465 }
466 result
467 }
468 Err(panic_payload) => {
469 // Mirror the Err branch: poison BEFORE re-raising so
470 // guard-dropping embedders still observe NotServing.
471 watch_server.core.step_down(None, None);
472 #[cfg(feature = "tracing")]
473 tracing::error!("leader-watch panicked; serving disabled");
474 std::panic::resume_unwind(panic_payload);
475 }
476 }
477 });
478
479 // Spawn the heartbeat task, if enabled. Gated on `feature = "tracing"`
480 // because the heartbeat module is only compiled with `tracing`
481 // (no subscriber to emit to without it) — and on a non-zero interval,
482 // since `Duration::ZERO` is the documented opt-out.
483 //
484 // The task body is wrapped in `AssertUnwindSafe(...).catch_unwind()`
485 // mirroring the leader-watch spawn above: on panic we bump the
486 // `heartbeat_task_panicked` counter and log at error level, then let
487 // the task end (no restart — the heartbeat is observability, not
488 // correctness, so a panicked task must not be allowed to thrash).
489 let (heartbeat_cancel_tx, heartbeat_handle) = {
490 #[cfg(feature = "tracing")]
491 {
492 if server.heartbeat_interval.is_zero() {
493 (None, None)
494 } else {
495 use futures::FutureExt;
496 let (htx, hrx) = tokio::sync::oneshot::channel::<()>();
497 let hb_reporter = server.reporter.clone();
498 let hb_core = server.core.clone();
499 let hb_interval = server.heartbeat_interval;
500 let handle = tokio::spawn(async move {
501 let outcome =
502 std::panic::AssertUnwindSafe(crate::heartbeat::run_heartbeat(
503 hb_interval,
504 hb_core,
505 hb_reporter.clone(),
506 hrx,
507 ))
508 .catch_unwind()
509 .await;
510 if outcome.is_err() {
511 hb_reporter.heartbeat_task_panicked.increment(1);
512 tracing::error!(
513 target: "tsoracle::heartbeat",
514 "heartbeat task panicked; liveness logs disabled until restart"
515 );
516 }
517 });
518 (Some(htx), Some(handle))
519 }
520 }
521 #[cfg(not(feature = "tracing"))]
522 {
523 (None, None)
524 }
525 };
526
527 let service = TsoServiceImpl { server };
528 #[allow(unused_mut)]
529 let mut routes = Routes::new(TsoServiceServer::new(service));
530 #[cfg(feature = "reflection")]
531 {
532 routes = routes.add_service(reflection);
533 }
534 Ok(RouterParts {
535 routes,
536 cancel_tx,
537 watch_handle,
538 heartbeat_cancel_tx,
539 heartbeat_handle,
540 })
541 }
542
543 pub async fn serve(self, addr: SocketAddr) -> Result<(), ServerError> {
544 self.serve_with_shutdown(addr, futures::future::pending())
545 .await
546 }
547
548 /// Run the gRPC server until either the caller's `shutdown` fires or the
549 /// leader-watch task terminates.
550 ///
551 /// Three outcomes:
552 /// 1. `shutdown` fires first → tonic drains in-flights and returns Ok.
553 /// The watch task is then stopped cooperatively, bounded by
554 /// `shutdown_grace` and forcibly aborted if it overruns (e.g. parked in a
555 /// wedged consensus-driver call); any error it had been about to return
556 /// is forfeited (the process is shutting down anyway).
557 /// 2. Watch returns `Ok(Err(e))` → poisoned state is already published;
558 /// `cancel_tx` triggers tonic's graceful shutdown; in-flight `GetTs`
559 /// calls whose `try_grant` already succeeded complete with the
560 /// timestamps they were allocated; new calls fail fast. Returns `Err(e)`
561 /// — the watch error wins even if the drain itself also errors (see
562 /// `combine_watch_and_drain`); a drain error is surfaced only when the
563 /// watch ended cleanly.
564 /// 3. Watch task panics → returns `Err(ServerError::WatchPanic{..})`
565 /// with the panic payload stringified. Same drain semantics as (2).
566 pub async fn serve_with_shutdown(
567 self,
568 addr: SocketAddr,
569 shutdown: impl Future<Output = ()> + Send + 'static,
570 ) -> Result<(), ServerError> {
571 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
572 let tls_config = self.tls_config.clone();
573
574 // Read the `Copy` grace and clone the shared core and reporter before
575 // `into_router_parts` consumes `self`.
576 let shutdown_grace = self.shutdown_grace;
577 let core = self.core.clone();
578 let reporter = self.reporter.clone();
579 let parts = self.into_router_parts()?;
580 let (combined_shutdown, cancel_tx) = combined_shutdown_with_cancel(shutdown);
581
582 let mut tonic = TonicServer::builder();
583 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
584 if let Some(cfg) = tls_config {
585 tonic = tonic.tls_config(cfg).map_err(ServerError::Transport)?;
586 }
587 let serve = tonic
588 .add_routes(parts.routes)
589 .serve_with_shutdown(addr, combined_shutdown);
590
591 serve_inner(
592 parts.cancel_tx,
593 parts.watch_handle,
594 parts.heartbeat_cancel_tx,
595 parts.heartbeat_handle,
596 serve,
597 cancel_tx,
598 shutdown_grace,
599 core,
600 reporter,
601 )
602 .await
603 }
604
605 /// Run the gRPC server on a caller-provided `TcpListener` until either
606 /// the caller-provided `shutdown` fires or the leader-watch task terminates.
607 ///
608 /// Use this instead of [`Self::serve_with_shutdown`] when you need to
609 /// observe the OS-picked port (`127.0.0.1:0`) before clients connect, or
610 /// when you want to wrap the listener in an outer adapter before passing it
611 /// in. The listening socket is owned by the caller and passed here; tsoracle
612 /// starts accepting on it immediately.
613 ///
614 /// Three outcomes:
615 /// 1. `shutdown` fires first → tonic drains in-flights and returns `Ok`.
616 /// The watch handle is aborted; any error it had been about to return
617 /// is forfeited (the process is shutting down anyway).
618 /// 2. Watch returns `Ok(Err(e))` → poisoned state is already published;
619 /// the caller-provided shutdown is cancelled internally so tonic begins
620 /// graceful shutdown; in-flight `GetTs` calls whose `try_grant` already
621 /// succeeded complete with the timestamps they were allocated; new calls
622 /// fail fast. Returns `Err(e)` — the watch error wins even if the drain
623 /// itself also errors (see `combine_watch_and_drain`); a drain error is
624 /// surfaced only when the watch ended cleanly.
625 /// 3. Watch task panics → returns `Err(ServerError::WatchPanic{..})`
626 /// with the panic payload stringified. Same drain semantics as (2).
627 pub async fn serve_with_listener(
628 self,
629 listener: tokio::net::TcpListener,
630 shutdown: impl Future<Output = ()> + Send + 'static,
631 ) -> Result<(), ServerError> {
632 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
633 let tls_config = self.tls_config.clone();
634
635 // Read the `Copy` grace and clone the shared core and reporter before
636 // `into_router_parts` consumes `self`.
637 let shutdown_grace = self.shutdown_grace;
638 let core = self.core.clone();
639 let reporter = self.reporter.clone();
640 let parts = self.into_router_parts()?;
641 let (combined_shutdown, cancel_tx) = combined_shutdown_with_cancel(shutdown);
642
643 let incoming = tonic::transport::server::TcpIncoming::from(listener);
644
645 let mut tonic = TonicServer::builder();
646 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
647 if let Some(cfg) = tls_config {
648 tonic = tonic.tls_config(cfg).map_err(ServerError::Transport)?;
649 }
650 let serve = tonic
651 .add_routes(parts.routes)
652 .serve_with_incoming_shutdown(incoming, combined_shutdown);
653
654 serve_inner(
655 parts.cancel_tx,
656 parts.watch_handle,
657 parts.heartbeat_cancel_tx,
658 parts.heartbeat_handle,
659 serve,
660 cancel_tx,
661 shutdown_grace,
662 core,
663 reporter,
664 )
665 .await
666 }
667}
668
669/// RAII handle to the leader-watch task spawned by [`Server::into_router`].
670///
671/// The watch task holds an `Arc<Server>` (and thus the consensus driver) and
672/// maintains serving state across leadership transitions. This guard ties the
673/// task's lifetime to the guard's: dropping it cooperatively cancels the task,
674/// and the task publishes [`ServingState::NotServing`] before it stops, so any
675/// `Routes` an embedder still has mounted fails subsequent RPCs fast.
676///
677/// Cancellation is cooperative — the task stops at its next await boundary and
678/// never mid-fence, so it is never torn down while holding internal locks, in
679/// contrast to a raw [`tokio::task::JoinHandle::abort`].
680pub struct WatchGuard {
681 // `Option` so `Drop` and the consuming `shutdown` / `abort` methods can
682 // each take a field without a partial-move conflict against the `Drop`
683 // impl. Dropping the sender (rather than sending) is itself the cancel
684 // signal: the task's `cancel` future resolves on sender-drop too.
685 cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
686 handle: Option<tokio::task::JoinHandle<Result<(), ServerError>>>,
687 /// Bound on the cooperative-stop wait in [`Self::shutdown`] before a forced
688 /// abort. Inherited from [`ServerBuilder::shutdown_grace`].
689 shutdown_grace: Duration,
690 /// Shared serving core, cloned from the `Server`. Lets `Drop` (and the
691 /// consuming `shutdown` / `abort`, which trigger `Drop` on return) close the
692 /// serving gate synchronously at the drop site, instead of waiting for the
693 /// watch task to observe cancellation and publish `NotServing` on its own
694 /// timeline — a window during which the fast gate would still admit RPCs.
695 core: Arc<ServingCore>,
696 /// Metrics reporter, cloned from the `Server`. Used to record the
697 /// `shutdown_watch_aborted` counter if the grace-bounded reap fires.
698 reporter: Arc<crate::reporter::Reporter>,
699 /// Cooperative-cancel sender for the heartbeat task. `None` when the
700 /// heartbeat is disabled (interval == 0 or built without `tracing`).
701 /// Dropping the sender resolves the task's cancel future.
702 heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
703 /// Join handle for the heartbeat task. `None` when the heartbeat is
704 /// disabled. Output is `()` because the task never returns an error —
705 /// panics are caught inside the task body and recorded via the
706 /// `heartbeat_task_panicked` counter.
707 heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
708}
709
710impl WatchGuard {
711 /// Signal the leader-watch task to stop, wait for it to drain, and report
712 /// its outcome.
713 ///
714 /// A cooperatively cancelled task returns `Ok(())` — the stop was
715 /// requested, so it is not an error. If the task had already terminated on
716 /// its own (driver error, stream EOF, or panic) the original outcome is
717 /// surfaced verbatim: `Err(e)` or [`ServerError::WatchPanic`]. Either way
718 /// serving state is `NotServing` once this returns.
719 ///
720 /// The cooperative wait is bounded by the configured
721 /// [`ServerBuilder::shutdown_grace`]: if the task is parked in a
722 /// consensus-driver call that never returns it is aborted once the grace
723 /// elapses (still reported as `Ok(())`), so an embedder's shutdown can never
724 /// wedge behind a hung driver.
725 pub async fn shutdown(mut self) -> Result<(), ServerError> {
726 // Dropping the senders fires each task's cancel future. The heartbeat
727 // task is reaped first because it is bounded by `tokio::time::sleep`
728 // (cooperative stop is fast and never wedges on a driver call), so its
729 // reap returns quickly and leaves the grace budget for the watch task
730 // — which may be parked in a wedged consensus-driver call.
731 self.heartbeat_cancel_tx.take();
732 self.cancel_tx.take();
733 if let Some(mut hb_handle) = self.heartbeat_handle.take() {
734 match tokio::time::timeout(self.shutdown_grace, &mut hb_handle).await {
735 Ok(Ok(())) => {}
736 // Task panicked — already counted + logged via catch_unwind in
737 // the task body. Nothing more to do here.
738 Ok(Err(_join_err)) => {}
739 // Grace overrun — sleep + select! should always observe a
740 // dropped cancel sender, so this is a backstop. Abort and
741 // reap; no separate metric (the heartbeat is observability
742 // only — its lateness is not a serving correctness signal).
743 Err(_elapsed) => {
744 hb_handle.abort();
745 let _ = (&mut hb_handle).await;
746 }
747 }
748 }
749 match self.handle.take() {
750 Some(mut handle) => join_to_server_result(
751 await_watch_within_grace(&mut handle, self.shutdown_grace, &self.reporter).await,
752 ),
753 None => Ok(()),
754 }
755 }
756
757 /// Hard-abort the leader-watch task without waiting for a cooperative stop.
758 ///
759 /// Prefer [`Self::shutdown`] or simply dropping the guard; both let the
760 /// task stop at a safe point. This is an escape hatch for callers that
761 /// cannot await and accept that the task may be torn down mid-fence.
762 pub fn abort(mut self) {
763 if let Some(handle) = self.handle.take() {
764 handle.abort();
765 }
766 // Hard-abort the heartbeat task too — leaving it running after the
767 // watch is torn down would publish heartbeats describing a stale
768 // (typically `NotServing`) view until the Arc<Reporter> is dropped.
769 if let Some(hb_handle) = self.heartbeat_handle.take() {
770 hb_handle.abort();
771 }
772 }
773
774 /// Whether the leader-watch task has finished — terminated for any reason
775 /// (cooperative cancel, driver error, stream EOF, or panic).
776 ///
777 /// A read-only liveness probe that neither consumes the guard nor disturbs
778 /// its cancel-on-drop behavior, so an embedder can poll task health while
779 /// keeping the guard alive.
780 pub fn is_finished(&self) -> bool {
781 self.handle
782 .as_ref()
783 .is_none_or(|handle| handle.is_finished())
784 }
785}
786
787impl Drop for WatchGuard {
788 fn drop(&mut self) {
789 // Close the serving gate synchronously, here at the drop site. Dropping
790 // the cancel sender below only *requests* the watch task to stop; the
791 // task publishes `NotServing` later, on its own timeline. Between this
792 // drop and the task's next poll the fast gate would still read `Serving`
793 // (and the allocator would still grant), so an RPC could be admitted by a
794 // server that has already been told to stop serving. `step_down` clears
795 // the allocator and publishes `NotServing` now, before any await; the
796 // watch task's later `step_down(None, None)` on cooperative cancel (see
797 // `fence::run_leader_watch`) republishes the identical state, so the
798 // double-close is harmless and idempotent.
799 self.core.step_down(None, None);
800 // Dropping the sender (if `shutdown` / `abort` did not already take it)
801 // resolves the task's cancel future; the task then publishes
802 // `NotServing` and returns. The `JoinHandle` is dropped here too,
803 // detaching the task to finish its cooperative shutdown on its own.
804 self.cancel_tx.take();
805 // Same treatment for the heartbeat task. `Drop` is sync so we cannot
806 // await the cooperative stop; instead we drop the cancel sender (the
807 // task will observe it at its next select! boundary) and hard-abort
808 // the handle so it cannot outlive the guard and publish heartbeats
809 // describing a stale view if the runtime keeps the Arc alive.
810 self.heartbeat_cancel_tx.take();
811 if let Some(hb_handle) = self.heartbeat_handle.take() {
812 hb_handle.abort();
813 }
814 }
815}
816
817/// Merge the caller's `shutdown` future with an internal cancellation signal.
818///
819/// Both [`Server::serve_with_shutdown`] and [`Server::serve_with_listener`]
820/// need tonic to stop when EITHER the caller's `shutdown` fires OR the
821/// leader-watch task terminates (signalled by firing the returned
822/// `oneshot::Sender`). This builds that merged shutdown future and hands back
823/// the sender so [`serve_inner`] can trip it from the watch arm.
824fn combined_shutdown_with_cancel(
825 shutdown: impl Future<Output = ()> + Send + 'static,
826) -> (
827 impl Future<Output = ()> + Send + 'static,
828 tokio::sync::oneshot::Sender<()>,
829) {
830 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
831 let combined_shutdown = async move {
832 tokio::select! {
833 _ = shutdown => {}
834 _ = cancel_rx => {}
835 }
836 };
837 (combined_shutdown, cancel_tx)
838}
839
840/// Wait for the leader-watch task to stop cooperatively, but no longer than
841/// `grace`, then forcibly abort it if it is still running.
842///
843/// The watch task observes its cancel signal only at the `select!` boundaries
844/// in [`crate::fence::run_leader_watch`], never inside a fence attempt. A
845/// consensus driver whose `load_high_water` / `persist_high_water` never
846/// returns (the trait places no latency bound; see
847/// [`tsoracle_consensus::ConsensusDriver`]) therefore parks the task upstream
848/// of any cancel-observing await, so dropping the cancel sender cannot stop it.
849/// Left unbounded, the shutdown wait would block process exit until the kubelet
850/// escalates to SIGKILL on a drain. Bounding the wait by `grace` and aborting
851/// on expiry guarantees forward progress: `tokio` tears a suspended task (and
852/// the wedged driver future it holds) down at the abort, dropping its
853/// drain-barrier guard.
854///
855/// Returns the task's join result. A clean cooperative stop forwards its real
856/// outcome verbatim; an aborted task surfaces as a cancelled `JoinError`, which
857/// [`join_to_server_result`] maps to `Ok(())` — the stop was requested, so a
858/// forced abort during shutdown is not an error. A `grace` of zero aborts
859/// immediately (the `timeout` future is already elapsed on first poll).
860async fn await_watch_within_grace(
861 watch_handle: &mut tokio::task::JoinHandle<Result<(), ServerError>>,
862 grace: Duration,
863 reporter: &Arc<crate::reporter::Reporter>,
864) -> Result<Result<(), ServerError>, tokio::task::JoinError> {
865 match tokio::time::timeout(grace, &mut *watch_handle).await {
866 Ok(join_result) => join_result,
867 Err(_elapsed) => {
868 reporter.shutdown_watch_aborted.increment(1);
869 #[cfg(feature = "tracing")]
870 tracing::warn!(
871 grace_ms = grace.as_millis() as u64,
872 "leader-watch task did not stop within the shutdown grace; aborting it (a consensus-driver call likely exceeded its latency bound)"
873 );
874 watch_handle.abort();
875 // Reap the aborted task so its Drop (releasing any held drain-barrier
876 // guard) runs before we report shutdown complete. Bounded: an aborted
877 // task resolves at its next poll.
878 (&mut *watch_handle).await
879 }
880 }
881}
882
883/// Drive the gRPC `serve_future` against the leader-watch task, shared by
884/// [`Server::serve_with_shutdown`] and [`Server::serve_with_listener`].
885///
886/// The two public methods differ only in how `serve_future` is assembled
887/// (address-bound via `serve_with_shutdown` vs listener-bound via
888/// `serve_with_incoming_shutdown`); everything downstream — the biased select,
889/// the cooperative-cancel path, and the drain/translate logic — is identical
890/// and lives here so a future change need only be made once.
891///
892/// `tonic_cancel_tx` is the cancellation half paired with the `serve_future`'s
893/// shutdown signal (see [`combined_shutdown_with_cancel`]); firing it begins
894/// tonic's graceful drain when the watch task terminates first. `watch_cancel_tx`
895/// is the leader-watch task's own cooperative-cancel sender (the same one a
896/// [`WatchGuard`] holds for embedders); dropping it stops the task. Taking the
897/// raw parts rather than a `WatchGuard` keeps this path free of the guard's
898/// `Option` fields — neither the watch handle nor the cancel sender is optional
899/// here. `shutdown_grace` bounds the user-shutdown arm's wait for the watch task
900/// (see [`await_watch_within_grace`]). `core` is the shared serving core (the
901/// same one the watch task and the gRPC service hold): the user-shutdown arm
902/// closes the gate on it synchronously so no RPC is admitted in the window
903/// before the watch task observes cancellation and publishes `NotServing`.
904// Private serve helper. The wide signature is the cost of being the single
905// merge point for the two public `serve_*` paths and the leader-watch +
906// heartbeat task pair: bundling these into a struct just to placate clippy
907// would obscure the lifecycle (every parameter is consumed exactly once and
908// has no shared identity worth naming). Keep the arguments visible.
909#[allow(clippy::too_many_arguments)]
910async fn serve_inner<S>(
911 watch_cancel_tx: tokio::sync::oneshot::Sender<()>,
912 mut watch_handle: tokio::task::JoinHandle<Result<(), ServerError>>,
913 heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
914 heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
915 serve_future: S,
916 tonic_cancel_tx: tokio::sync::oneshot::Sender<()>,
917 shutdown_grace: Duration,
918 core: Arc<ServingCore>,
919 reporter: Arc<crate::reporter::Reporter>,
920) -> Result<(), ServerError>
921where
922 S: Future<Output = Result<(), tonic::transport::Error>>,
923{
924 tokio::pin!(serve_future);
925
926 let outcome = tokio::select! {
927 // Bias toward the watch arm: if both are ready in the same poll
928 // (rare but possible — graceful shutdown completed in the same
929 // tick the watch returned), we want to surface the watch error
930 // rather than report a clean shutdown.
931 biased;
932
933 watch_result = &mut watch_handle => {
934 // Watch terminated. State is already poisoned (see watch
935 // task body in into_router). Trigger tonic drain, wait for
936 // it to finish, then report the watch's outcome — preferring
937 // it over any drain error, which surfaces only if the watch
938 // itself ended cleanly.
939 let _ = tonic_cancel_tx.send(());
940 let drain_result = serve_future.await;
941 combine_watch_and_drain(watch_result, drain_result)
942 }
943 serve_result = &mut serve_future => {
944 // User shutdown fired (or our cancel — but watch arm has
945 // `biased` priority, so reaching here means user shutdown).
946 // Prefer a cooperative stop: dropping the cancel sender resolves
947 // the task's cancel future so it stops at its next `select!`
948 // boundary, having published `NotServing` and never torn down
949 // mid-fence while holding `extension_gate.write()`. But a
950 // cooperative stop is only observed at those boundaries, never
951 // inside a fence attempt — a consensus-driver call that never
952 // returns (the trait places no latency bound) would park the task
953 // upstream of any cancel point and block process exit until a
954 // kubelet SIGKILL. `await_watch_within_grace` therefore bounds the
955 // wait by `shutdown_grace` and aborts the task if it overruns. The
956 // task's own outcome (a clean `Ok(())` on cooperative stop, a
957 // cancelled `JoinError` on abort) is discarded; the user-requested
958 // shutdown result wins.
959 //
960 // Close the serving gate synchronously first: dropping the sender
961 // only *requests* the stop, and a task aborted on grace expiry (or
962 // simply not yet rescheduled) may never reach its own `step_down`. So
963 // a `GetTs` arriving during the drain would still be admitted unless
964 // we close the gate here. `step_down` is idempotent with the watch
965 // task's own cooperative-cancel publish.
966 core.step_down(None, None);
967 drop(watch_cancel_tx);
968 let _ = await_watch_within_grace(&mut watch_handle, shutdown_grace, &reporter).await;
969 serve_result?;
970 Ok(())
971 }
972 };
973
974 // Stop the heartbeat task on every exit path. Done after the watch reap so
975 // the watch-arm `combine_watch_and_drain` already saw the watch outcome,
976 // and the user-shutdown arm has finished its grace-bounded wait. Dropping
977 // the cancel sender breaks the task's `tokio::select! { biased; cancel,
978 // sleep }` loop on the next poll; if the task is wedged for any reason we
979 // hard-abort on grace overrun. The task's outcome is observability only
980 // and cannot influence serving correctness, so its join result is dropped.
981 drop(heartbeat_cancel_tx);
982 if let Some(mut hb_handle) = heartbeat_handle {
983 match tokio::time::timeout(shutdown_grace, &mut hb_handle).await {
984 Ok(Ok(())) => {}
985 Ok(Err(_join_err)) => {} // panic — already counted via catch_unwind
986 Err(_elapsed) => {
987 hb_handle.abort();
988 let _ = (&mut hb_handle).await;
989 }
990 }
991 }
992
993 outcome
994}
995
996/// Convert a `JoinHandle` result into a `ServerError`-typed result.
997///
998/// - `Ok(Ok(()))` — cooperative cancellation: `run_leader_watch` observed its
999/// cancel signal (the `WatchGuard` was dropped, `WatchGuard::shutdown` was
1000/// called, or `serve_inner` cancelled it on user shutdown), published
1001/// `NotServing`, and returned cleanly. Forwarded verbatim as `Ok(())`.
1002/// - `Ok(Err(e))` — task returned an error (including `WatchStreamClosed`
1003/// from a clean EOF). Forward verbatim.
1004/// - `Err(JoinError)` — task was aborted or panicked. An abort
1005/// (`WatchGuard::abort` or `JoinHandle::abort`) maps to Ok (we asked for it);
1006/// a panic maps to `WatchPanic` with payload.
1007fn join_to_server_result(
1008 join_result: Result<Result<(), ServerError>, tokio::task::JoinError>,
1009) -> Result<(), ServerError> {
1010 match join_result {
1011 Ok(inner) => inner,
1012 Err(join_err) if join_err.is_panic() => {
1013 let payload = panic_payload_to_string(join_err.into_panic());
1014 Err(ServerError::WatchPanic {
1015 payload,
1016 bt: Bt::capture(),
1017 })
1018 }
1019 Err(_cancelled) => Ok(()),
1020 }
1021}
1022
1023/// Combine the leader-watch outcome with the tonic graceful-drain outcome
1024/// after the watch arm fired.
1025///
1026/// When the watch task terminates first we trigger the drain and then must
1027/// report a single result. The watch error is the root cause — poisoned
1028/// serving state was already published before the task returned — so it wins
1029/// when both fail. A drain error (port stolen, resource exhaustion) is only
1030/// surfaced when the watch outcome is otherwise `Ok`; previously it was
1031/// discarded via `let _ = serve.await`, hiding a failed drain behind a clean
1032/// shutdown report.
1033///
1034/// Generic over the drain error so the precedence logic is unit-testable
1035/// without fabricating a `tonic::transport::Error` (which has no public
1036/// constructor): production passes `Result<(), tonic::transport::Error>`,
1037/// tests pass `Result<(), ServerError>` via the reflexive `From` impl.
1038fn combine_watch_and_drain<E>(
1039 watch_result: Result<Result<(), ServerError>, tokio::task::JoinError>,
1040 drain_result: Result<(), E>,
1041) -> Result<(), ServerError>
1042where
1043 ServerError: From<E>,
1044{
1045 match join_to_server_result(watch_result) {
1046 Err(watch_err) => Err(watch_err),
1047 Ok(()) => drain_result.map_err(ServerError::from),
1048 }
1049}
1050
1051/// Build the gRPC reflection service from an encoded protobuf descriptor set.
1052///
1053/// Factored out of [`Server::into_router`] so the decode-failure path is unit
1054/// testable: production passes [`tsoracle_proto::FILE_DESCRIPTOR_SET`], while
1055/// tests can feed deliberately corrupt bytes to exercise the error mapping.
1056/// A decode failure becomes [`ServerError::ReflectionInit`] rather than a panic.
1057#[cfg(feature = "reflection")]
1058fn build_reflection_service(
1059 descriptor_set: &[u8],
1060) -> Result<
1061 tonic_reflection::server::v1::ServerReflectionServer<
1062 impl tonic_reflection::server::v1::ServerReflection,
1063 >,
1064 ServerError,
1065> {
1066 tonic_reflection::server::Builder::configure()
1067 .register_encoded_file_descriptor_set(descriptor_set)
1068 .build_v1()
1069 .map_err(ServerError::ReflectionInit)
1070}
1071
1072fn panic_payload_to_string(panic: Box<dyn std::any::Any + Send>) -> String {
1073 if let Some(text) = panic.downcast_ref::<&'static str>() {
1074 (*text).to_string()
1075 } else if let Some(text) = panic.downcast_ref::<String>() {
1076 text.clone()
1077 } else {
1078 "watch task panicked with non-string payload".to_string()
1079 }
1080}
1081
1082#[cfg(any(test, feature = "test-fakes"))]
1083impl Server {
1084 /// Test-only entry point for the leader-watch loop. Exposed to integration
1085 /// tests via the `test-fakes` feature; not part of the stable public API.
1086 #[doc(hidden)]
1087 pub async fn run_leader_watch_for_tests(self: Arc<Self>) -> Result<(), ServerError> {
1088 // A never-resolving cancel future: these tests drive termination via
1089 // leadership events or `JoinHandle::abort`, not cooperative cancel.
1090 crate::fence::run_leader_watch(self, futures::future::pending()).await
1091 }
1092
1093 /// Test-only allocator probe. Issues a window grant against the current
1094 /// in-memory state without going through the gRPC service. Used by
1095 /// regression tests that need to observe the behavioral fence (no
1096 /// timestamp at or below the prior leader's high-water) directly.
1097 #[doc(hidden)]
1098 pub fn try_grant_for_tests(&self, count: u32) -> Result<WindowGrant, CoreError> {
1099 self.core.try_grant(self.clock.now_ms(), count)
1100 }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106
1107 #[test]
1108 fn panic_payload_to_string_recovers_static_str() {
1109 // `panic!("literal")` produces a `&'static str` payload; we want the
1110 // verbatim text so operators see what the watch task said.
1111 let payload: Box<dyn std::any::Any + Send> = Box::new("watch boom");
1112 assert_eq!(panic_payload_to_string(payload), "watch boom");
1113 }
1114
1115 #[test]
1116 fn panic_payload_to_string_recovers_owned_string() {
1117 // `panic!("{var}")` produces a `String` payload (formatted at panic
1118 // time); the helper must downcast that branch too.
1119 let payload: Box<dyn std::any::Any + Send> = Box::new(String::from("formatted"));
1120 assert_eq!(panic_payload_to_string(payload), "formatted");
1121 }
1122
1123 #[test]
1124 fn panic_payload_to_string_falls_back_for_other_types() {
1125 // Custom payloads (panic!(MyType { .. })) hit the catch-all branch.
1126 struct Custom;
1127 let payload: Box<dyn std::any::Any + Send> = Box::new(Custom);
1128 assert_eq!(
1129 panic_payload_to_string(payload),
1130 "watch task panicked with non-string payload",
1131 );
1132 }
1133
1134 #[test]
1135 fn serving_transitions_publish_through_core() {
1136 // The Server delegates serving-state transitions to its ServingCore; a
1137 // step_down on a freshly built Server lands as NotServing with the hint.
1138 // (The #346 send_replace-with-zero-receivers regression is pinned by the
1139 // ServingCore unit tests, which can observe `receiver_count` directly.)
1140 let server = Server::builder()
1141 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1142 .build()
1143 .expect("build must succeed");
1144
1145 let hint = PeerEndpoint::try_from("new-leader:9000").unwrap();
1146 server.core.step_down(Some(hint.clone()), Some(Epoch(7)));
1147
1148 match server.core.serving_state() {
1149 ServingState::NotServing {
1150 leader_endpoint,
1151 leader_epoch,
1152 } => {
1153 assert_eq!(leader_endpoint, Some(hint));
1154 assert_eq!(leader_epoch, Some(Epoch(7)));
1155 }
1156 ServingState::Serving => panic!("expected NotServing after step_down"),
1157 }
1158 }
1159
1160 #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
1161 #[test]
1162 fn builder_stores_tls_config() {
1163 // The serve_* paths read `tls_config` from `Server` (not the builder)
1164 // after `into_router` consumes self — so the field must survive the
1165 // builder → Server hand-off, not just the builder method.
1166 use crate::test_fakes::InMemoryDriver;
1167
1168 let driver = Arc::new(InMemoryDriver::new());
1169 let cfg = tonic::transport::ServerTlsConfig::new();
1170 let server = Server::builder()
1171 .consensus_driver(driver)
1172 .tls_config(cfg)
1173 .build()
1174 .expect("build with tls_config must succeed");
1175 assert!(server.tls_config.is_some());
1176 }
1177
1178 #[test]
1179 fn build_rejects_zero_max_seq_count() {
1180 // max_seq_count(0) makes every positive `count` fail as
1181 // SeqCountTooLarge{max:0} (the count>=1 floor leaves no valid value) —
1182 // GetSeq is silently dead. build() must fail-fast rather than ship a
1183 // server where GetSeq can never succeed.
1184 // `Server` is not `Debug`, so match rather than `expect_err`.
1185 match Server::builder()
1186 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1187 .max_seq_count(0)
1188 .build()
1189 {
1190 Err(BuildError::ZeroMaxSeqCount) => {}
1191 Err(other) => panic!("expected ZeroMaxSeqCount, got {other:?}"),
1192 Ok(_) => panic!("max_seq_count(0) must be rejected at build, but build succeeded"),
1193 }
1194 }
1195
1196 #[test]
1197 fn build_accepts_max_seq_count_of_one() {
1198 // 1 is the smallest usable cap: a single-ordinal block is valid, so the
1199 // floor and the cap coincide and GetSeq still works. Must build.
1200 Server::builder()
1201 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1202 .max_seq_count(1)
1203 .build()
1204 .expect("max_seq_count(1) must build");
1205 }
1206
1207 #[test]
1208 fn build_accepts_max_seq_batch_keys_of_one() {
1209 // 1 is the smallest usable batch cap: a single-entry batch is valid, so
1210 // the cap must build (the guard rejects only 0).
1211 Server::builder()
1212 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1213 .max_seq_batch_keys(1)
1214 .build()
1215 .expect("max_seq_batch_keys(1) must build");
1216 }
1217
1218 #[test]
1219 fn build_rejects_inverted_lease_ttl_bounds() {
1220 match Server::builder()
1221 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1222 .lease_ttl_floor(Duration::from_secs(10))
1223 .lease_ttl_ceiling(Duration::from_secs(5))
1224 .build()
1225 {
1226 Err(BuildError::LeaseTtlBoundsInvalid) => {}
1227 Err(other) => panic!("expected LeaseTtlBoundsInvalid, got {other:?}"),
1228 Ok(_) => panic!("inverted lease TTL bounds must be rejected"),
1229 }
1230 }
1231
1232 #[test]
1233 fn build_accepts_default_lease_ttl_bounds() {
1234 Server::builder()
1235 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1236 .build()
1237 .expect("default lease TTL bounds must build");
1238 }
1239
1240 #[test]
1241 fn zero_max_seq_batch_keys_is_rejected() {
1242 // max_seq_batch_keys(0) makes every GetSeqBatch call fail as
1243 // "batch has N entries; the maximum is 0" — the RPC is silently dead.
1244 // build() must fail-fast rather than ship a server where GetSeqBatch
1245 // can never succeed.
1246 match Server::builder()
1247 .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1248 .max_seq_batch_keys(0)
1249 .build()
1250 {
1251 Err(BuildError::ZeroMaxSeqBatchKeys) => {}
1252 Err(other) => panic!("expected ZeroMaxSeqBatchKeys, got {other:?}"),
1253 Ok(_) => panic!("max_seq_batch_keys(0) must be rejected at build, but build succeeded"),
1254 }
1255 }
1256
1257 #[tokio::test]
1258 async fn join_to_server_result_passes_through_clean_outcome() {
1259 // Ok(Ok(())) — task finished cleanly; forward verbatim.
1260 let handle = tokio::spawn(async { Ok::<(), ServerError>(()) });
1261 let join = handle.await;
1262 assert!(matches!(join_to_server_result(join), Ok(())));
1263 }
1264
1265 #[tokio::test]
1266 async fn join_to_server_result_forwards_inner_error() {
1267 // Ok(Err(e)) — task returned an error; forward it.
1268 let handle = tokio::spawn(async {
1269 Err::<(), ServerError>(ServerError::WatchPanic {
1270 payload: "synthetic".into(),
1271 bt: Bt::capture(),
1272 })
1273 });
1274 let join = handle.await;
1275 match join_to_server_result(join) {
1276 Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "synthetic"),
1277 other => panic!("expected forwarded WatchPanic, got {other:?}"),
1278 }
1279 }
1280
1281 #[tokio::test]
1282 async fn join_to_server_result_translates_panic_to_watch_panic() {
1283 // Err(JoinError::is_panic) — task panicked; surface as WatchPanic with
1284 // the payload stringified by `panic_payload_to_string`.
1285 let handle = tokio::spawn(async {
1286 panic!("intentional");
1287 #[allow(unreachable_code)]
1288 Ok::<(), ServerError>(())
1289 });
1290 let join = handle.await;
1291 match join_to_server_result(join) {
1292 Err(ServerError::WatchPanic { payload, .. }) => {
1293 assert!(payload.contains("intentional"))
1294 }
1295 other => panic!("expected WatchPanic, got {other:?}"),
1296 }
1297 }
1298
1299 #[tokio::test]
1300 async fn join_to_server_result_treats_cancellation_as_clean_exit() {
1301 // Err(JoinError::is_cancelled) — caller aborted the task; we asked
1302 // for that, so map to Ok.
1303 let handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1304 tokio::spawn(async { futures::future::pending().await });
1305 handle.abort();
1306 let join = handle.await;
1307 assert!(matches!(join_to_server_result(join), Ok(())));
1308 }
1309
1310 #[tokio::test]
1311 async fn combine_watch_and_drain_surfaces_drain_error_when_watch_ok() {
1312 // Watch ended cleanly but the graceful drain failed (port stolen,
1313 // resource exhaustion). The drain error must not be swallowed.
1314 let watch = tokio::spawn(async { Ok::<(), ServerError>(()) }).await;
1315 let drain: Result<(), ServerError> = Err(ServerError::WatchStreamClosed);
1316 assert!(matches!(
1317 combine_watch_and_drain(watch, drain),
1318 Err(ServerError::WatchStreamClosed)
1319 ));
1320 }
1321
1322 #[tokio::test]
1323 async fn combine_watch_and_drain_returns_ok_when_both_succeed() {
1324 // Watch clean, drain clean — the only fully-Ok outcome.
1325 let watch = tokio::spawn(async { Ok::<(), ServerError>(()) }).await;
1326 let drain: Result<(), ServerError> = Ok(());
1327 assert!(matches!(combine_watch_and_drain(watch, drain), Ok(())));
1328 }
1329
1330 #[tokio::test]
1331 async fn combine_watch_and_drain_prefers_watch_error_over_drain_error() {
1332 // Both failed. The watch error is the root cause (poisoned state was
1333 // already published), so it wins; the drain error is dropped.
1334 let watch = tokio::spawn(async {
1335 Err::<(), ServerError>(ServerError::WatchPanic {
1336 payload: "watch".into(),
1337 bt: Bt::capture(),
1338 })
1339 })
1340 .await;
1341 let drain: Result<(), ServerError> = Err(ServerError::WatchStreamClosed);
1342 match combine_watch_and_drain(watch, drain) {
1343 Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "watch"),
1344 other => panic!("expected watch error to win, got {other:?}"),
1345 }
1346 }
1347
1348 #[tokio::test]
1349 async fn combine_watch_and_drain_returns_watch_error_when_drain_ok() {
1350 // Watch failed, drain succeeded — forward the watch error verbatim.
1351 let watch = tokio::spawn(async {
1352 Err::<(), ServerError>(ServerError::WatchPanic {
1353 payload: "watch".into(),
1354 bt: Bt::capture(),
1355 })
1356 })
1357 .await;
1358 let drain: Result<(), ServerError> = Ok(());
1359 match combine_watch_and_drain(watch, drain) {
1360 Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "watch"),
1361 other => panic!("expected forwarded watch error, got {other:?}"),
1362 }
1363 }
1364
1365 #[tokio::test]
1366 async fn dropping_watch_guard_closes_serving_gate_synchronously() {
1367 // Regression: dropping the guard must close the serving gate at the
1368 // drop site, not on the watch task's later timeline. Build a guard whose
1369 // watch handle never touches the core (so the ONLY thing that can flip
1370 // the state to NotServing is `Drop`), publish `Serving`, then drop the
1371 // guard and read the state with NO await in between. On the current-thread
1372 // test runtime no other task can run between the synchronous `drop` and
1373 // the synchronous `serving_state` read, so observing `NotServing` proves
1374 // `Drop` closed the gate synchronously rather than the watch task.
1375 let core = Arc::new(ServingCore::new(
1376 Duration::from_secs(3),
1377 tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
1378 tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
1379 ));
1380 core.publish_serving();
1381
1382 let handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1383 tokio::spawn(async { Ok(()) });
1384 let (cancel_tx, _cancel_rx) = tokio::sync::oneshot::channel::<()>();
1385 let guard = WatchGuard {
1386 cancel_tx: Some(cancel_tx),
1387 handle: Some(handle),
1388 shutdown_grace: Duration::from_secs(10),
1389 core: core.clone(),
1390 reporter: Arc::new(crate::reporter::Reporter::new()),
1391 heartbeat_cancel_tx: None,
1392 heartbeat_handle: None,
1393 };
1394
1395 drop(guard);
1396
1397 assert!(
1398 matches!(core.serving_state(), ServingState::NotServing { .. }),
1399 "dropping the WatchGuard must close the serving gate synchronously",
1400 );
1401 }
1402
1403 #[tokio::test]
1404 async fn serve_inner_closes_serving_gate_on_user_shutdown() {
1405 // Regression for the serve path: when the caller's shutdown fires,
1406 // `serve_inner` drops the watch cancel sender and waits out the grace,
1407 // forcibly aborting the watch task if it overruns. A task parked upstream
1408 // of any cancel-observing await (modelled here by a never-resolving
1409 // future) is aborted before it can publish `NotServing`, so the gate
1410 // would stay open unless `serve_inner` closes it itself. Seed `Serving`,
1411 // run the user-shutdown arm with a zero grace (immediate abort), and
1412 // assert the gate is closed on return.
1413 let core = Arc::new(ServingCore::new(
1414 Duration::from_secs(3),
1415 tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
1416 tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
1417 ));
1418 core.publish_serving();
1419
1420 let watch_handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1421 tokio::spawn(async { futures::future::pending().await });
1422 let (watch_cancel_tx, _watch_cancel_rx) = tokio::sync::oneshot::channel::<()>();
1423 let (tonic_cancel_tx, _tonic_cancel_rx) = tokio::sync::oneshot::channel::<()>();
1424
1425 // A serve future that is immediately ready models the user's shutdown
1426 // having fired; with the biased select preferring the (pending) watch arm,
1427 // control reaches the user-shutdown arm.
1428 let serve_future = async { Ok::<(), tonic::transport::Error>(()) };
1429
1430 let result = serve_inner(
1431 watch_cancel_tx,
1432 watch_handle,
1433 None, // heartbeat_cancel_tx — heartbeat disabled in this regression test
1434 None, // heartbeat_handle
1435 serve_future,
1436 tonic_cancel_tx,
1437 Duration::from_millis(0),
1438 core.clone(),
1439 Arc::new(crate::reporter::Reporter::new()),
1440 )
1441 .await;
1442
1443 assert!(
1444 result.is_ok(),
1445 "user shutdown must return Ok, got {result:?}"
1446 );
1447 assert!(
1448 matches!(core.serving_state(), ServingState::NotServing { .. }),
1449 "serve_inner's user-shutdown arm must close the serving gate synchronously",
1450 );
1451 }
1452
1453 #[cfg(feature = "reflection")]
1454 #[test]
1455 fn build_reflection_service_accepts_embedded_descriptor_set() {
1456 // The descriptor set emitted by `tsoracle-proto`'s build.rs must decode
1457 // cleanly — this is the production happy path that previously sat behind
1458 // an `expect`.
1459 assert!(build_reflection_service(tsoracle_proto::FILE_DESCRIPTOR_SET).is_ok());
1460 }
1461
1462 #[cfg(feature = "reflection")]
1463 #[test]
1464 fn build_reflection_service_maps_corrupt_descriptor_to_typed_error() {
1465 // A descriptor set that fails to decode (build artifact drift) must
1466 // surface as a typed `ServerError::ReflectionInit`, not a panic. The
1467 // bytes below are not a valid encoded `FileDescriptorSet`.
1468 let corrupt = b"\xff\xff\xff\xff not a descriptor set";
1469 // The Ok variant wraps a reflection service that is not `Debug`, so map
1470 // to a unit result before asserting on the error variant.
1471 match build_reflection_service(corrupt).map(|_| ()) {
1472 Err(ServerError::ReflectionInit(_)) => {}
1473 other => panic!("expected ReflectionInit error, got {other:?}"),
1474 }
1475 }
1476}