Skip to content

fix(canary): one cycle per fleet, not one per uvicorn worker (#1881) - #2047

Merged
vybe merged 2 commits into
devfrom
feature/1881-canary-leader-lease
Aug 6, 2026
Merged

vybe merged 2 commits into
devfrom
feature/1881-canary-leader-lease

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

Closes #1881.

Note on scope: the issue is a pair, and part 1 already landed as #1876 (docker-compose.prod.yml now forwards CANARY_ENABLED / CANARY_SLACK_WEBHOOK_URL, guarded by tests/unit/test_canary_env_prod_parity.py). This PR is part 2, so merging it closes the issue for both.

That ordering matters, because it is the one the issue warned about: "shipping the packaging fix alone converts a dormant bug into a live one." The harness has been enabled on prod compose without a leader lease since #1876, so this is not a hypothetical.

The live state, measured on eu2

  • backend command is uvicorn main:app ... --workers 2
  • canary cycle log lines interleave at ~2m15s / ~2m47s alternating gaps — the signature of two independent 5-minute loops running offset from each other, not one loop
  • 11,942 canary_violations rows in 24h, double-persisted

Why nothing prevented it

The FastAPI lifespan calls canary_service.start(), so every uvicorn worker starts its own loop. The service's only mutual exclusion is self._lock = asyncio.Lock(), which guards re-entrancy inside one process and says nothing about cross-worker exclusion. Reading an asyncio.Lock in a service class as evidence of single-instance-ness is exactly the mistake learnings.md 2026-07-29 records — it is a prompt to ask where the leader lease is, not proof that there is one.

The concrete cost: R-01 docker execs into every running agent container each cycle, so the fleet is probed twice per 5 min per agent by the subsystem meant to observe it unobtrusively; every violation inserts twice; and canary:last_cycle_at, canary:last_cycle_red, E-02's canary:e02:terminal_seen and H-01's canary:h01:suspect_since each have two independent writers.

The fix

The scheduled loop runs only while it holds a Redis canary:leader lease — mirroring monitoring:leader (#1464) and opqueue:leader (#1632): SET NX for a single atomic winner, TTL refresh only when the stored id is our own (so a worker can never steal or clobber a sibling's lease), best-effort release on stop() for instant handoff. Every worker still runs its loop and re-evaluates leadership each cycle rather than once at startup, so a dead leader's lease expires and a sibling takes over with no restart.

Two deliberate departures from the precedents, both for the same underlying reason — a duplicate canary cycle is not inert the way a doubled breaker feed (#1464) or an on-conflict create (#1632) is.

1. The TTL carries a floor. interval * 3 is the right scaling for a leader whose work scales with its interval. A canary cycle's cost does not: it is dominated by R-01's container.exec_run sweep across every running agent container, which is bounded by no timeout and scales with fleet size, not with how often we look. The TTL is refreshed once at the top of a cycle, so it must outlast one worst-case cycle plus the inter-cycle sleep — otherwise the lease lapses mid-cycle, a sibling grabs it, leadership flaps, and we are back to the concurrent probing the lease exists to remove. The floor is set to what interval * 3 already yields at the default 300s interval, so the default is unchanged and it only binds at a shorter one.

2. Fail-open is kept, but not on the precedents' reasoning. monitoring_service can fail open because a doubled record_failure() lands in a breaker that is itself fail-open; operator_queue_service can because duplicate creates dedupe on conflict. Neither argument transfers here. We fail open anyway, because the alternative is strictly worse: this is the one subsystem whose purpose is noticing that something went quiet. H-01 exists because a blind collector reports green — and a canary that stops running is that same silent-green one level up, where no invariant can catch it, because invariants only run inside the thing that isn't running. A fail-closed lease would let a Redis blip stop the watcher on every worker at once with nothing saying so. Duplicated probes are noisy and visible; silence is not. A Redis outage is also already a degraded state the harness announces (sources_unavailable; H-01 fires unconfirmed on an unreadable marker) — failing closed would suppress exactly those paths.

What the lease does NOT buy — and what therefore did not change

It is best-effort, and the fail-open above re-opens the multi-writer window precisely when Redis is down. H-01's CONFIRMATION_MIN_SECONDS and R-01's DWELL_SECONDS elapsed-wall-clock gates are therefore untouched. Their docstrings asserted — correctly at the time — that the service holds no lease; those are corrected rather than deleted, with the reason they stay load-bearing spelled out so the next reader does not "simplify" either back to a cycle count.

There is a second, more fundamental reason than the fail-open window: both gates ride out a real-time transient (a container finishing teardown; a claude child awaiting its parent's wait()). That is a single-worker property, so a cycle count was never the right unit at any worker count.

One knock-on is recorded at R-01's _MAX_OBSERVATION_GAP_SECONDS: a leader failover leaves up to ~1200s (TTL + interval) with nobody cycling, exceeding that 600s window and restarting the dwell. Correct — a crashed leader is a genuine observation outage by that constant's own rule — and restarting is the fail-safe direction, so the constant is documented, not widened.

run_cycle() is deliberately not gated. The lease belongs to the scheduled path, which is what runs unattended in every worker. POST /api/canary/run-cycle lands on whichever worker uvicorn routes it to, so gating there would make an explicit admin request return an empty result about half the time under --workers 2 — structurally identical to a green cycle, the exact ambiguity the skipped/409 contract was added to remove. Non-leaders log on the leadership transition only; narrating idleness every 5 minutes is how real canary output gets tuned out.

Acceptance criteria

Tests

tests/unit/test_1881_canary_leader_lease.py — 23 tests. Under tests/unit/ deliberately: the 3,700-line canary suite at tests/ root is collected by no CI workflow (#2037), so a guard beside it would never go red.

Covers the lease mechanics (single winner, refresh keeps leadership, refresh actually re-arms the TTL), release semantics (immediate handoff, own-lease-only, stop() releases, never raises), failover on TTL expiry, both fail-open arms (Redis None and Redis raising), TTL sizing (outlasts a cycle + sleep; floor is interval-independent), transition-only logging, run_cycle staying ungated, and the key's namespace.

Two behavioural guards worth calling out:

  • a non-leader never reaches collect_snapshot — the saving is the probe, not the bookkeeping. This drives the real run_cycle / _run_cycle_inner path with db/Redis/sink stubbed, and carries a leader=True positive control: _loop swallows every cycle exception, so a raise-on-call probe would be silently eaten and pass vacuously with or without the gate.
  • the confirmation gates survive — pins that H-01's and R-01's elapsed-time gates are still present and shorter than the lease TTL, plus the concrete mechanism (with Redis down, both workers are leader and share the markers).

Meta-checked: removing the gate from _loop turns test_loop_runs_cycle_only_when_leader[False] and test_non_leader_never_collects_a_snapshot[False] red, and only those.

Full unit suite: 7823 passed, 18 skipped (baseline 7801 + these tests).

No schema, endpoint, config or compose change.

#1881 describes two chained defects and says they must land together, because
"shipping the packaging fix alone converts a dormant bug into a live one".
Part 1 shipped in #1876 — docker-compose.prod.yml now forwards CANARY_ENABLED
and CANARY_SLACK_WEBHOOK_URL. Part 2 did not, so the warned-about state has
been live since.

Verified on eu2 today. The backend command is `uvicorn main:app ... --workers 2`;
the canary cycle log lines interleave at ~2m15s / ~2m47s alternating gaps, which
is the signature of two independent 5-minute loops running offset from each
other; and canary_violations holds 11,942 rows for the last 24h, double-persisted.

Why there was nothing stopping it

The FastAPI lifespan calls canary_service.start(), so each worker process starts
its own loop. The only mutual exclusion in the service is
`self._lock = asyncio.Lock()`, which guards re-entrancy inside ONE process and
says nothing about cross-worker exclusion. Reading an asyncio.Lock in a service
class as evidence of single-instance-ness is exactly the mistake learnings.md
2026-07-29 records; it is a prompt to ask where the leader lease is, not proof
that there is one.

What that costs, concretely: R-01 `docker exec`s into every running agent
container each cycle, so the fleet is probed twice per 5 min per agent by the
subsystem that is supposed to observe it unobtrusively. Every violation is
inserted twice. And every shared cross-cycle marker — canary:last_cycle_at,
canary:last_cycle_red, E-02's canary:e02:terminal_seen and H-01's
canary:h01:suspect_since — has two independent writers.

The fix

The scheduled loop now runs only when it holds a Redis `canary:leader` lease,
mirroring monitoring:leader (#1464) and opqueue:leader (#1632): SET NX to pick a
single winner atomically, TTL refresh only when the stored id is our own (so a
worker can never steal or clobber a sibling's lease), best-effort release on
stop() for an instant handoff. Every worker still runs its loop and re-evaluates
leadership each cycle rather than once at startup, so a dead leader's lease
expires and a sibling takes over with no restart.

Two places it deliberately departs from the precedents, both for the same
underlying reason — a duplicate canary cycle is not inert the way a doubled
breaker feed (#1464) or an on-conflict create (#1632) is.

TTL carries a floor. `interval * 3` is the right scaling for a leader whose work
scales with its interval. A canary cycle's cost does not: it is dominated by
R-01's container.exec_run sweep across every running agent container, which is
bounded by no timeout and scales with FLEET SIZE, not with how often we look.
The TTL is refreshed once at the top of a cycle, so it must outlast one
worst-case cycle plus the inter-cycle sleep — otherwise the lease lapses
mid-cycle, a sibling grabs it, and leadership flaps, restoring the exact
concurrent probing the lease exists to remove. The floor is set to what
`interval * 3` already yields at the default 300s interval, so the default is
unchanged and it only binds if the service is constructed with a shorter one.

Fail-open is kept, but not on the precedents' reasoning. monitoring_service can
fail open because a doubled record_failure() lands in a breaker that is itself
fail-open; operator_queue_service can because duplicate creates dedupe on
conflict. Neither argument transfers — here the duplicate re-runs the sweep and
double-persists rows. We fail open anyway, because the alternative is strictly
worse: this is the one subsystem whose purpose is noticing that something went
quiet, H-01 exists because a blind collector reports green, and a canary that
stops running is that same silent-green one level up, where no invariant can
catch it because invariants only run inside the thing that isn't running. A
fail-closed lease would let a Redis blip stop the watcher on every worker at
once with nothing saying so. Duplicated probes are noisy and visible; silence is
not. A Redis outage is also already a degraded state the harness is built to
announce (sources_unavailable; H-01 fires unconfirmed on an unreadable marker),
and failing closed would suppress precisely those paths.

What the lease does not buy, and what therefore did not change

It is best-effort, and the fail-open above re-opens the multi-writer window
exactly when Redis is down. H-01's CONFIRMATION_MIN_SECONDS and R-01's
DWELL_SECONDS elapsed-wall-clock gates are therefore untouched, and their
docstrings — which asserted, correctly at the time, that the service holds no
lease — are corrected rather than deleted, with the reason they remain
load-bearing spelled out so the next reader does not "simplify" either back to a
cycle count. There is a second, more fundamental reason beyond the fail-open
window: both gates ride out a REAL-TIME transient (a container finishing
teardown, a claude child awaiting its parent's wait()), which is a single-worker
property that a cycle count never expressed correctly at any worker count.

One knock-on is recorded at R-01's _MAX_OBSERVATION_GAP_SECONDS: a leader
failover leaves up to ~1200s (TTL + interval) with nobody cycling, which exceeds
that 600s window and restarts the dwell. That is correct — a crashed leader is a
genuine observation outage by that constant's own rule — and restarting is the
fail-safe direction, so the constant is documented, not widened.

run_cycle() is deliberately NOT gated. The lease belongs to the scheduled path,
which is the one that runs unattended in every worker. POST /api/canary/run-cycle
lands on whichever worker uvicorn routes it to, so gating there would make an
explicit admin request return an empty result about half the time under
--workers 2 — structurally identical to a green cycle, which is the exact
ambiguity the skipped/409 contract was added to remove. Non-leaders log on the
leadership transition only; narrating idleness every 5 minutes is how real canary
output gets tuned out.

Tests go in tests/unit/ (#2037: the 3,700-line canary suite at tests/ root is
collected by no CI workflow, so a guard beside it would never go red). 23 tests
covering the lease mechanics, failover, both fail-open arms, transition-only
logging, that a non-leader never reaches collect_snapshot — with a leader=True
positive control, since _loop swallows cycle exceptions and a raise-on-call probe
would pass vacuously — that run_cycle stays ungated, and that the two
confirmation gates survive.

No schema, endpoint, config or compose change.
@obasilakis

Copy link
Copy Markdown
Contributor Author

Reviewed the diff rather than the summary. The shape is right — SET NX + own-lease-only refresh, gate placed before collect_snapshot so it removes the actual cost (R-01's exec sweep) rather than just the bookkeeping, run_cycle() deliberately ungated so POST /api/canary/run-cycle doesn't return empty half the time on --workers 2, and transition-only logging. Faithful to monitoring:leader down to the client and the TTL rationale.

Particularly glad the H-01 / R-01 edits are documentation-only and land as "do NOT relax this to a cycle count" with two independent reasons. The lease is best-effort and fails open, so those gates stay load-bearing — and the second reason is the better one: what they ride out is a real-time transient, so cycle count was never the right unit regardless of worker count.

Two residuals. Neither blocks, both are worth a decision rather than discovery.

1. The TTL is refreshed only at the top of a cycle, and the cycle has no upper bound. _LEADER_TTL_FLOOR_SECONDS' own comment says the R-01 sweep is "unbounded by any timeout and scales with FLEET SIZE". So a single wedged container.exec_run past 900s expires the lease mid-cycle, a sibling acquires, and two cycles run concurrently — the exact state the lease exists to remove. It is rare, but it is most likely on a degraded fleet, which is when the canary matters most. A mid-cycle refresh (or bounding the exec) closes it; a longer TTL only widens residual 2.

2. The PR trades zero-gap coverage for no-double-probing, and worker death is not rare on this deployment. Before this change, a worker dying left the sibling cycling with no gap at all. Now: TTL 900s, plus up to interval (300s) before a sibling's next loop iteration notices — ~20 minutes with nobody watching. That is fine if workers die rarely, but this codebase documents the opposite: uvicorn's ping/pong watchdog SIGKILLs workers that miss a 5s response, which is why the fleet runs --timeout-worker-healthcheck 120 at all (see the "Uvicorn Worker Recycling" runbook entry). A recycled leader therefore means a recurring ~20-minute dark window in the one subsystem whose job is noticing silence — the same silent-green shape as #1881's own framing, just intermittent.

Worth considering a sibling takeover keyed on canary:last_cycle_at staleness rather than lease expiry alone, which decouples "how long before someone notices nobody is cycling" from "how long can one cycle safely run". Those are two different quantities and the single TTL currently has to serve both.

Neither needs fixing in this PR — but residual 2 in particular should be a conscious trade, and it belongs in the docstring next to the fail-open reasoning if it stays as-is.

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the diff against monitoring_service._try_acquire_leadership (#1464) and operator_queue_service (#1632), plus the H-01/R-01 docstring corrections and the 23-test module. CI green on all shards.

The bug is real and measured, not inferred: lifespan starts the service per worker, prod is --workers 2, and self._lock is an asyncio.Lock — in-process re-entrancy only. The eu2 evidence (alternating ~2m15s/~2m47s cycle gaps, 11,942 violation rows in 24h) is the right kind of evidence. The lease mirrors the two precedents closely enough to review by diff, and the two documented departures (TTL floor, fail-open on different reasoning) are both justified — the fail-open argument in particular ("a canary that stops running is silent-green one level up, where no invariant can catch it") is the correct call, since duplicated probes are visible and silence is not.

Four things worth a look, none of them blocking on their own.

1. _release_leadership is a non-atomic GET-then-DELETE

if r is not None and r.get(REDIS_KEY_LEADER) == self._worker_id:
    r.delete(REDIS_KEY_LEADER)

If our lease expires between the GET and the DELETE and a sibling wins SET NX in that window, we delete the successor's lease. A third worker then acquires while the successor still has _is_leader = True and runs its cycle → two concurrent cycles, i.e. exactly the duplication this PR removes. The window is one round-trip wide at the instant of TTL expiry, so it's rare and self-heals next cycle.

_try_acquire_leadership's get + expire is not exposed the same way (the value must equal our own id, which no sibling can hold), so only release needs it. A Lua compare-and-delete closes it:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

You already have ScriptCache in redis_breaker_util. #1464/#1632 share the flaw, so matching them is defensible — but this service is the one where a duplicated cycle has real cost, which is the PR's own argument for departing from the precedent elsewhere.

2. The failover blind window is bought entirely by the TTL, and doesn't have to be

_leader_ttl() = max(interval * 3, 900) = 900s at the default. A crashed leader means nobody cycles for up to TTL + interval ≈ 1200s, which R-01's comment correctly flags as exceeding _MAX_OBSERVATION_GAP_SECONDS and restarting the dwell.

The stated reason the TTL must be that long is that it's refreshed once at the top of the cycle, so it has to cover worst-case cycle + sleep. Refreshing it again after run_cycle returns (or a mid-cycle touch around the R-01 sweep) removes that constraint entirely — the TTL then only has to cover the sleep plus one refresh interval, so it could drop to interval * 2 and failover would land inside ~10 min without any risk of lapsing mid-cycle. That seems strictly better than documenting a 20-minute unwatched window as acceptable, given the PR's own position that an unwatched window is the failure mode H-01 exists to announce.

If you'd rather not, fine — but consider stating the ~1200s worst case in architecture.md alongside the lease, not only in the R-01 constant comment where an operator won't look.

3. Two Redis clients, two failure contracts, in one service

Cycle state uses self._redis() (the slot-service client, raises on failure → caught per call site). The lease uses get_breaker_redis() (returns None → fail-open). Different pools, and more importantly divergent semantics inside one cycle: the lease can fail open to leader while the marker reads raise, or vice versa.

Not wrong — the breaker client is the right leaf choice for a fail-open contract, and decode_responses=True there is what makes r.get(...) == self._worker_id a valid str comparison. But nothing in the code says why there are two, and the next reader will "unify" them onto _redis(), silently converting the lease from fail-open to raise-and-be-caught. One sentence at _try_acquire_leadership naming the reason would prevent that. (If the slot-service client is ever constructed with decode_responses=False, that unification also breaks the comparison silently — worth mentioning in the same sentence.)

4. stop() releases the lease without awaiting the cancelled task

self._task.cancel()
self._task = None
self._release_leadership()

stop() is sync, so it can't await — but that means the release can land while a cycle is still unwinding inside the cancelled task, letting a sibling acquire and start a cycle concurrently with our tail. monitoring_service.stop() is async and awaits the cancellation before releasing, which is why it doesn't have this. Given the release is a deliberate optimisation for fast handoff, it's worth one line acknowledging the overlap is possible and bounded, or making stop() async to match the precedent.

5. Question on run_cycle staying ungated

Agreed on the reasoning for the endpoint — a gated on-demand cycle returning empty ~50% of the time is worse than a duplicate. But it is now the one remaining multi-writer path: an admin cycle on a non-leader worker still advances canary:last_cycle_at, writes E-02's terminal_seen and H-01's suspect_since, and persists violations. Does the manual path also drive the alert sink? If it does, a green→red caught by the manual cycle alerts once and the leader's next cycle correctly sees it as continuing-red — fine. If it doesn't, the manual cycle can consume the transition and the Slack alert is lost. Worth confirming in the docstring either way, since the lease makes that the only remaining way for it to happen.

6. Also

  • Any cleanup planned for the ~12k historical double-persisted rows on eu2, or does canary_violations retention absorb them? Not this PR's job, but a follow-up issue would stop them skewing M7's 7-day counts during the #1766 soak.

What's good

  • The TTL floor rationale (cycle cost scales with fleet size, not interval) is exactly the kind of reasoning that's usually missing from a copied lease, and setting the floor to what interval * 3 already yields at the default keeps the change inert.
  • Correcting the H-01/R-01 docstrings instead of deleting them, and stating the second, lease-independent reason those gates are load-bearing (they ride out a real-time transient — a single-worker property), is the durable version. Those comments were about to become actively misleading.
  • Transition-only logging is the right call; a per-cycle "not leader" line is how canary output gets tuned out.
  • test_non_leader_never_collects_a_snapshot with a leader=True positive control is the test that actually proves the saving, given _loop swallows every exception — nice catch, that's a vacuous-pass trap most guards fall into.

…tomic

Review findings (dolho, #2047), plus the mid-cycle residual raised in review.

**1. Non-atomic release could delete a SUCCESSOR's lease.** `_release_leadership`
was GET-then-DELETE: if our lease expired between the two and a sibling won
SET NX in that window, we deleted theirs. A third worker could then acquire
while the successor still had `_is_leader = True` — two concurrent cycles, the
exact state this PR removes. Both writes are now Lua compare-and-{delete,expire}
via the existing `ScriptCache`, so no path can touch a lease that is not ours.
#1464/#1632 share the non-atomic shape; we depart for the same reason this
service departs on the TTL — a duplicated canary cycle is not inert.

**2 + the mid-cycle residual: one TTL was answering two questions.** "How long
may a cycle run" wants LARGE (R-01's exec sweep has no timeout and scales with
fleet size); "how long before a dead leader is noticed" wants SMALL. The single
`max(interval*3, 900)` TTL bought a ~1200s blind failover to pay for (a) — and
did not even close (a): a cycle overrunning 900s lapsed its lease mid-flight and
a sibling acquired anyway.

`_heartbeat_loop` re-arms the lease on its own 60s timer, independent of where
the cycle is, so the TTL only has to survive a couple of missed beats:
`_LEADER_TTL_SECONDS` is 3x the heartbeat (the precedents' rule), and worst-case
failover drops ~1200s → ~780s, owned by `_max_failover_seconds()` so R-01's
comment and architecture.md quote one number instead of three prose copies.

`_MAX_CYCLE_LEASE_SECONDS` (900s) caps how long the heartbeat will keep a
running cycle's lease alive. Without it a WEDGED leader holds the lease forever
and nobody cycles — trading "two workers probing" for "no worker watching",
the wrong direction here. Past the cap we choose the duplicate over the silence,
at ERROR. Same direction as the fail-open.

**3.** Documented why two Redis clients coexist and must not be unified —
including that `decode_responses=True` on the breaker client is what makes the
ownership comparison valid, so a client built without it turns every check
silently False.

**4.** `stop()` is async and awaits the cancelled task before releasing, matching
`monitoring_service`; its one caller in `main.py`'s lifespan already awaits.

**5.** Confirmed and documented: the manual `POST /api/canary/run-cycle` path
DOES drive the alert sink (`run_cycle` → `_run_cycle_inner` →
`CanaryAlerts.emit_transition`), so a transition caught by an admin cycle
alerts and the leader's next cycle correctly sees continuing-red. No lost alert.

Tests: `FakeRedis` grows a client-aware `register_script`. That is load-bearing,
not plumbing — every lease mutation is Lua now, and `_try_acquire_leadership`
fails OPEN, so a fake that raised on `register_script` made every non-leader
return True (the double-running under test) while the suite still read green.
It caught 8 failures here. The two TTL tests asserting the retired single-TTL
design are replaced by ones pinning the new split, including that `ttl <
interval` is deliberate so nobody "fixes" it back.
@obasilakis

Copy link
Copy Markdown
Contributor Author

All five addressed in ee4b86e0, plus the follow-up filed. ⚠️ Worth re-looking before merge — you approved c19e0ba0, and the branch is now ee4b86e0; the approval survived the push but it predates these changes.

1. Non-atomic release — taken. Both writes are Lua compare-and-{delete,expire} through the existing ScriptCache. Agreed on the reasoning for departing from #1464/#1632 here: it is the PR's own argument, and a duplicated canary cycle is not inert the way a duplicated record_failure() is.

2 — taken, and merged with the mid-cycle residual I'd raised separately, because they turned out to be one bug. A post-cycle refresh alone doesn't close it: a cycle overrunning the TTL still lapses, and shortening the TTL makes the mid-cycle exposure worse. The root cause is one TTL answering two questions that want opposite values. _heartbeat_loop re-arms on a 60s timer independent of the cycle, so _LEADER_TTL_SECONDS = 3× the heartbeat (your precedents' rule) and failover drops ~1200s → ~780s. _max_failover_seconds() owns the arithmetic so R-01's comment and architecture.md quote one number instead of three prose copies.

Added beyond what you asked, because the heartbeat introduces it: _MAX_CYCLE_LEASE_SECONDS (900s) caps how long the heartbeat keeps a running cycle's lease alive. Unbounded, a wedged leader holds the lease forever and nobody cycles at all — trading "two workers probing" for "no worker watching", which is the wrong direction for this subsystem specifically. Past the cap we take the duplicate over the silence, at ERROR.

3 — taken. Sentence at _try_acquire_leadership, including your decode_responses=True point: a client built without it returns bytes, every ownership check silently becomes False, and you get no refresh, no release, and both workers acquiring in turn — a failure with no error anywhere in it.

4 — taken. stop() is async and awaits the cancelled task before releasing, matching monitoring_service. Its only caller (main.py lifespan) already awaits.

5 — verified, and the answer is yes. run_cycle → _run_cycle_inner → CanaryAlerts.emit_transition, so a manual cycle does drive the sink: a green→red it catches alerts once, and the leader's next cycle correctly classifies it as continuing-red. No lost alert. Now stated in the docstring.

6 — filed as #2053. Sharper than "retention may absorb it": the doubling stops when eu2 updates past #1881, so if those rows fall inside M7's window the baseline and the soak get measured under different multipliers — which manufactures an apparent drop in violations at exactly the moment pull mode switches on. Cheapest fix is probably to start the baseline clock after the update rather than dedupe, but that constrains scheduling, so it should be a decision.

One test note worth your attention. FakeRedis needed a register_script, and that turned out to be load-bearing rather than plumbing: every lease mutation is Lua now and _try_acquire_leadership fails open, so a fake that raises on register_script makes every non-leader return True — the exact double-running under test — while the suite still reads green. It surfaced as 8 failures. The emulator resolves its target from the client= kwarg at call time rather than closing over self, because _LEASE_SCRIPTS memoises process-wide and the first test's fake would otherwise stay bound for every later one.

I also confirmed +@scripting is granted in the backend Redis ACL (docker-compose.prod.yml), so the Lua path is reachable in prod and the fail-open stays bounded to real outages rather than silently swallowing a permissions error.

Suite: 7825 passed, 18 skipped.

@vybe
vybe merged commit ee3736a into dev Aug 6, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants