Skip to content

Liveness check-in for processor run (co-status monitor co-processor-live) #39

Closed

Description

@gregoryfoster

Suggested by CannObserv/status#24, where the operator decided on 2026-10-07: yes to a liveness monitor, created once this issue settles its design. Raised in this repo's #35 hand-off as out of scope there.

Why

Since 2026-10-06T21:35Z (CannObserv/watcher#326), Processor's output decides Watcher's production results. A quiet Processor shows up only in Watcher's own health, and nothing alerts the operator directly. co-processor-drift (status#24) doesn't cover it: its missing means the drift check isn't reporting, not that processor run has stopped consuming.

What co-status provides

A monitor co-processor-live on the existing co-processor tenant, with the same key as the drift check (/etc/processor/status-checkin.key). It uses the same contract: POST http://status:9000/api/v1/monitors/{id}/checkin, with {"status": "ok"|"alert", "variables": {...}} and X-API-Key. Alerts go to the same Mailgun + Slack pair. Silence past interval + grace goes missing, then recovered on the next check-in.

co-status creates the monitor once the table below is filled in, and posts its id here.

To decide here

Field Question
interval_seconds N: how often processor run checks in
grace_seconds How much silence is normal: restarts, deploys (scripts/deploy.sh restarts the unit), slow batches
renotify_seconds Daily, as the drift monitor?
title_template / body_template Only if alert is ever sent; they render variables
variables What each check-in carries (opaque to co-status; every check-in should carry all the template uses)

Design questions

  1. What does a check-in prove? If it fires only after work was done, an empty queue reads as an outage, a false alarm. A check-in from the consume loop on a timer, whether or not there was work, proves the loop is alive. Which does "live" mean for Processor?
  2. ok only, or alert too? E.g. consuming but failing every item. Each alert check-in sends its own report, with no dedupe on co-status's side, so an alert every N minutes is one notification every N minutes.
  3. A co-status outage must not affect Processor (A drift signal when live lags origin/main (status#12's drift check) #35's trap 4). The drift check stays out of processor run's path. A liveness check-in can't, so it needs a short timeout, must never block or slow consumption, and never retries in a loop. A failed check-in logs and moves on.
  4. Deploys: a restart longer than the grace pages. Size the grace for it, or accept the page.

Done when

  • processor run checks in on its cadence, and a stubbed slow or failing co-status provably doesn't stall consumption (tests).
  • The table above is posted on Tenant + drift monitor for Processor (co-processor / co-processor-drift) status#24, so co-status can create the monitor.
  • Live: the monitor goes pending → ok. Stopping processor.service past the grace sends "has stopped reporting", and starting it sends "has recovered", confirmed by the operator.

Activity

  1. gregoryfoster commented on Oct 8, 2026

    @gregoryfoster
    MemberAuthor

    Hand-off from the burn-down epic #24, 2026-10-08. This is next. #40 shipped in #41 (test-only, no deploy).

    Why now. Processor has decided production results since 2026-10-06T21:35:24Z. Since the last check-in it has decided three real changes on its own: the WSLCB item at 2026-10-07T19:29Z (7d020b93…) and again at 22:32Z (abddd5dc…), and 01KTCNXKXN7Z94G495YEGM6JX3 at 19:22Z (85da1fe7…). If Processor goes quiet now, nothing pages the operator directly. The operator said yes to this monitor on status#24.

    Soak policy (operator, 2026-10-07): Watcher's soak runs until 2026-10-09T21:35Z. This item deploys (a restart), and its live Done-when stops processor.service on purpose. Both are allowed during the soak, each announced on watcher#326 beforehand. That's a cross-repo post, drafted here and approved by the operator each time. Doing the deliberate stop after 21:35Z on the 9th needs no heads-up.

    What #35 left you (checked against main at f8f5534):

    • src/processor/checkin.py: read_key(directory) and post_checkin(base_url, monitor_id, key, status, variables, *, timeout). It refuses a malformed key before any request (CR 1), and raises CheckinFailed without quoting headers.
    • src/processor/settings.py:70–90: a separate settings class, so the drift unit never needs the broker credential. It has status_url (default http://status:9000), drift_monitor_id (a ULID pattern) and credentials_directory.
    • deploy/processor-drift.service:39–40: LoadCredential=status-checkin-key:/etc/processor/status-checkin.key, plus a SetCredential= fallback. deploy/processor.service loads only gcs-writer-key, so the main unit needs the Status key added.
    • Consumer.run (consumer.py:118): start, then step in a loop, backing off on any exception. step reclaims when due, then read(count=1, block_ms). One extraction can hold a step for up to 120 s.

    Traps found while drafting:

    1. post_checkin is blocking requests. Called from the async loop, a slow Status stalls consumption for up to its timeout, every interval. Run it off the loop (asyncio.to_thread, or its own task with a bounded asyncio.timeout). Add a test where a stubbed Status hangs and a command is still processed on time (the issue's Done-when). tests/test_checkin.py::test_a_stall_is_cut_off is the existing stub to build on.
    2. What a check-in proves (design question 1). A heartbeat task on a timer proves the event loop is alive, not that the consume loop is making progress. If step is wedged (a hung read, or a reclaim walk that never ends), a free-running heartbeat keeps reporting ok. Recommendation: the consume loop records last_step_at (monotonic) after each completed step, success or backoff, and the heartbeat checks in ok only if that's fresh: newer than the interval plus the longest legitimate step (the extraction timeout plus the read block). Stale means stay silent and let Status go missing. Don't send alert: Status sends a report for every alert check-in with no dedupe (design question 2), and silence already pages.
    3. The child must not read the new credential. The main unit's credentials directory will now hold status-checkin-key next to gcs-writer-key. Containment's allowlist leaves /run/credentials out, and tests/test_child.py (around line 263) pins the GCS key's denial. Add the Status key to that test, so the denial is proven rather than assumed.
    4. Settings. The main Settings and the drift check's class are separate on purpose. Add live_monitor_id (and an interval) where processor run reads them, without making the drift unit need broker settings. An empty live_monitor_id means liveness is off (dev, CI), stated in the starting record.
    5. The grace for deploys (design question 4). A deploy is a stop plus a start, with a boot canary, about 5 s observed. A failed deploy switches back and proves the old build, which takes longer. A transient broker outage (2026-10-06's reboot was about 15 s) shows as backoff, not a stopped loop, and last_step_at advances on backoff. Size the grace above the worst case a deploy produces (give the number in the table), so a normal deploy never pages.

    The status#24 table (the issue's), filled in on status#24, as a cross-repo comment drafted and approved. Status then creates co-processor-live and posts its id. interval_seconds, grace_seconds, renotify_seconds (the drift monitor's is daily), variables (e.g. build, last_step_age_s; no templates if no alert is ever sent).

    Done when (the issue's, plus):

    • A hung or failing Status doesn't delay a command (test).
    • A wedged consume loop goes silent while the event loop stays up (test).
    • The child can't read status-checkin-key (test).
    • Live: pending → ok, and a deliberate stop past the grace sends "has stopped reporting", then "has recovered", confirmed by the operator. That's a stop during the soak unless after 2026-10-09T21:35Z, so a heads-up first.
    • DEPLOYMENT.md (the second credential, the alert's meaning and what to do, the grace) and spec §2/§3 are updated.

    Ship: issue → plan → the status#24 table (approval) → TDD → PR → FF merge → a heads-up on watcher#326 (approval) → scripts/deploy.sh → Status's id in the unit → live checks. The closing comment gives each box with its evidence, the known limits, and the follow-ups (or "none").

    Kickoff prompt:

    Pick up processor#39. Read its hand-off comment from epic #24 first.

  2. added 11 commits that reference this issue on Oct 8, 2026
  3. gregoryfoster commented on Oct 9, 2026

    @gregoryfoster
    MemberAuthor

    Monitor created by Status, disabled: co-processor-live = 01M4H5FH3PTPGEB24XYQJ4NG7A (CannObserv/status#29, issuecomment-6088692433, 2026-10-09T20:26:53Z; 300/900/86400, the same channels as co-processor-drift; check-ins on a disabled monitor are recorded, and the sweep doesn't watch it). The id is in deploy/processor.service on PR #42 (40e2495 test, f3ba617 feat).

    Agreed with Status (status#29 is the record):

    1. Merge #39 Liveness check-in for processor run (co-processor-live) #42, then deploy. Before 2026-10-09T21:35Z a deploy needs the heads-up on watcher#326; after that, none.
    2. Post the first ok (server time, build) on status#29. Status's operator then enables the monitor.
    3. The rehearsal: a PATCH to 60/0 right after a check-in, "has stopped reporting", a PATCH back to 300/900, then our next check-in sends "has recovered". No restart of processor.service during that window. The operator confirms both notices.

    This replaces the plan's deliberate stop: the rehearsal never stops processor.service.

  4. added 6 commits that reference this issue on Oct 9, 2026
  5. gregoryfoster commented on Oct 9, 2026

    @gregoryfoster
    MemberAuthor

    Shipped and live. PR #42 FF-merged at fe0de17 (2026-10-09T21:59:14Z), with one CR round (CR 1–5). scripts/deploy.sh put fe0de17d4c96 live: CI run 37996667935 passed after re-runs through Docker Hub's outage.

    • starting 22:03:07.493863Z: liveness: on 01M4H5FH3PTPGEB24XYQJ4NG7A.
    • First check-in 22:03:07.539540Z: 202, last_progress_age_s 0, backing_off false.
    • Containment: the contained child is refused /run/credentials/processor.service/status-checkin-key (PermissionError), while the service uid can read it (the control). Same for gcs-writer-key.

    Left:

    1. Status enables the monitor.
    2. The rehearsal, a PATCH to 60/0 and back, confirmed by the operator.
    3. The closing comment.
  6. gregoryfoster commented on Oct 9, 2026

    @gregoryfoster
    MemberAuthor

    Done. Shipped in #42 (FF to fe0de17, 2026-10-09T21:59:14Z, one CR round, CR 1–5). Live as fe0de17d4c96 since 22:03:05Z. Monitor co-processor-live = 01M4H5FH3PTPGEB24XYQJ4NG7A (CannObserv/status#29).

    Box Evidence
    processor run checks in on its cadence starting 22:03:07.493863Z with liveness: on 01M4H5FH3PTPGEB24XYQJ4NG7A. First check-in 22:03:07.539540Z, 202. Then every 300 s: Status recorded 22:08, 22:13:08 and 22:18:11, against our ticks at :08:07, :13:07 and :18:07
    A hung or failing Status doesn't delay a command tests/test_consumer.py::test_a_hung_status_never_delays_a_command: Status takes the connection and never answers, and a command completes while the check-in hangs. tests/test_liveness.py: refusals, an unreachable Status, a hung one cut off at the timeout, and the next tick skipped while one is in flight
    A wedged consume loop goes silent while the event loop stays up tests/test_consumer.py::test_a_wedged_loop_goes_silent_while_the_event_loop_stays_up
    The child can't read status-checkin-key tests/test_child.py::test_the_child_cannot_read_the_real_secrets gained both paths. It skips where exedev can't read them, which is the case on co-processor. Live, after the deploy (DEPLOYMENT § Containment): the service uid reads /run/credentials/processor.service/status-checkin-key (the control), and the contained child gets PermissionError. Same for gcs-writer-key
    The table posted, and the monitor created Posted on status#24 (issuecomment-6066016981, approved). #24 had closed on 2026-10-07, so it moved to status#29. Created disabled at 300/900/86400 on the drift monitor's channels, then enabled at 22:08:07Z after the first ok
    Live: pending → ok, then "has stopped reporting" and "has recovered", confirmed by the operator pending → ok at the first check-in. The rehearsal was run by Status's operator on co-processor. It was a PATCH to 60/0 rather than a stop of processor.service, agreed with Status and recorded in the plan's amendments. "Has stopped reporting" went out at 22:14:44Z, and the PATCH back to 300/900 followed within 5 s. Our 22:18:07 tick sent "has recovered" at 22:18:11Z. Both were delivered, and both arrived by email and Slack, confirmed by the operator. Our side: same MainPID throughout, no failed, skipped or stale tick. Timeline and dispatch ids: status#29
    DEPLOYMENT.md and spec §2/§3 § Liveness covers what missing means, the records, the grace, deliberate stops and rollback past #39. Also the second credential in § /etc/processor/, the containment loop over both keys, and the smoke run's credential fallbacks. Spec §2 (Liveness row, tailnet, grants), §3 (the heartbeat) and §7 (its tests); AGENTS.md's infrastructure row

    Known limits:

    • A broker outage counts as alive: it backs off, and backoff is progress. The variables carry backing_off. The broker's own probe is the alarm for that.
    • Consuming but failing every command doesn't page here (plan decision 1). Watcher sees failures per command.
    • A wedge pages at most about 30 min after it starts: 605 s to go stale, plus interval + grace.
    • CO_PROCESSOR_LIVE_INTERVAL_S must move together with the monitor's interval_seconds.
    • A deliberate stop longer than 20 min, or a rollback past Liveness check-in for processor run (co-status monitor co-processor-live) #39, pages unless the monitor is paused first (DEPLOYMENT § Liveness, § Rollback).
    • CI's Redis service image comes from Docker Hub unauthenticated. On 2026-10-09 that failed twice: a rate limit, then the incident "Hub Registry Authenticated Actions Failing". It needed re-runs before the merge, and main's run gated the deploy. It resolved itself, so nothing is filed.

    Follow-ups: none.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions