sui_rpc_api/service/health.rs
1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::extract::{Query, State};
5use std::time::Duration;
6use std::time::SystemTime;
7
8use crate::Result;
9use crate::RpcService;
10
11/// The largest gap, in checkpoints, between the latest executed checkpoint and
12/// the highest checkpoint the live-object index has committed while still
13/// considered healthy.
14///
15/// The embedded indexer follows the tip asynchronously, so the live-object
16/// index always trails the executed tip by a little (roughly the indexer's
17/// snapshot window). A gap larger than this means the live index has fallen
18/// behind -- e.g. the indexer has stalled -- and cannot serve current
19/// live-object reads. The ledger-history cohort backfills separately after a
20/// restore and is deliberately excluded from this gap, so a node is healthy as
21/// soon as its live-object reads are caught up.
22const MAX_HEALTHY_INDEX_LAG: u64 = 60;
23
24impl RpcService {
25 /// Perform a simple health check on the service.
26 ///
27 /// The threshold, or delta, between the server's system time and the
28 /// timestamp in the most recently executed checkpoint for which the server
29 /// is considered to be healthy. If not provided, the server's tip is not
30 /// subject to a staleness check.
31 ///
32 /// Independent of the threshold, when indexing is enabled the server is
33 /// only considered healthy once its live-object indexes have caught up to
34 /// within `MAX_HEALTHY_INDEX_LAG` checkpoints of the latest executed
35 /// checkpoint. When indexing is disabled this check is skipped.
36 pub fn health_check(&self, threshold_seconds: Option<u32>) -> Result<()> {
37 let latest = self.reader.inner().get_latest_checkpoint()?;
38
39 // If we have a provided threshold, check that it's close to the current
40 // time.
41 if let Some(threshold_seconds) = threshold_seconds {
42 let latest_chain_time = latest.timestamp();
43
44 let threshold = SystemTime::now() - Duration::from_secs(threshold_seconds as u64);
45
46 if latest_chain_time < threshold {
47 return Err(anyhow::anyhow!(
48 "The latest checkpoint timestamp is less than the provided threshold"
49 )
50 .into());
51 }
52 }
53
54 // When indexing is enabled, the node is only healthy once its
55 // live-object indexes (owned objects, types, balances) have kept up
56 // with the executed tip. Those indexes are restored to the tip and
57 // follow it, so a healthy node's live frontier trails execution by at
58 // most the indexer's snapshot window. The ledger-history cohort
59 // backfills independently after a restore and is deliberately excluded:
60 // gating on it would report a node unhealthy for the whole backfill
61 // even though its live-object reads are already caught up. The executed
62 // tip is read unbounded (rather than via `get_latest_checkpoint`, which
63 // is itself bounded to the live frontier) so a stalled live indexer,
64 // whose frontier falls behind ongoing execution, is still detected. A
65 // node without an index surface (indexing disabled) skips this check.
66 if let Some(indexes) = self.reader.inner().indexes() {
67 let executed = self
68 .reader
69 .inner()
70 .get_highest_executed_checkpoint_seq_number()?;
71 let highest_live_indexed = indexes.get_highest_live_indexed_checkpoint_seq_number()?;
72
73 if !index_caught_up(executed, highest_live_indexed, MAX_HEALTHY_INDEX_LAG) {
74 return Err(anyhow::anyhow!(
75 "the live-object index is not caught up to within {MAX_HEALTHY_INDEX_LAG} \
76 checkpoints of the latest executed checkpoint"
77 )
78 .into());
79 }
80 }
81
82 Ok(())
83 }
84}
85
86/// Whether the highest live-indexed checkpoint is close enough to the executed
87/// tip to be considered healthy.
88///
89/// `highest_live_indexed` is `None` when the live-object index has not committed
90/// any checkpoint yet, which is never healthy. The live frontier never runs
91/// ahead of execution (it indexes executed checkpoints), but an equal frontier
92/// saturates to a zero lag rather than underflowing.
93fn index_caught_up(executed_seq: u64, highest_live_indexed: Option<u64>, max_lag: u64) -> bool {
94 match highest_live_indexed {
95 Some(indexed) => executed_seq.saturating_sub(indexed) <= max_lag,
96 None => false,
97 }
98}
99
100#[derive(Debug, serde::Serialize, serde::Deserialize)]
101pub struct Threshold {
102 /// The threshold, or delta, between the server's system time and the timestamp in the most
103 /// recently executed checkpoint for which the server is considered to be healthy.
104 ///
105 /// If not provided, the server will be considered healthy if it can simply fetch the latest
106 /// checkpoint from its store and, when indexing is enabled, its indexes have caught up to it.
107 pub threshold_seconds: Option<u32>,
108}
109
110pub async fn health(
111 Query(Threshold { threshold_seconds }): Query<Threshold>,
112 State(state): State<RpcService>,
113) -> impl axum::response::IntoResponse {
114 match state.health_check(threshold_seconds) {
115 Ok(()) => (axum::http::StatusCode::OK, "up"),
116 Err(_) => (axum::http::StatusCode::SERVICE_UNAVAILABLE, "down"),
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 // The live-object index has not committed any checkpoint yet: never
125 // healthy.
126 #[test]
127 fn not_caught_up_when_unindexed() {
128 assert!(!index_caught_up(100, None, MAX_HEALTHY_INDEX_LAG));
129 assert!(!index_caught_up(0, None, MAX_HEALTHY_INDEX_LAG));
130 }
131
132 // The live frontier is within (or at) the allowed lag of the executed tip:
133 // healthy.
134 #[test]
135 fn caught_up_within_lag() {
136 assert!(index_caught_up(100, Some(100), 60)); // no lag
137 assert!(index_caught_up(100, Some(40), 60)); // exactly at the bound
138 assert!(index_caught_up(100, Some(41), 60)); // inside the bound
139 }
140
141 // The live frontier trails the executed tip by more than the allowed lag
142 // (e.g. the live indexer stalled while execution advanced): unhealthy. A
143 // lagging ledger-history backfill does not reach this path -- it is not part
144 // of the live frontier.
145 #[test]
146 fn not_caught_up_beyond_lag() {
147 assert!(!index_caught_up(100, Some(39), 60)); // one past the bound
148 assert!(!index_caught_up(1_000, Some(0), 60)); // live index far behind
149 }
150
151 // A live frontier level with the executed tip saturates to zero lag rather
152 // than underflowing.
153 #[test]
154 fn caught_up_when_index_at_tip() {
155 assert!(index_caught_up(100, Some(100), 60));
156 assert!(index_caught_up(100, Some(200), 60));
157 }
158}