A dead man's switch incident cannot recover when its heartbeat moves to another team, and nothing says the switch is orphaned #22
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in production on 2026-09-25 while checking why an incident had been open for four days. Nothing was changed; this records what was observed and read from the code. Deployed version: v0.20.1 (
43f0044), chart 1.12.1.Observed
Incident #9 ("No heartbeat from Watchdog", severity critical, team 1 "Default") opened on 2026-09-21 17:43:30Z and is still
triggeredon 2026-09-25, never acknowledged. In that time it has sent 1triggeredand 352remindernotifications, allsenton the first attempt, every 15 minutes, to one topic. Meanwhile the Watchdog heartbeat is arriving normally: the alert was last received 2026-09-25 09:50Z.The reason both are true is that the heartbeat moved to a different team:
The sender's Alertmanager receiver was repointed from integration 1's key to integration 2's on 2026-09-21 (Ryuvia/charts
963f53e, 17:15Z). That is a deliberate change, not a fault. Team 1's switch then correctly declared the heartbeat dead, because no more heartbeats reached team 1.Why it never recovers (from reading
internal/api/deadman.go, not from running a test)sweepDeadmanruns per team. For team 2, row 920 is fresh and firing, so it callsdeadmanRecovered, which selects... WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULLwith team 2's id. The open incident is in team 1, so it findssql.ErrNoRowsand returns. That is correct for isolated teams.resolved, so it callsdeadmanDied, which returns immediately because an incident is already open. Nothing ever sends team 1 another heartbeat, sodeadmanRecoveredis never called for it.So the only way out of this state is a person resolving the incident. The code comment in
deadmanRecoveredsays "recovery is the only automatic way out", and here recovery is structurally unreachable.What is working as designed (so this is not filed as a reminder bug)
The 352 reminders are the intended behaviour.
enqueueRemindersstops on acknowledged, snoozed, resolved or archived, and its own comment says there is no reminder cap because snooze is the mute button. Reminders apply only to teams with no escalation ladder (NOT EXISTS (SELECT 1 FROM escalation_levels ...)). That is why team 2's four incidents on the same night got only atriggeredand aresolvednotification, and team 1's has been reminded ever since. None of that is wrong.The only thing the reminders did here was make an orphaned switch loud. They did not say why it was silent.
The gap
Nothing in the system distinguishes "this heartbeat source died" from "this heartbeat source now reports somewhere else". Both look like a switch that stopped chirping. The switch's team keeps a permanent open incident, the operator's only signal is a reminder every 15 minutes, and the remedy (resolve the incident, remove the matcher from the old team's config) is not suggested anywhere.
Options, none chosen
Recorded so the next attempt does not start from scratch. No option has been tried.
A smaller observation from the same incident
The sweeper ticks every 15 minutes (
sweepInterval = 15 * time.Minuteinarchiver.go) and the switch's timeout is 900 s. Detection therefore takes between 15 and 30 minutes after the last heartbeat. Here: last heartbeat 17:17:40, incident 17:43:30, that is 25 minutes 50 seconds ("last heartbeat 25m ago" in the event detail). TheTimeoutcomment says it must be shorter than Alertmanager'srepeat_interval; that comparison omits the sweep interval. One data point plus the arithmetic; not a defect by itself, but a configured "900 s" does not mean 900 s.What I have not done
deadman_test.go.Closing condition
Close when a decision is recorded here and verified: for whichever option is chosen, either a test that reproduces the two-team scenario and shows the intended outcome, or, if option 4, the documented step. Not on merge alone.
Refs Ryuvia/charts
963f53e(the receiver change), Ryuvia/talos#1 (unrelated; the night's incidents that led here).Cleanup applied to production on 2026-09-25 (the orphaned instance only; the design gap is unchanged)
The stuck incident from this issue was cleared by hand at 10:05:04Z, on request. This comment records what was done and how, so the state in the database is explained. It does not address anything in "The gap" or "Options" above, and the issue stays open.
What changed
Team 2's deadman config (
alertname=Watchdog, 900 s, critical) and its Watchdog alert (row 920) were not touched; that is the heartbeat that matters now. Team 1 therefore watches nothing.How, and what that cost
Applied with SQL in one guarded transaction, not through the API. No API key or session was available, and creating one in the
api_keystable was a bigger step than the change itself, so it was not done. The transaction only ran if the incident was exactly id 9 / team 1 / its deadman group key / still open, and the config was exactlyalertname=Watchdog; otherwise it would have raised and changed nothing. The three incident changes copy whathandleIncidentResolvedoes (UPDATE ... resolution_source = 'manual',stopEscalation,logEvent(evResolved)); the config change is whathandleSetTeamDeadmandoes with an emptymatchers.Two costs of going around the app, stated so nobody has to work them out later:
resolvedevent hasuser_id NULL, so the timeline does not say who resolved it. The handler would have recorded the caller.deadmanRecovereddoes), so nothing that the app would have sent was skipped.Verified afterwards
triggered, 352reminderplus the ones after) and noqueued reminderline was logged after the change. Reminders had been going every 15 minutes since 09-21.received_at10:18:26Z.Still open
Everything in the issue above: nothing tells an operator that a switch is orphaned when its heartbeat moves to another team, and recovery only looks in the team that received the heartbeat. Team 1's stale Watchdog row (id 1,
resolved) is harmless and was left as is. If the heartbeat ever moves again, the same thing will happen.Correction to the comment above: it says incident 9 has 354 notifications "(1
triggered, 352reminderplus the ones after)". The exact breakdown, read fromnotifications, is 1triggeredand 353reminder(last reminder 2026-09-25 09:59:52Z, none since the resolve). The 352 in the issue body was the count when first looked at, before the reminders at 09:44, 09:59 and one more had been sent; the total of 354 is right.Second correction, to my own correction above. I said the 352 in the issue body predates "the reminders at 09:44, 09:59 and one more". That is wrong. The 352 was the reminder count when I queried at about 09:53Z, which already included the 09:44:52 reminder. Exactly one reminder was sent after that query (09:59:52Z), which is what took it from 352 to 353. Final state: 1
triggered+ 353reminder= 354, none sent since the incident was resolved at 10:05:04Z.