Skip to content

feat(worker): health/readiness endpoints + awa health CLI probe#387

Open
hardbyte wants to merge 2 commits into
mainfrom
brian/368-worker-health-endpoints
Open

feat(worker): health/readiness endpoints + awa health CLI probe#387
hardbyte wants to merge 2 commits into
mainfrom
brian/368-worker-health-endpoints

Conversation

@hardbyte

@hardbyte hardbyte commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Closes #368. Unblocks the probe requirements in #344 (Helm) and applies to the deployment shapes in #282/#372 (roadmap WS-1, PR #384).

Design

Worker listener. Opt-in via AWA_HEALTH_ADDR or ClientBuilder::health_addr(SocketAddr) (invalid env value is a BuildError; bind failure aborts start() — a misconfigured probe must not fail silently). Two endpoints:

  • GET /healthz — process liveness. Stays 200 through the graceful drain: a draining worker must not be killed by its liveness probe. The listener runs on service_cancel, so it outlives dispatchers and dies with the services.
  • GET /readyz200 only when the worker can do useful work: Postgres reachable, schema >= the binary's CURRENT_VERSION (newer-schema skew is the supported rolling-deploy shape; older means unmigrated), every queue's claim loop ticking, heartbeat + maintenance alive, not shutting down. Otherwise 503 with a JSON body naming each failing check.

Per the issue's constraint ("must not pull the UI stack into awa-worker") the listener is hand-rolled over tokio::net::TcpListenerzero new dependencies in awa-worker. Bounded request size, per-connection deadline, connection-close responses, 404/405 for anything that isn't GET /healthz|/readyz. Port 0 binds ephemeral and Client::health_listener_addr() reports the bound address.

The readiness flags are the same atomics the runtime snapshot reporter already publishes to awa.runtime_instances — no new state, just an HTTP view of it.

awa health CLI probe for probe-less environments (cron drivers, systemd ExecStartPre, CI smoke): cluster-level readiness from the database alone — reachable, schema migrated for this binary, storage state + active engine, fleet heartbeat counts (90s window, matching the runtime-liveness gates). --json for machines, exit 0 ready / 1 not ready. Fleet counts are informational: an idle cluster with zero workers is still "ready" for producers and migrations.

Tests

Five integration tests on a dedicated awa_health_test database (fault injection must not race the shared suite):

  • baseline: readyz 200 with all fields true; healthz 200; 404 unknown path; 405 POST
  • schema mismatch → 503 (schema_compatible: false) and recovery after restore
  • database outage → 503 (postgres_connected: false) and recovery — injected via ALTER DATABASE … ALLOW_CONNECTIONS false + pg_terminate_backend, no containers needed
  • graceful drain: readyz 503 with shutting_down: true while an in-flight job drains, healthz stays 200, listener gone after shutdown completes
  • invalid AWA_HEALTH_ADDR is a build error

Plus two awa health CLI tests (ready report + exit codes; unreachable DB → exit 1 with a JSON report).

Docs

docs/deployment.md health-checks section rewritten (endpoint semantics, response schema, Kubernetes liveness/readiness manifest, awa health examples); docs/configuration.md gains AWA_HEALTH_ADDR; CHANGELOG entries under Unreleased.

TLA+

