Skip to main content

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}