Skip to main content

ruststream/runtime/app/
health.rs

1//! The [`HealthProbe`]: a cloneable, watch-backed view of the running service's lifecycle state
2//! and of how many of its subscriptions are consuming.
3//!
4//! The standard deployment shape runs the messaging service beside another tokio task - typically
5//! an HTTP server with a `/healthz` route. When the service fail-fasts, the process stays alive
6//! because of that sibling task, and the probe is what lets its endpoint report "the app died,
7//! stop routing traffic here" instead of a permanent 200.
8
9use std::future::pending;
10
11use tokio::sync::watch;
12
13/// The lifecycle state reported by a [`HealthProbe`].
14///
15/// [`RustStream::start`](super::RustStream::start) is the readiness gate (it resolves only after
16/// subscriptions are open), so the probe covers the post-startup half of the lifecycle.
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum HealthState {
20    /// Subscriptions are open and the dispatch loops are running.
21    Running,
22    /// An orderly [`shutdown`](super::RunningApp::shutdown) is in progress.
23    ShuttingDown,
24    /// The service shut down cleanly.
25    Stopped,
26    /// The service tore itself down on a fail-fast failure, or teardown itself failed.
27    Failed {
28        /// The diagnostic the fail-fast path recorded (subscription and cause), the same string
29        /// [`run`](super::RustStream::run) returns inside
30        /// [`RustStreamError::Dispatch`](super::RustStreamError::Dispatch).
31        reason: String,
32    },
33}
34
35impl HealthState {
36    /// Whether the state reports a live, dispatching service.
37    #[must_use]
38    pub fn is_running(&self) -> bool {
39        matches!(self, Self::Running)
40    }
41}
42
43/// How many of the service's subscriptions are consuming, as a [`HealthProbe`] reports it.
44///
45/// A subscription counts once, whatever its worker count. It is active from the moment its
46/// dispatch loop starts until the loop ends: on shutdown, or when its stream ends while the
47/// service runs (see [`SubscriptionEnd`]). Whether a service with fewer active subscriptions than
48/// it started is healthy is the service's own call.
49///
50/// # Examples
51///
52/// ```
53/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
54/// # mod demo {
55/// use ruststream::memory::prelude::*;
56/// use ruststream::runtime::{HealthProbe, RustStreamError, SubscriptionCount};
57/// use serde::Deserialize;
58///
59/// #[derive(Deserialize)]
60/// struct Order {
61///     id: u64,
62/// }
63///
64/// #[subscriber("orders")]
65/// async fn accept(order: &Order) -> HandlerOutcome {
66///     tracing::info!(order.id, "accepted");
67///     HandlerOutcome::ack()
68/// }
69///
70/// /// Healthy while the service runs and every subscription it started still reads its queue.
71/// fn healthy(health: &HealthProbe) -> bool {
72///     let SubscriptionCount {
73///         active, started, ..
74///     } = health.subscriptions();
75///     health.is_running() && active == started
76/// }
77///
78/// pub async fn run() -> Result<(), RustStreamError> {
79///     let app = RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
80///         MemoryBroker::new(),
81///         |b| {
82///             b.include(accept);
83///         },
84///     );
85///     let running = app.start().await?;
86///     tracing::info!(healthy = healthy(&running.health()), "service started");
87///     running.shutdown().await
88/// }
89/// # }
90/// # fn main() {}
91/// ```
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct SubscriptionCount {
95    /// The subscriptions whose dispatch loop is running.
96    pub active: usize,
97    /// The subscriptions the service started.
98    pub started: usize,
99}
100
101/// What a subscription's stream ending while the service runs does, set with
102/// [`RustStream::on_subscription_end`](super::RustStream::on_subscription_end).
103///
104/// A stream can end without a shutdown: the server cancels a consumer when its queue is deleted,
105/// a broker's reconnection policy gives up, or a broker is configured not to reconnect. An end
106/// during shutdown is part of the shutdown and is never reported.
107///
108/// # Examples
109///
110/// ```
111/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
112/// # mod demo {
113/// use ruststream::memory::prelude::*;
114/// use ruststream::runtime::SubscriptionEnd;
115/// use serde::Deserialize;
116///
117/// #[derive(Deserialize)]
118/// struct Order {
119///     id: u64,
120/// }
121///
122/// #[subscriber("orders")]
123/// async fn accept(order: &Order) -> HandlerOutcome {
124///     tracing::info!(order.id, "accepted");
125///     HandlerOutcome::ack()
126/// }
127///
128/// /// The service is useless without its queue, so it stops when the queue stops.
129/// pub fn app() -> impl App {
130///     RustStream::new(AppInfo::new("orders", "0.1.0"))
131///         .on_subscription_end(SubscriptionEnd::FailFast)
132///         .with_broker(MemoryBroker::new(), |b| {
133///             b.include(accept);
134///         })
135/// }
136/// # }
137/// # fn main() {}
138/// ```
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum SubscriptionEnd {
142    /// The loop logs the end and exits; the rest of the service runs on. The subscription leaves
143    /// the active count of [`HealthProbe::subscriptions`].
144    #[default]
145    Log,
146    /// The end is a fail-fast failure: an error naming the subscription, a graceful shutdown,
147    /// [`run`](super::RustStream::run) returning
148    /// [`RustStreamError::Dispatch`](super::RustStreamError::Dispatch), and the probe reporting
149    /// [`HealthState::Failed`].
150    FailFast,
151}
152
153/// What the probe reads: the lifecycle state and the subscription count, one value, so a wake-up
154/// covers both.
155#[derive(Debug)]
156pub(crate) struct Health {
157    state: HealthState,
158    subscriptions: SubscriptionCount,
159}
160
161/// A cheap, cloneable handle reporting the service's [`HealthState`] and how many of its
162/// subscriptions are consuming.
163///
164/// Obtained from [`RunningApp::health`](super::RunningApp::health); backed by a
165/// [`watch`](tokio::sync::watch) channel written only on lifecycle transitions and when a
166/// subscription's loop starts or ends, so reading is a lock-free borrow and nothing runs on the
167/// per-delivery hot path. The probe outlives [`shutdown`](super::RunningApp::shutdown) (which
168/// consumes the app handle), so a sibling HTTP task keeps answering with the terminal state.
169///
170/// Watch semantics: observers always see the latest state; rapid transitions (`ShuttingDown`
171/// immediately followed by `Stopped`) may coalesce, so poll [`state`](Self::state) for snapshots
172/// and use [`changed`](Self::changed) only as a wake-up.
173///
174/// Dropping the [`RunningApp`](super::RunningApp) without calling `shutdown` detaches the service
175/// (per the crate rule that destructors never block); a probe then keeps reporting the last
176/// observed state, and [`changed`](Self::changed) resolves only if a fail-fast failure still
177/// flips the state or a subscription still ends.
178#[derive(Debug, Clone)]
179pub struct HealthProbe {
180    rx: watch::Receiver<Health>,
181}
182
183impl HealthProbe {
184    /// A snapshot of the current state.
185    #[must_use]
186    pub fn state(&self) -> HealthState {
187        self.rx.borrow().state.clone()
188    }
189
190    /// Whether the service is currently running (the healthy answer for a liveness endpoint).
191    #[must_use]
192    pub fn is_running(&self) -> bool {
193        self.rx.borrow().state.is_running()
194    }
195
196    /// How many subscriptions are consuming, and how many the service started.
197    ///
198    /// A subscription whose stream ended while the service runs leaves the active count, under
199    /// the default [`SubscriptionEnd::Log`]. The lifecycle state stays
200    /// [`Running`](HealthState::Running): the service decides what a partly working service
201    /// reports outside.
202    ///
203    /// # Examples
204    ///
205    /// ```no_run
206    /// # #[cfg(feature = "memory")]
207    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
208    /// use axum::Router;
209    /// use axum::extract::State;
210    /// use axum::http::StatusCode;
211    /// use axum::routing::get;
212    /// use ruststream::memory::MemoryBroker;
213    /// use ruststream::runtime::{AppInfo, HealthProbe, RustStream};
214    /// use tokio::net::TcpListener;
215    ///
216    /// /// Ready while every subscription the service started still consumes.
217    /// async fn readyz(State(health): State<HealthProbe>) -> StatusCode {
218    ///     let count = health.subscriptions();
219    ///     if health.is_running() && count.active == count.started {
220    ///         StatusCode::OK
221    ///     } else {
222    ///         StatusCode::SERVICE_UNAVAILABLE
223    ///     }
224    /// }
225    ///
226    /// let app =
227    ///     RustStream::new(AppInfo::new("svc", "0.1.0")).register_broker(MemoryBroker::new());
228    /// let running = app.start().await?;
229    /// let routes = Router::new()
230    ///     .route("/readyz", get(readyz))
231    ///     .with_state(running.health());
232    /// axum::serve(TcpListener::bind("0.0.0.0:8080").await?, routes)
233    ///     .with_graceful_shutdown(running.stopping())
234    ///     .await?;
235    /// running.shutdown().await?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    #[must_use]
240    pub fn subscriptions(&self) -> SubscriptionCount {
241        self.rx.borrow().subscriptions
242    }
243
244    /// Waits for the next change, a lifecycle transition or a subscription starting or ending,
245    /// and returns the state.
246    ///
247    /// If the service was detached (the app handle dropped without `shutdown`) and no further
248    /// change can arrive, the future stays pending forever rather than spinning the caller's
249    /// loop; pair it with [`state`](Self::state) when a snapshot is enough.
250    ///
251    /// # Cancel safety
252    ///
253    /// Cancel-safe: dropping the future loses nothing; a fresh call observes the same channel.
254    pub async fn changed(&mut self) -> HealthState {
255        if self.rx.changed().await.is_ok() {
256            return self.state();
257        }
258        // The sender is gone without a final transition: the state is final, park forever.
259        pending().await
260    }
261}
262
263/// The write half the running app, its fail-fast watcher and the dispatch loops drive; probes
264/// are subscribed off it, so the public surface stays read-only.
265///
266/// Every write uses `send_modify`, which keeps the value when no probe is subscribed yet (where
267/// `send` drops it): a probe taken after a change must still observe it.
268#[derive(Debug)]
269pub(crate) struct HealthReport(watch::Sender<Health>);
270
271impl HealthReport {
272    /// A running service with no subscription started yet.
273    pub(crate) fn new() -> Self {
274        Self(
275            watch::channel(Health {
276                state: HealthState::Running,
277                subscriptions: SubscriptionCount::default(),
278            })
279            .0,
280        )
281    }
282
283    /// A probe on this report.
284    pub(crate) fn probe(&self) -> HealthProbe {
285        HealthProbe {
286            rx: self.0.subscribe(),
287        }
288    }
289
290    /// Moves the lifecycle state to `state`.
291    pub(crate) fn transition(&self, state: HealthState) {
292        self.0.send_modify(|health| health.state = state);
293    }
294
295    /// A subscription's dispatch loop started.
296    pub(crate) fn subscription_started(&self) {
297        self.0.send_modify(|health| {
298            health.subscriptions.started += 1;
299            health.subscriptions.active += 1;
300        });
301    }
302
303    /// A subscription's dispatch loop ended.
304    pub(crate) fn subscription_ended(&self) {
305        self.0
306            .send_modify(|health| health.subscriptions.active -= 1);
307    }
308}