Skip to content

Commit 0ff0ae3

Browse files
aaltshulerclaude
andauthored
feat(server): readiness witness and bounded shutdown (RFC 0049) (#612)
feat(server): readiness reports counts, the inventory names the quarantined, a thread keeps the deadline From review: GET /readyz no longer lists graph ids, which are topology behind the authenticated GET /graphs; that route gains `quarantined`, derived from the ledger's applied graphs minus the registry, with sidecar graphs intersected with the applied set so a sidecar for a graph the revision does not name is never a phantom. The shutdown watchdog is an operating-system thread, so a blocked runtime cannot postpone it, and the signal listener is spawned before any graph opens, so the bound covers startup. The flag wins over OMNIGRAPH_SHUTDOWN_GRACE_SECONDS, which is read only when the flag is absent. openapi.json regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cD8PEeUfrzpqYuU1jaZBq feat(server): readiness witness and bounded shutdown (RFC 0048) `GET /readyz` reports whether a replica is serving or draining, the applied `config_digest` it booted from (`booted_serving_digest`), the ledger revision and CAS it read, and its served and quarantined graphs; it answers 503 once shutdown has begun while `/healthz` stays 200. `--shutdown-grace-seconds` (or `OMNIGRAPH_SHUTDOWN_GRACE_SECONDS`, default 25) puts one deadline on graceful shutdown: readiness turns off at the signal, in-flight requests drain, and at the deadline the process exits 2 with the unfinished work logged. `ServingSnapshot` carries the boot facts; `ServerConfig` gains `witness` and `shutdown_grace`; `openapi.json` is regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cD8PEeUfrzpqYuU1jaZBq
1 parent 1609ac3 commit 0ff0ae3

14 files changed

Lines changed: 597 additions & 15 deletions

File tree

crates/omnigraph-api-types/src/lib.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,34 @@ pub struct HealthOutput {
10651065
pub source_version: Option<String>,
10661066
}
10671067

1068+
/// The readiness witness of one replica (`GET /readyz`, RFC 0049): whether
1069+
/// it is serving or draining, the applied revision it booted from, and how
1070+
/// many graphs it does and does not serve. Unauthenticated, so it carries
1071+
/// no graph id: those stay behind `GET /graphs`.
1072+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1073+
pub struct ReadinessOutput {
1074+
/// False once shutdown has begun; the response is then 503.
1075+
pub ready: bool,
1076+
/// `serving` or `draining`.
1077+
pub status: String,
1078+
/// The `config_digest` of the applied revision this process booted from.
1079+
/// Fixed for the life of the process: the server never reloads.
1080+
#[serde(skip_serializing_if = "Option::is_none")]
1081+
pub booted_serving_digest: Option<String>,
1082+
/// The ledger revision the process booted from.
1083+
pub state_revision: u64,
1084+
/// The ledger CAS (`sha256:<hex>`) the process booted from.
1085+
#[serde(skip_serializing_if = "Option::is_none")]
1086+
pub state_cas: Option<String>,
1087+
/// How many graphs this process serves.
1088+
pub served_graph_count: usize,
1089+
/// How many graphs the applied revision names that this process does
1090+
/// not serve, for any reason. `GET /graphs` names them.
1091+
pub quarantined_graph_count: usize,
1092+
/// The bound on graceful shutdown, after which the process exits 2.
1093+
pub shutdown_grace_seconds: u64,
1094+
}
1095+
10681096
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
10691097
#[serde(rename_all = "snake_case")]
10701098
pub enum ErrorCode {
@@ -1611,6 +1639,11 @@ pub struct GraphInfo {
16111639
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16121640
pub struct GraphListResponse {
16131641
pub graphs: Vec<GraphInfo>,
1642+
/// Graphs the applied revision names that this process does not serve,
1643+
/// for any reason, sorted (RFC 0049). Empty when every applied graph is
1644+
/// served.
1645+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
1646+
pub quarantined: Vec<String>,
16141647
}
16151648

16161649
#[cfg(test)]

crates/omnigraph-cluster/src/serve.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ pub struct ServingSnapshot {
4141
pub queries: Vec<ServingQuery>,
4242
pub policies: Vec<ServingPolicy>,
4343
pub diagnostics: Vec<Diagnostic>,
44+
/// The applied revision's `config_digest`: what a server booted from
45+
/// reports as its `booted_serving_digest` (RFC 0049).
46+
pub config_digest: Option<String>,
47+
/// The ledger revision and CAS this snapshot was read from.
48+
pub state_revision: u64,
49+
pub state_cas: Option<String>,
50+
/// Every graph the applied revision names, sorted.
51+
pub applied_graphs: Vec<String>,
52+
/// Applied graphs this snapshot does not serve because pending recovery
53+
/// quarantined them, sorted. A sidecar for a graph the revision does not
54+
/// name is not in this list.
55+
pub quarantined_graphs: Vec<String>,
4456
}
4557

4658
/// Read the applied revision as a serving snapshot — the read-only loader for
@@ -271,6 +283,10 @@ async fn read_snapshot_with_store(
271283
diagnostics.extend(startup_diagnostics);
272284
return Err(diagnostics);
273285
};
286+
let boot_config_digest = state.applied_revision.config_digest.clone();
287+
let boot_state_revision = state.state_revision;
288+
let boot_state_cas = observations.state_cas.clone();
289+
let boot_applied_graphs = applied_graph_ids(&state);
274290

275291
let required_embedding_providers: BTreeSet<String> = state
276292
.applied_revision
@@ -457,6 +473,14 @@ async fn read_snapshot_with_store(
457473
queries,
458474
policies,
459475
diagnostics: startup_diagnostics,
476+
config_digest: boot_config_digest,
477+
state_revision: boot_state_revision,
478+
state_cas: boot_state_cas,
479+
quarantined_graphs: quarantined_graphs
480+
.into_iter()
481+
.filter(|graph_id| boot_applied_graphs.contains(graph_id))
482+
.collect(),
483+
applied_graphs: boot_applied_graphs,
460484
})
461485
}
462486

crates/omnigraph-server/src/handlers.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,48 @@ pub(crate) async fn server_health() -> Json<HealthOutput> {
2828
})
2929
}
3030

31+
/// Readiness witness (RFC 0049).
32+
///
33+
/// Unauthenticated, and therefore minimal: it reports whether this replica
34+
/// is serving or draining, the applied `config_digest` it booted from, the
35+
/// ledger revision and CAS it read, and how many graphs it serves and does
36+
/// not serve. Graph ids are topology and stay behind `GET /graphs`. Answers
37+
/// 503 once shutdown has begun; `/healthz` stays 200 while the process is
38+
/// alive.
39+
#[utoipa::path(
40+
get,
41+
path = "/readyz",
42+
tag = "health",
43+
operation_id = "readiness",
44+
responses(
45+
(status = 200, description = "Serving", body = ReadinessOutput),
46+
(status = 503, description = "Draining", body = ReadinessOutput),
47+
),
48+
)]
49+
pub(crate) async fn server_ready(
50+
State(state): State<AppState>,
51+
) -> (StatusCode, Json<ReadinessOutput>) {
52+
let draining = state.draining.load(std::sync::atomic::Ordering::SeqCst);
53+
let served_graph_count = state.routing().registry.list().len();
54+
let quarantined_graph_count = state.quarantined_graphs().len();
55+
let output = ReadinessOutput {
56+
ready: !draining,
57+
status: if draining { "draining" } else { "serving" }.to_string(),
58+
booted_serving_digest: state.witness.booted_serving_digest.clone(),
59+
state_revision: state.witness.state_revision,
60+
state_cas: state.witness.state_cas.clone(),
61+
served_graph_count,
62+
quarantined_graph_count,
63+
shutdown_grace_seconds: state.shutdown_grace.as_secs(),
64+
};
65+
let status = if draining {
66+
StatusCode::SERVICE_UNAVAILABLE
67+
} else {
68+
StatusCode::OK
69+
};
70+
(status, Json(output))
71+
}
72+
3173
#[utoipa::path(
3274
get,
3375
path = "/graphs",
@@ -81,7 +123,10 @@ pub(crate) async fn server_graphs_list(
81123
})
82124
.collect();
83125
graphs.sort_by(|a, b| a.graph_id.cmp(&b.graph_id));
84-
Ok(Json(GraphListResponse { graphs }))
126+
Ok(Json(GraphListResponse {
127+
graphs,
128+
quarantined: state.quarantined_graphs(),
129+
}))
85130
}
86131

87132
pub(crate) async fn server_openapi(
@@ -114,7 +159,7 @@ const CLUSTER_OPERATION_ID_PREFIX: &str = "cluster_";
114159
/// always-flat endpoints. `/graphs` is the management enumeration —
115160
/// it lives at the root in both single mode (405) and multi mode, and
116161
/// must never be rewritten to `/graphs/{graph_id}/graphs`.
117-
const ALWAYS_FLAT_PATHS: &[&str] = &["/healthz", "/graphs"];
162+
const ALWAYS_FLAT_PATHS: &[&str] = &["/healthz", "/readyz", "/graphs"];
118163

119164
/// In multi-mode `server_openapi`, every protected path-item is
120165
/// reattached under the cluster prefix. Operation IDs gain the

0 commit comments

Comments
 (0)