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}