No model changes: the endpoints are read-only observers of existing runtime state and touch no storage lifecycle semantics. (The transition-model suite was run and extended separately in #386 this cycle.)

Verification

  • cargo fmt clean; clippy --all-targets --all-features -D warnings clean; cargo build --workspace clean.
  • health_endpoint_test: 5/5 pass. health_cli_test: 2/2 pass.

Follow-ups (out of scope here)

Summary by CodeRabbit

  • New Features

    • Added an optional worker health listener with /healthz and /readyz endpoints for liveness and readiness checks.
    • Added a new awa health command with JSON output and exit codes for readiness checks when no probe server is available.
    • Documented configuration and example probe setups for deployments.
  • Bug Fixes

    • Improved readiness reporting with detailed reasons when the database, schema, or runtime state is not healthy.
    • Added graceful-drain behavior so liveness stays available while readiness can report not ready.

#368)

- Minimal HTTP listener in awa-worker (AWA_HEALTH_ADDR or
  ClientBuilder::health_addr): GET /healthz answers 200 for as long as the
  runtime is up (including graceful drain); GET /readyz answers 200 only
  when Postgres is reachable, the schema is at least the binary's expected
  version, every claim loop is ticking, heartbeat + maintenance are alive,
  and shutdown has not started — else 503 with per-check JSON reasons.
  Hand-rolled over tokio's TcpListener: no new dependencies in awa-worker.
  Port 0 supported; bound address exposed via Client::health_listener_addr.
- awa health CLI probe for probe-less environments: DB reachability,
  schema-vs-binary compatibility, storage state, fleet heartbeat counts;
  --json output; exit 0 ready / 1 not ready.
- Tests: five integration tests on a dedicated awa_health_test database
  (baseline, 404/405, schema mismatch -> 503, database outage via
  ALLOW_CONNECTIONS false + backend termination -> 503 and recovery,
  shutting_down 503 during drain while /healthz stays 200), plus two CLI
  tests (ready JSON + exit codes, unreachable -> exit 1 with report).
- Docs: deployment.md health-checks rewrite with k8s probe manifests,
  configuration.md AWA_HEALTH_ADDR, CHANGELOG entries.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hardbyte, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc9ff737-8f71-4ef7-920e-d2850b07f4e8

📥 Commits

Reviewing files that changed from the base of the PR and between e9167e5 and 44b92ba.

📒 Files selected for processing (1)
  • awa/tests/health_endpoint_test.rs
📝 Walkthrough

Walkthrough

Adds opt-in worker HTTP endpoints (/healthz, /readyz) configurable via AWA_HEALTH_ADDR or ClientBuilder::health_addr, and a new awa health CLI subcommand for cluster readiness probing. Includes integration tests, CHANGELOG entries, and documentation updates for configuration and deployment.

Changes

Health probe feature

Layer / File(s) Summary
Worker health server implementation
awa-worker/src/health_server.rs, awa-worker/src/lib.rs
Implements HealthProbe, ReadyzReport, and a hand-rolled TCP accept loop serving /healthz (liveness) and /readyz (readiness with per-check JSON), with request size/timeout guards and method/path validation.
Client/ClientBuilder health listener wiring
awa-worker/src/client.rs
Adds health_addr to ClientBuilder (resolved from builder value or AWA_HEALTH_ADDR), binds a listener during start(), spawns health_server::serve, and exposes Client::health_listener_addr().
awa health CLI command
awa-cli/src/health.rs, awa-cli/src/main.rs, awa-cli/tests/health_cli_test.rs
Adds HealthReport, probe, unreachable_report, render_human, and a Health subcommand that connects to Postgres, prints JSON/human output, and exits 1 when not ready; includes CLI integration tests.
End-to-end tests and documentation
awa/tests/health_endpoint_test.rs, docs/configuration.md, docs/deployment.md, CHANGELOG.md
Adds worker-level integration tests covering readiness, schema mismatch, drain, and DB outage scenarios, plus docs and changelog entries describing the new endpoints and CLI command.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Suggested labels: full-ci

Poem

Thump-thump, I check the burrow's beat,
/healthz and /readyz, oh so neat! 🐇
Postgres pulse and schema true,
awa health tells me what to do.
Drain or run, I hop with cheer,
Green fields ahead — the coast is clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: worker health/readiness endpoints plus the new awa health CLI probe.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Jul 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9167e572c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// deploy skew (older binary against newer schema); an older schema
// means `awa migrate` has not run and the runtime cannot work.
let schema_version = if postgres_connected {
awa_model::migrations::current_version(&self.pool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a read-only schema version check

In the rolling-deploy case this probe explicitly allows (database schema newer than this binary), awa_model::migrations::current_version is not a read-only lookup: the current migration helper treats a raw schema_version outside this binary's range with any row >= 6 as legacy and rewrites the table back to the normalized legacy version. An older worker can therefore corrupt schema metadata just because Kubernetes polls GET /readyz (the same pattern exists in awa-cli/src/health.rs) and then report the newer schema as incompatible instead of ready. Use a non-mutating max-version query for probes, or make current_version safe for future versions before calling it here.

Useful? React with 👍 / 👎.

Comment thread awa-worker/src/client.rs
Comment on lines +1617 to +1621
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|err| {
awa_model::AwaError::Validation(format!(
"failed to bind health listener on {addr}: {err}"
))
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind the health listener before spawning services

When the configured health address is already in use, this ? returns an error after the completion batcher and cancel listener have already been spawned into self.service_handles. Callers see start() fail and normally will not call shutdown(), so the cancel listener can keep a DB connection/task alive even though startup supposedly aborted. Bind before starting background services, or cancel and join the already-spawned handles on this error path.

Useful? React with 👍 / 👎.


// Simulate a binary that is newer than the schema: remove the newest
// schema_version row so the DB reports an older version.
sqlx::query("DELETE FROM awa.schema_version WHERE version = $1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate destructive health endpoint tests

These tests all share the single awa_health_test database, and the Rust test harness runs tests in this integration-test binary in parallel by default. While this test deletes the global schema version row (and the outage test later disables connections to the same database), concurrently running baseline/drain tests can observe schema_compatible: false or lose DB access and time out. Serialize the fault-injection tests or create a separate database per test.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
awa-worker/src/client.rs (1)

1605-1639: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Health-listener bind failure leaks already-spawned service tasks.

completion_batcher.spawn() and the cancel listener are pushed into service_handles (lines 1607-1610) before the fallible TcpListener::bind for the health listener (lines 1617-1621). If the bind fails (port in use, permission denied on a privileged port, etc.), start() returns Err via ?, but nothing cancels service_cancel or awaits service_handles — those already-spawned background tasks (holding DB connections/LISTEN sockets) keep running detached for the life of the process, contradicting the stated intent that "Bind failures abort startup."

This breaks the pattern the rest of start() follows, where every fallible step up to this point runs before anything is spawned. Moving the health-listener bind earlier (before completion_batcher/cancel_listener are created) restores that invariant with no behavior change on the success path.

♻️ Suggested reordering (conceptual — bind before any service spawns)
// Resolve/bind the health listener first — before anything else is
// spawned — so a bind failure aborts start() with nothing to leak.
let health_listener = match self.health_addr {
    Some(addr) => {
        let listener = tokio::net::TcpListener::bind(addr).await.map_err(|err| {
            awa_model::AwaError::Validation(format!(
                "failed to bind health listener on {addr}: {err}"
            ))
        })?;
        let bound = listener.local_addr().map_err(|err| {
            awa_model::AwaError::Validation(format!(
                "failed to resolve health listener address: {err}"
            ))
        })?;
        let _ = self.health_bound_addr.set(bound);
        Some(listener)
    }
    None => None,
};

// ... existing completion_batcher / cancel_listener / service_handles setup ...

if let Some(listener) = health_listener {
    let probe = HealthProbe {
        pool: self.pool.clone(),
        dispatcher_alive: self.dispatcher_alive.clone(),
        heartbeat_alive: self.heartbeat_alive.clone(),
        maintenance_alive: self.maintenance_alive.clone(),
        leader: self.leader.clone(),
        dispatch_cancel: self.dispatch_cancel.clone(),
    };
    let cancel = self.service_cancel.clone();
    service_handles.push(tokio::spawn(health_server::serve(listener, probe, cancel)));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-worker/src/client.rs` around lines 1605 - 1639, The health listener bind
currently happens after completion_batcher.spawn() and the cancel listener are
already added to service_handles, so a TcpListener::bind failure can leak
already-spawned background tasks. Reorder the start() flow in client.rs so the
health_addr bind and local_addr resolution happen before any service handles are
spawned or pushed, and only create/push the completion_batcher, cancel listener,
and health_server::serve task after the bind succeeds.
♻️ Duplicate comments (1)
awa/tests/health_endpoint_test.rs (1)

271-310: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

DB-outage injection disables connections for the entire shared test database.

Same root cause as the schema-mismatch test (see comment above): ALTER DATABASE awa_health_test WITH ALLOW_CONNECTIONS false plus pg_terminate_backend on every backend for that database affects all connections to awa_health_test, not just this test's pool. If any other test in this file (e.g. the baseline or drain test) is running concurrently, its pool connections get killed and new connections refused, producing spurious failures unrelated to the intended scenario.

Consider giving fault-injection tests (this one and the schema-mismatch test) their own dedicated per-test database, or serializing this file's tests, to make the suite deterministic regardless of test-runner parallelism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa/tests/health_endpoint_test.rs` around lines 271 - 310, The DB-outage
injection in test_readyz_flips_503_when_database_unreachable is mutating the
shared awa_health_test database for all tests, which can kill unrelated pools
and refuse new connections. Fix this by isolating the fault-injection tests
(this one and the schema-mismatch test) from the rest of the file, either by
giving each test its own dedicated database or by serializing the
health_endpoint_test suite so only one test can run at a time. Keep the change
localized around test_readyz_flips_503_when_database_unreachable and the related
schema-mismatch test setup.
🧹 Nitpick comments (3)
awa-cli/src/health.rs (1)

56-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Schema-compatibility logic duplicated with the worker's /readyz.

This schema_compatible derivation is identical to awa-worker/src/health_server.rs's readyz() (both compute schema_version.is_some_and(|v| v >= expected)), and postgres_connected via SELECT 1 is the same pattern too. Since both already depend on awa-model, consider hoisting this into a small shared helper (e.g. awa_model::migrations::schema_compatible(pool) -> (Option<i32>, bool)) to keep the two probes from drifting if the compatibility rule ever changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-cli/src/health.rs` around lines 56 - 59, The schema-compatibility and
postgres-connectivity checks are duplicated between `health.rs` and the worker’s
`readyz()` in `health_server.rs`, so update the
`schema_compatible`/`postgres_connected` logic to use a shared helper in
`awa_model` instead of keeping two separate implementations. Move the common
compatibility derivation (including the `SELECT 1` connectivity check) into a
small reusable function such as `awa_model::migrations::schema_compatible(...)`,
then have both health probes call it so the rule stays consistent if it changes
later.
awa-cli/tests/health_cli_test.rs (1)

56-77: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Test doesn't exercise a real connect-timeout hang.

127.0.0.1:9 is normally closed and yields an immediate connection refusal, so this doesn't actually validate that --connect-timeout bounds wall-clock time against a genuinely unresponsive/black-holed host (e.g. a non-routable IP). Given the tool's cron/CI/systemd use case, a test that connects to something like 10.255.255.1 and asserts the command exits within a bounded window would give real confidence in the timeout behavior — this ties into the timeout gap flagged in main.rs/health.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-cli/tests/health_cli_test.rs` around lines 56 - 77, The current health
CLI test uses a closed localhost port, so it only checks immediate connection
refusal rather than a real connect-timeout. Update
test_health_unreachable_database_exits_nonzero_with_report to target a
black-holed/non-routable host and assert the Command::cargo_bin("awa")
invocation completes within a bounded duration while still producing the
expected JSON report. Keep the existing assertions on health output, but make
the test exercise the actual timeout path in the health/main connection flow.
awa-worker/src/health_server.rs (1)

66-90: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

/readyz DB check has no independent timeout.

sqlx::query("SELECT 1").execute(&self.pool) acquires from the same pool used by dispatchers/heartbeat/maintenance. If the pool is saturated under load, this call can block well beyond a "quick ping," and the only backstop is the blunt CONNECTION_DEADLINE (5s) in serve(), which just drops the TCP connection rather than returning a proper 503. Infra probes then see a connection reset/timeout instead of a fast, informative 503.

Wrapping the check with a short explicit timeout (e.g. 1–2s) would let the probe fail fast with postgres_connected: false instead of silently hanging until the outer deadline kills the socket.

♻️ Proposed fix
-        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
+        let postgres_connected = tokio::time::timeout(
+            Duration::from_secs(2),
+            sqlx::query("SELECT 1").execute(&self.pool),
+        )
+        .await
+        .map(|result| result.is_ok())
+        .unwrap_or(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-worker/src/health_server.rs` around lines 66 - 90, The `/readyz` database
ping in HealthProbe::readyz currently depends on sqlx::query("SELECT
1").execute(&self.pool) with no independent timeout, so it can block on pool
saturation and only fail via the outer CONNECTION_DEADLINE in serve(). Add a
short explicit timeout around the postgres connectivity check inside
HealthProbe::readyz so it returns quickly with postgres_connected false and a
proper readiness failure instead of hanging; keep the rest of the readiness
logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@awa-cli/src/health.rs`:
- Around line 47-98: The health probe in probe currently runs several database
calls without any execution deadline, so it can hang even after the pool is
created. Wrap the probe flow in tokio::time::timeout, or apply per-query
timeouts around the SELECT 1, awa_model::migrations::current_version,
awa_model::storage::status, and the runtime count query, and on timeout return
an unreachable-style report with ready set to false. Keep the change localized
to probe and the HealthReport construction so the timeout behavior is consistent
for all probe steps.

In `@awa-cli/src/main.rs`:
- Around line 667-692: The health check flow in Commands::Health only times out
the pool connection, not the actual health::probe(&pool) work, so a stalled
database can still hang indefinitely. Wrap the probe call in a timeout or add
per-query deadlines inside health::probe so the whole health path has a bounded
duration, and keep the existing unreachable_report handling for connection
failures.

In `@awa-worker/src/client.rs`:
- Around line 949-960: The doc comment for Client::build is getting attached to
Client::health_addr because the new health_addr doc block is placed immediately
after the existing “Build the client.” comment. Move the health_addr
documentation so it only annotates health_addr, and ensure build retains its own
doc comment by separating the two doc blocks clearly around the health_addr and
build methods.
- Around line 2090-2098: Move the stray “Health check.” doc comment so it
directly documents `health_check()` instead of `health_listener_addr()`, and
keep the listener address docs attached to `health_listener_addr`. Update the
`Client` impl block around `health_listener_addr` and `health_check` so each
public method has the correct docstring immediately above its declaration.

In `@awa/tests/health_endpoint_test.rs`:
- Around line 157-184: The health endpoint tests are mutating shared database
state in test_readyz_flips_503_on_schema_mismatch, which can race with other
tests using awa_health_test and cause nondeterministic failures. Update the
tests in this file so the schema_version mutation is isolated or the
health-related tests are serialized, and ensure
test_readyz_flips_503_on_schema_mismatch restores the DB state in a way that
cannot affect concurrent tests.

In `@docs/deployment.md`:
- Around line 185-191: Update the readiness probe description in the deployment
docs to match `HealthProbe::readyz` and `ready` in `health_server.rs`: remove
`leader` from the list of failing checks because it is reported but does not
gate readiness. Keep the wording focused on the actual readiness inputs
(`postgres_connected`, `schema_compatible`, `poll_loop_alive`,
`heartbeat_alive`, `maintenance_alive`, `shutting_down`) so the `GET /readyz`
JSON body description reflects the code accurately.

---

Outside diff comments:
In `@awa-worker/src/client.rs`:
- Around line 1605-1639: The health listener bind currently happens after
completion_batcher.spawn() and the cancel listener are already added to
service_handles, so a TcpListener::bind failure can leak already-spawned
background tasks. Reorder the start() flow in client.rs so the health_addr bind
and local_addr resolution happen before any service handles are spawned or
pushed, and only create/push the completion_batcher, cancel listener, and
health_server::serve task after the bind succeeds.

---

Duplicate comments:
In `@awa/tests/health_endpoint_test.rs`:
- Around line 271-310: The DB-outage injection in
test_readyz_flips_503_when_database_unreachable is mutating the shared
awa_health_test database for all tests, which can kill unrelated pools and
refuse new connections. Fix this by isolating the fault-injection tests (this
one and the schema-mismatch test) from the rest of the file, either by giving
each test its own dedicated database or by serializing the health_endpoint_test
suite so only one test can run at a time. Keep the change localized around
test_readyz_flips_503_when_database_unreachable and the related schema-mismatch
test setup.

---

Nitpick comments:
In `@awa-cli/src/health.rs`:
- Around line 56-59: The schema-compatibility and postgres-connectivity checks
are duplicated between `health.rs` and the worker’s `readyz()` in
`health_server.rs`, so update the `schema_compatible`/`postgres_connected` logic
to use a shared helper in `awa_model` instead of keeping two separate
implementations. Move the common compatibility derivation (including the `SELECT
1` connectivity check) into a small reusable function such as
`awa_model::migrations::schema_compatible(...)`, then have both health probes
call it so the rule stays consistent if it changes later.

In `@awa-cli/tests/health_cli_test.rs`:
- Around line 56-77: The current health CLI test uses a closed localhost port,
so it only checks immediate connection refusal rather than a real
connect-timeout. Update
test_health_unreachable_database_exits_nonzero_with_report to target a
black-holed/non-routable host and assert the Command::cargo_bin("awa")
invocation completes within a bounded duration while still producing the
expected JSON report. Keep the existing assertions on health output, but make
the test exercise the actual timeout path in the health/main connection flow.

In `@awa-worker/src/health_server.rs`:
- Around line 66-90: The `/readyz` database ping in HealthProbe::readyz
currently depends on sqlx::query("SELECT 1").execute(&self.pool) with no
independent timeout, so it can block on pool saturation and only fail via the
outer CONNECTION_DEADLINE in serve(). Add a short explicit timeout around the
postgres connectivity check inside HealthProbe::readyz so it returns quickly
with postgres_connected false and a proper readiness failure instead of hanging;
keep the rest of the readiness logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26c3cc5f-0784-43b5-856c-7307d65aaf0b

📥 Commits

Reviewing files that changed from the base of the PR and between 94ad9f5 and e9167e5.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • awa-cli/src/health.rs
  • awa-cli/src/main.rs
  • awa-cli/tests/health_cli_test.rs
  • awa-worker/src/client.rs
  • awa-worker/src/health_server.rs
  • awa-worker/src/lib.rs
  • awa/tests/health_endpoint_test.rs
  • docs/configuration.md
  • docs/deployment.md

Comment thread awa-cli/src/health.rs
Comment on lines +47 to +98
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,
}
}

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

printf '\n== health.rs outline ==\n'
ast-grep outline awa-cli/src/health.rs --view expanded || true

printf '\n== health.rs relevant lines ==\n'
cat -n awa-cli/src/health.rs | sed -n '1,220p'

printf '\n== search for probe call sites and timeouts ==\n'
rg -n --hidden --glob '!target' 'probe\(' awa-cli . || true
rg -n --hidden --glob '!target' 'acquire_timeout|connect_timeout|tokio::time::timeout|timeout\(' awa-cli . || true

printf '\n== search for module docs mentioning bounded time ==\n'
rg -n --hidden --glob '!target' 'bounded|timeout|health|cron|systemd|CI' awa-cli/src . || true

Repository: hardbyte/awa

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== awa-cli/src/health.rs outline ==\n'
ast-grep outline awa-cli/src/health.rs --view expanded || true

printf '\n== awa-cli/src/health.rs lines 1-220 ==\n'
cat -n awa-cli/src/health.rs | sed -n '1,220p'

printf '\n== probe call sites in awa-cli ==\n'
rg -n --hidden --glob '!target' '\bprobe\(' awa-cli/src || true

printf '\n== health docs / module comments in awa-cli ==\n'
rg -n --hidden --glob '!target' 'bounded|timeout|health|probe' awa-cli/src || true

Repository: hardbyte/awa

Length of output: 8870


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== awa-cli/src/main.rs around health command ==\n'
cat -n awa-cli/src/main.rs | sed -n '110,190p'
printf '\n== awa-cli/src/main.rs around pool setup and health dispatch ==\n'
cat -n awa-cli/src/main.rs | sed -n '650,690p'

printf '\n== search for any timeout wrapping around health::probe ==\n'
rg -n --hidden --glob '!target' 'health::probe|tokio::time::timeout\(|connect_timeout' awa-cli/src/main.rs awa-cli/src/health.rs || true

Repository: hardbyte/awa

Length of output: 6416


Bound awa health with a probe timeout. connect_timeout only covers pool establishment; once health::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 in tokio::time::timeout (or add per-query timeouts) and return unreachable_report()/ready: false on expiry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-cli/src/health.rs` around lines 47 - 98, The health probe in probe
currently runs several database calls without any execution deadline, so it can
hang even after the pool is created. Wrap the probe flow in
tokio::time::timeout, or apply per-query timeouts around the SELECT 1,
awa_model::migrations::current_version, awa_model::storage::status, and the
runtime count query, and on timeout return an unreachable-style report with
ready set to false. Keep the change localized to probe and the HealthReport
construction so the timeout behavior is consistent for all probe steps.

Comment thread awa-cli/src/main.rs
Comment on lines +667 to +692
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);
}
}

Copy link
Copy Markdown

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:

#!/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.rs

Repository: 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.rs

Repository: hardbyte/awa

Length of output: 7803


Wrap the health probe in a timeout
acquire_timeout(...) only covers the initial connection. health::probe(&pool) still issues several queries with no deadline, so a database that accepts the connection and then stalls can block awa health indefinitely in ExecStartPre/CI. Add a timeout around the probe or enforce one per query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-cli/src/main.rs` around lines 667 - 692, The health check flow in
Commands::Health only times out the pool connection, not the actual
health::probe(&pool) work, so a stalled database can still hang indefinitely.
Wrap the probe call in a timeout or add per-query deadlines inside health::probe
so the whole health path has a bounded duration, and keep the existing
unreachable_report handling for connection failures.

Comment thread awa-worker/src/client.rs
Comment on lines 949 to 960
/// Build the client.
/// Serve `GET /healthz` and `GET /readyz` on this address while the
/// runtime is running (#368). Defaults to off; the `AWA_HEALTH_ADDR`
/// environment variable is honoured when this is not set. Port 0 binds
/// an ephemeral port — read it back with
/// [`Client::health_listener_addr`] after `start()`.
pub fn health_addr(mut self, addr: SocketAddr) -> Self {
self.health_addr = Some(addr);
self
}

pub fn build(self) -> Result<Client, BuildError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc comment misattached: build() loses its docstring, health_addr() inherits the wrong one.

/// Build the client. (line 949, pre-existing) is immediately followed by the new health_addr doc block with no blank line, so both concatenate into a single doc comment attached to pub fn health_addr. The actual pub fn build() right below ends up with no doc comment at all.

🩹 Proposed fix
-    /// Build the client.
     /// Serve `GET /healthz` and `GET /readyz` on this address while the
     /// runtime is running (`#368`). Defaults to off; the `AWA_HEALTH_ADDR`
     /// environment variable is honoured when this is not set. Port 0 binds
     /// an ephemeral port — read it back with
     /// [`Client::health_listener_addr`] after `start()`.
     pub fn health_addr(mut self, addr: SocketAddr) -> Self {
         self.health_addr = Some(addr);
         self
     }

+    /// Build the client.
     pub fn build(self) -> Result<Client, BuildError> {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Build the client.
/// Serve `GET /healthz` and `GET /readyz` on this address while the
/// runtime is running (#368). Defaults to off; the `AWA_HEALTH_ADDR`
/// environment variable is honoured when this is not set. Port 0 binds
/// an ephemeral port — read it back with
/// [`Client::health_listener_addr`] after `start()`.
pub fn health_addr(mut self, addr: SocketAddr) -> Self {
self.health_addr = Some(addr);
self
}
pub fn build(self) -> Result<Client, BuildError> {
/// Serve `GET /healthz` and `GET /readyz` on this address while the
/// runtime is running (`#368`). Defaults to off; the `AWA_HEALTH_ADDR`
/// environment variable is honoured when this is not set. Port 0 binds
/// an ephemeral port — read it back with
/// [`Client::health_listener_addr`] after `start()`.
pub fn health_addr(mut self, addr: SocketAddr) -> Self {
self.health_addr = Some(addr);
self
}
/// Build the client.
pub fn build(self) -> Result<Client, BuildError> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-worker/src/client.rs` around lines 949 - 960, The doc comment for
Client::build is getting attached to Client::health_addr because the new
health_addr doc block is placed immediately after the existing “Build the
client.” comment. Move the health_addr documentation so it only annotates
health_addr, and ensure build retains its own doc comment by separating the two
doc blocks clearly around the health_addr and build methods.

Comment thread awa-worker/src/client.rs
Comment on lines 2090 to 2098
/// Health check.
/// The address the health/readiness listener bound during `start()`.
/// `None` when the listener is disabled or `start()` has not run yet.
/// Useful with port 0 (ephemeral) configurations.
pub fn health_listener_addr(&self) -> Option<SocketAddr> {
self.health_bound_addr.get().copied()
}

pub async fn health_check(&self) -> HealthCheck {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Same doc-misattachment bug: health_check() loses its docstring.

/// Health check. (line 2090, pre-existing) now sits directly above the new health_listener_addr doc block, so it attaches to health_listener_addr instead of health_check, leaving pub async fn health_check undocumented.

🩹 Proposed fix
-    /// Health check.
     /// The address the health/readiness listener bound during `start()`.
     /// `None` when the listener is disabled or `start()` has not run yet.
     /// Useful with port 0 (ephemeral) configurations.
     pub fn health_listener_addr(&self) -> Option<SocketAddr> {
         self.health_bound_addr.get().copied()
     }

+    /// Health check.
     pub async fn health_check(&self) -> HealthCheck {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Health check.
/// The address the health/readiness listener bound during `start()`.
/// `None` when the listener is disabled or `start()` has not run yet.
/// Useful with port 0 (ephemeral) configurations.
pub fn health_listener_addr(&self) -> Option<SocketAddr> {
self.health_bound_addr.get().copied()
}
pub async fn health_check(&self) -> HealthCheck {
/// The address the health/readiness listener bound during `start()`.
/// `None` when the listener is disabled or `start()` has not run yet.
/// Useful with port 0 (ephemeral) configurations.
pub fn health_listener_addr(&self) -> Option<SocketAddr> {
self.health_bound_addr.get().copied()
}
/// Health check.
pub async fn health_check(&self) -> HealthCheck {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@awa-worker/src/client.rs` around lines 2090 - 2098, Move the stray “Health
check.” doc comment so it directly documents `health_check()` instead of
`health_listener_addr()`, and keep the listener address docs attached to
`health_listener_addr`. Update the `Client` impl block around
`health_listener_addr` and `health_check` so each public method has the correct
docstring immediately above its declaration.

Comment thread awa/tests/health_endpoint_test.rs
Comment thread docs/deployment.md
Comment on lines +185 to +191
- `GET /readyz` — readiness. `200` only when the worker can do useful work:
Postgres reachable, schema at least the binary's expected version, every
queue's claim loop ticking, heartbeat + maintenance services alive, and not
shutting down. Otherwise `503` with a JSON body naming each failing check
(`postgres_connected`, `schema_version`, `expected_schema_version`,
`schema_compatible`, `poll_loop_alive`, `heartbeat_alive`,
`maintenance_alive`, `shutting_down`, `leader`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

leader isn't actually a "failing check."

Per HealthProbe::readyz (awa-worker/src/health_server.rs), ready is computed from postgres_connected, schema_compatible, poll_loop_alive, heartbeat_alive, maintenance_alive, and !shutting_downleader is reported but never gates readiness. Listing it alongside the other fields under "JSON body naming each failing check" could send an on-call engineer chasing leader: false as a cause of a 503 when it never is one.

✏️ Suggested wording
-  shutting down. Otherwise `503` with a JSON body naming each failing check
-  (`postgres_connected`, `schema_version`, `expected_schema_version`,
-  `schema_compatible`, `poll_loop_alive`, `heartbeat_alive`,
-  `maintenance_alive`, `shutting_down`, `leader`).
+  shutting down. Otherwise `503` with a JSON body naming each failing check
+  (`postgres_connected`, `schema_version`, `expected_schema_version`,
+  `schema_compatible`, `poll_loop_alive`, `heartbeat_alive`,
+  `maintenance_alive`, `shutting_down`). `leader` is also included in the
+  body for observability but never gates readiness.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `GET /readyz` — readiness. `200` only when the worker can do useful work:
Postgres reachable, schema at least the binary's expected version, every
queue's claim loop ticking, heartbeat + maintenance services alive, and not
shutting down. Otherwise `503` with a JSON body naming each failing check
(`postgres_connected`, `schema_version`, `expected_schema_version`,
`schema_compatible`, `poll_loop_alive`, `heartbeat_alive`,
`maintenance_alive`, `shutting_down`, `leader`).
- `GET /readyz` — readiness. `200` only when the worker can do useful work:
Postgres reachable, schema at least the binary's expected version, every
queue's claim loop ticking, heartbeat + maintenance services alive, and not
shutting down. Otherwise `503` with a JSON body naming each failing check
(`postgres_connected`, `schema_version`, `expected_schema_version`,
`schema_compatible`, `poll_loop_alive`, `heartbeat_alive`,
`maintenance_alive`, `shutting_down`). `leader` is also included in the
body for observability but never gates readiness.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/deployment.md` around lines 185 - 191, Update the readiness probe
description in the deployment docs to match `HealthProbe::readyz` and `ready` in
`health_server.rs`: remove `leader` from the list of failing checks because it
is reported but does not gate readiness. Keep the wording focused on the actual
readiness inputs (`postgres_connected`, `schema_compatible`, `poll_loop_alive`,
`heartbeat_alive`, `maintenance_alive`, `shutting_down`) so the `GET /readyz`
JSON body description reflects the code accurately.

…ners

CI runs test binaries without --test-threads=1, so the five health tests
raced on the shared awa_health_test database (schema_version deletion and
ALLOW_CONNECTIONS fault injection are process-wide). Serialize with the
same guard shape as migration_test — process-local mutex + Postgres
advisory lock — and tolerate the duplicate-database creation race.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full CI matrix (Python build+test, E2E) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worker health &amp; readiness endpoints (+ awa health probe)

1 participant