fix(gateway): expire same-epoch orphaned drain markers past a max-age (#85433) - #85447
fix(gateway): expire same-epoch orphaned drain markers past a max-age (#85433)#85447PRATHAMESH75 wants to merge 1 commit into
Conversation
A .drain_request.json marker orphaned WITHOUT a machine restart wedges the gateway in draining indefinitely: the NS-570 epoch check (PR NousResearch#53050) only recognises a marker that outlived a restart, but a maintenance action that finishes without recreating the container — and whose writer never clears the marker (e.g. a crash between 'action done' and 'cancel drain') — leaves the epoch matching, so the 1s watcher honours the orphan forever and bounces every inbound message with 'draining for a maintenance action' (NousResearch#85433). The marker already carries requested_at but no reader consulted it. Add a lenient max-age fallback (60 min) alongside the epoch check: a same-epoch marker whose requested_at is present, parseable, and older than the bound reads as stale in drain_requested()/drain_notification_suppressed(), with a once-per-marker warning. A marker with no/unparseable requested_at stays honoured (same fail-safe-toward-quiescing leniency as the epoch check), and a deliberately long drain keeps itself alive by re-writing the marker (idempotent write refreshes the stamp). Mirrors the requested_at staleness guard the neighbouring .restart_notify.json marker already applies in gateway/run.py. Fixes NousResearch#85433
|
Closing this as superseded. Upstream landed a maintainer fix for the same issue (#85433) in Redundancy verified: the merged |
What does this PR do?
Fixes #85433: a
.drain_request.jsonmarker orphaned without a machine restart wedges the gateway indrainingforever — every inbound message on every platform is bounced with "⏳ This agent is draining for a maintenance action…" until someone removes the marker by hand.Root cause
The NS-570 fix (PR #53050) stamps the marker with an instantiation epoch, so a marker that survives a machine restart on the durable
HERMES_HOMEvolume is recognised as stale. But that defence rests on the assumption that every drain-gated lifecycle action restarts the machine. When a maintenance action finishes without recreating the container and its writer never clears the marker (e.g. a crash between "action done" and "cancel drain"), the epoch still matches,_marker_epoch_is_stale()correctly returnsFalse, and the 1s watcher honours the orphan indefinitely. In the reported incident a Hermes Cloud instance bounced every Telegram message for ~3 days.The marker already carries
requested_at, but no reader ever consulted it — there was no TTL/max-age fallback, so a same-epoch orphan had unbounded lifetime.The fix
Add a lenient max-age fallback (
_DRAIN_MARKER_MAX_AGE_SECONDS = 60 min) alongside the epoch check, consumed by bothdrain_requested()anddrain_notification_suppressed()via a shared_marker_is_inactive():requested_atis present, parseable, and older than the bound reads as stale, with a once-per-marker warning log (memoised so the 1s watcher doesn't spam);requested_at, a non-string one, or an unparseable one stays honoured — the same fail-safe-toward-quiescing leniency the epoch check already uses for legacy/corrupt markers;write_drain_request()is documented idempotent, and re-writing refreshesrequested_at.60 min is deliberately generous — drain-gated actions (auto-update / image migrate / env edit / profile change) complete in minutes. This mirrors the
requested_atstaleness guard the neighbouring.restart_notify.jsonmarker already applies ingateway/run.py, precisely so "a legitimately old marker should not swallow a fresh action."The writer-side clean-up (the cloud control plane clearing the marker in a
finally) is out of scope for this repo, as the issue notes — the gateway should defend itself regardless, since any writer crash reproduces this.Tests
tests/gateway/test_external_drain_control.py::TestRequestedAtMaxAge— the same-epoch aged-out orphan reading as absent (the regression), a just-within-bound marker staying active, missing/unparseablerequested_atstaying active (leniency), naive-timestamp handling, and an aged orphan no longer suppressing the shutdown broadcast. Fulltest_external_drain_control.pyand the neighbouring drain suites pass locally; ruff + windows-footgun gates clean.Fixes #85433