-
Notifications
You must be signed in to change notification settings - Fork 3
feat(worker): health/readiness endpoints + awa health CLI probe
#387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| //! `awa health` — cluster-level readiness probe over the database (#368). | ||
| //! | ||
| //! For environments without HTTP probing (cron drivers, systemd | ||
| //! `ExecStartPre`, CI smoke checks): answers "can a worker built from this | ||
| //! binary do useful work against this database, and is the fleet | ||
| //! heartbeating?" from the schema alone. The per-process equivalents are the | ||
| //! worker's `/healthz` / `/readyz` endpoints (`AWA_HEALTH_ADDR`). | ||
| //! | ||
| //! Exit codes: 0 = ready, 1 = not ready (unreachable database, missing or | ||
| //! stale schema). Fleet numbers are informational — an idle cluster with no | ||
| //! workers is still "ready" for producers and migrations. | ||
|
|
||
| use serde::Serialize; | ||
| use sqlx::PgPool; | ||
|
|
||
| /// The `--json` output. Field names are a documented CLI surface. | ||
| #[derive(Debug, Serialize)] | ||
| pub struct HealthReport { | ||
| pub ready: bool, | ||
| pub postgres_connected: bool, | ||
| pub schema_version: Option<i32>, | ||
| pub expected_schema_version: i32, | ||
| pub schema_compatible: bool, | ||
| pub storage_state: Option<String>, | ||
| pub active_engine: Option<String>, | ||
| /// Runtimes whose heartbeat was seen inside the 90s liveness window. | ||
| pub live_runtimes: i64, | ||
| /// Live runtimes currently reporting `healthy = false`. | ||
| pub unhealthy_runtimes: i64, | ||
| } | ||
|
|
||
| /// The report shape for a database that could not be reached at all. | ||
| pub fn unreachable_report() -> HealthReport { | ||
| HealthReport { | ||
| ready: false, | ||
| postgres_connected: false, | ||
| schema_version: None, | ||
| expected_schema_version: awa_model::migrations::CURRENT_VERSION, | ||
| schema_compatible: false, | ||
| storage_state: None, | ||
| active_engine: None, | ||
| live_runtimes: 0, | ||
| unhealthy_runtimes: 0, | ||
| } | ||
| } | ||
|
|
||
| pub async fn probe(pool: &PgPool) -> HealthReport { | ||
| let postgres_connected = sqlx::query("SELECT 1").execute(pool).await.is_ok(); | ||
|
|
||
| let schema_version = if postgres_connected { | ||
| awa_model::migrations::current_version(pool).await.ok() | ||
| } else { | ||
| None | ||
| }; | ||
| let expected_schema_version = awa_model::migrations::CURRENT_VERSION; | ||
| // A schema newer than the binary is the supported rolling-deploy skew; | ||
| // an older schema means `awa migrate` has not run. | ||
| let schema_compatible = | ||
| schema_version.is_some_and(|version| version >= expected_schema_version); | ||
|
|
||
| let (storage_state, active_engine) = if schema_compatible { | ||
| match awa_model::storage::status(pool).await { | ||
| Ok(status) => (Some(status.state), Some(status.active_engine)), | ||
| Err(_) => (None, None), | ||
| } | ||
| } else { | ||
| (None, None) | ||
| }; | ||
|
|
||
| let (live_runtimes, unhealthy_runtimes) = if schema_compatible { | ||
| sqlx::query_as::<_, (i64, i64)>( | ||
| r#" | ||
| SELECT | ||
| count(*)::bigint, | ||
| count(*) FILTER (WHERE NOT healthy)::bigint | ||
| FROM awa.runtime_instances | ||
| WHERE last_seen_at + make_interval(secs => 90) >= now() | ||
| "#, | ||
| ) | ||
| .fetch_one(pool) | ||
| .await | ||
| .unwrap_or((0, 0)) | ||
| } else { | ||
| (0, 0) | ||
| }; | ||
|
|
||
| HealthReport { | ||
| ready: postgres_connected && schema_compatible, | ||
| postgres_connected, | ||
| schema_version, | ||
| expected_schema_version, | ||
| schema_compatible, | ||
| storage_state, | ||
| active_engine, | ||
| live_runtimes, | ||
| unhealthy_runtimes, | ||
| } | ||
| } | ||
|
|
||
| pub fn render_human(report: &HealthReport) -> String { | ||
| let mut lines = Vec::new(); | ||
| lines.push(format!( | ||
| "ready: {}", | ||
| if report.ready { "yes" } else { "NO" } | ||
| )); | ||
| lines.push(format!( | ||
| "postgres: {}", | ||
| if report.postgres_connected { | ||
| "connected" | ||
| } else { | ||
| "UNREACHABLE" | ||
| } | ||
| )); | ||
| lines.push(format!( | ||
| "schema version: {} (binary expects >= {}){}", | ||
| report | ||
| .schema_version | ||
| .map_or_else(|| "none".to_string(), |v| v.to_string()), | ||
| report.expected_schema_version, | ||
| if report.schema_compatible { | ||
| "" | ||
| } else { | ||
| " — run `awa migrate`" | ||
| } | ||
| )); | ||
| if let (Some(state), Some(engine)) = (&report.storage_state, &report.active_engine) { | ||
| lines.push(format!("storage: {state} ({engine})")); | ||
| } | ||
| lines.push(format!( | ||
| "live runtimes: {} ({} unhealthy)", | ||
| report.live_runtimes, report.unhealthy_runtimes | ||
| )); | ||
| lines.join("\n") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; | |
| use clap::{Args, Parser, Subcommand}; | ||
| use sqlx::postgres::PgPoolOptions; | ||
|
|
||
| mod health; | ||
| mod storage_wait; | ||
|
|
||
| #[derive(Parser)] | ||
|
|
@@ -119,6 +120,15 @@ enum Commands { | |
| #[command(subcommand)] | ||
| command: CallbackCommands, | ||
| }, | ||
| /// Cluster readiness probe: database reachable, schema migrated, fleet heartbeats | ||
| Health { | ||
| /// Emit the report as JSON | ||
| #[arg(long)] | ||
| json: bool, | ||
| /// Seconds to wait for the database connection | ||
| #[arg(long, default_value = "5")] | ||
| connect_timeout: u64, | ||
| }, | ||
| } | ||
|
|
||
| #[derive(Subcommand)] | ||
|
|
@@ -654,6 +664,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| axum::serve(listener, app).await?; | ||
| } | ||
|
|
||
| Commands::Health { | ||
| json, | ||
| connect_timeout, | ||
| } => { | ||
| let db_url = require_pool(&cli.database_url)?; | ||
| let pool_result = PgPoolOptions::new() | ||
| .max_connections(1) | ||
| .acquire_timeout(Duration::from_secs(connect_timeout)) | ||
| .connect(&db_url) | ||
| .await; | ||
|
|
||
| let report = match pool_result { | ||
| Ok(pool) => health::probe(&pool).await, | ||
| Err(_) => health::unreachable_report(), | ||
| }; | ||
|
|
||
| if json { | ||
| println!("{}", serde_json::to_string_pretty(&report)?); | ||
| } else { | ||
| println!("{}", health::render_human(&report)); | ||
| } | ||
| if !report.ready { | ||
| std::process::exit(1); | ||
| } | ||
| } | ||
|
|
||
|
Comment on lines
+667
to
+692
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the relevant snippet in main.rs
sed -n '640,720p' awa-cli/src/main.rs
printf '\n--- health.rs locations ---\n'
rg -n "connect-timeout|probe|systemd|ExecStartPre|timeout" awa-cli/src/health.rs awa-cli/src -g '!target'
printf '\n--- health.rs outline ---\n'
ast-grep outline awa-cli/src/health.rs --view expanded || true
printf '\n--- health.rs selected content ---\n'
sed -n '1,260p' awa-cli/src/health.rsRepository: hardbyte/awa Length of output: 10609 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '640,720p' awa-cli/src/main.rs
printf '\n---\n'
sed -n '1,260p' awa-cli/src/health.rsRepository: hardbyte/awa Length of output: 7803 Wrap the health probe in a timeout 🤖 Prompt for AI Agents |
||
| // Most remaining CLI commands are single-shot (one query, then exit) | ||
| // so a small pool is sufficient. `storage prepare-queue-storage-schema` | ||
| // is the exception: it acquires an advisory-lock connection and then | ||
|
|
@@ -668,7 +704,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| .await?; | ||
|
|
||
| match command { | ||
| Commands::Migrate { .. } | Commands::Serve { .. } | Commands::Callbacks { .. } => { | ||
| Commands::Migrate { .. } | ||
| | Commands::Serve { .. } | ||
| | Commands::Callbacks { .. } | ||
| | Commands::Health { .. } => { | ||
| unreachable!() | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| //! `awa health` CLI probe tests (#368). | ||
| //! | ||
| //! Read-only against the standard test database (plus one unreachable-URL | ||
| //! case), so no schema isolation is needed. | ||
| //! | ||
| //! Set DATABASE_URL=postgres://postgres:test@localhost:15432/awa_test | ||
|
|
||
| use assert_cmd::Command; | ||
| use sqlx::postgres::PgPoolOptions; | ||
|
|
||
| fn database_url() -> String { | ||
| std::env::var("DATABASE_URL") | ||
| .unwrap_or_else(|_| "postgres://postgres:test@localhost:15432/awa_test".to_string()) | ||
| } | ||
|
|
||
| async fn ensure_migrated() { | ||
| let pool = PgPoolOptions::new() | ||
| .max_connections(2) | ||
| .connect(&database_url()) | ||
| .await | ||
| .expect("Failed to connect to test database — is Postgres running?"); | ||
| awa_model::migrations::run(&pool) | ||
| .await | ||
| .expect("migrations should apply"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_health_ready_against_migrated_database() { | ||
| ensure_migrated().await; | ||
|
|
||
| let assert = tokio::task::spawn_blocking(move || { | ||
| let mut command = Command::cargo_bin("awa").expect("awa binary should build"); | ||
| command | ||
| .arg("--database-url") | ||
| .arg(database_url()) | ||
| .arg("health") | ||
| .arg("--json") | ||
| .assert() | ||
| }) | ||
| .await | ||
| .expect("command task should join"); | ||
|
|
||
| let output = assert.success().get_output().stdout.clone(); | ||
| let report: serde_json::Value = | ||
| serde_json::from_slice(&output).expect("health --json should emit JSON"); | ||
| assert_eq!(report["ready"], true); | ||
| assert_eq!(report["postgres_connected"], true); | ||
| assert_eq!(report["schema_compatible"], true); | ||
| assert_eq!( | ||
| report["expected_schema_version"], | ||
| awa_model::migrations::CURRENT_VERSION | ||
| ); | ||
| assert!(report["storage_state"].is_string()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_health_unreachable_database_exits_nonzero_with_report() { | ||
| let assert = tokio::task::spawn_blocking(move || { | ||
| let mut command = Command::cargo_bin("awa").expect("awa binary should build"); | ||
| command | ||
| .arg("--database-url") | ||
| .arg("postgres://postgres:wrong@127.0.0.1:9/awa_nowhere") | ||
| .arg("health") | ||
| .arg("--json") | ||
| .arg("--connect-timeout") | ||
| .arg("2") | ||
| .assert() | ||
| }) | ||
| .await | ||
| .expect("command task should join"); | ||
|
|
||
| let output = assert.failure().code(1).get_output().stdout.clone(); | ||
| let report: serde_json::Value = | ||
| serde_json::from_slice(&output).expect("health --json should emit JSON even when down"); | ||
| assert_eq!(report["ready"], false); | ||
| assert_eq!(report["postgres_connected"], false); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: hardbyte/awa
Length of output: 50369
🏁 Script executed:
Repository: hardbyte/awa
Length of output: 8870
🏁 Script executed:
Repository: hardbyte/awa
Length of output: 6416
Bound
awa healthwith a probe timeout.connect_timeoutonly covers pool establishment; oncehealth::probe()starts,SELECT 1,current_version,storage::status, and the runtime count query can still block forever if Postgres stalls after accepting the connection. Wrap the probe path intokio::time::timeout(or add per-query timeouts) and returnunreachable_report()/ready: falseon expiry.🤖 Prompt for AI Agents