Skip to content

fix(alerts): guard anomaly tick resolves and lease escalation claims - #2315

Merged
Makisuo merged 9 commits into
mainfrom
fix/alert-lifecycle-races
Nov 6, 2026
Merged

Makisuo merged 9 commits into
mainfrom
fix/alert-lifecycle-races

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2310 (uses its transitionRow helper).

Anomaly incidents. The tick's two resolves (returned to baseline, no-data sweep) wrote by id only, so they could overwrite a manual resolve's reason and time, or re-resolve an incident an overlapping tick had just reopened or continued. They now go through one guarded resolve: still open, plus the reopen count and lastTriggeredAt the tick read (baseline) or the staleness cutoff (sweep). A lost race is a logged no-op. The sweep clears detector pointers only for rows it actually resolved, and a lost baseline resolve on an incident that is open again keeps its pointer and attach target.

Escalations. The claim bumped attempts but left the row queued, so a tick that read the row after another tick's claim could claim it again and page twice. Claims now take a lease (claimed_at, migration escalation_claim_lease), only on a queued row with no live lease, and attempts fences every later write: finalize is guarded on the claim and on the status it expects, and dispatch is skipped if the pre-dispatch sent flip loses. A requeue releases the lease. A claim that dies before dispatch keeps its lease for 5 minutes, then is retried, and a row whose attempts already hit MAX_ATTEMPTS fails with max_attempts_exceeded.

Note: during the deploy rollout, an old worker's claim leaves claimed_at null, so a new worker could claim the same row once in that window.

Tests (PGlite; races injected right before the tick's UPDATE):

  • manual resolve lands first: reason manual and its time are kept
  • overlapping tick bumps lastTriggeredAt: incident stays open, detector pointer kept
  • two concurrent escalation ticks page once
  • a leased row is left alone, then reclaimed after the lease expires
  • an issue read that fails before dispatch on every tick stops at the attempt budget, zero pages
packages/backend: EscalationService.test.ts + AnomalyDetectionService.test.ts   Tests  25 passed (25)
packages/backend: ErrorPolicyService.test.ts + alerts/anomaly                   Tests  86 passed (86)
packages/db: parity.test.ts                                                     Tests  5 passed (5)

tsrs --noEmit is clean in packages/backend and packages/db.

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

transitionRow runs a caller-built UPDATE guarded on the states the writer read
and fails with TransitionConflictError when the row already moved.
Writers updated status columns by id alone, so one that read state earlier
silently overwrote a concurrent transition. Every writer now guards its UPDATE
on the state it decided from, through transitionRow.

Error issues: applyTransition returns a 409 when it loses the race; the tick
only regresses rows still done; alert and integration reopens land in
regressed with the tick's bookkeeping.

Investigations: late diagnoses on failed or resolved runs are dropped; manual
status changes may only resolve a finished run or reopen it to its own verdict
(MANUAL_INVESTIGATION_TRANSITIONS, 409 otherwise); restart refuses a running
pass; one heartbeat-aware stale sweep; anomaly triage settles only once.
…iters

The enqueue path's stale sweep now uses the shared heartbeat-aware sweep,
markFailed is guarded on investigating, restart rescues a run whose start
never claimed it, and the errors tick skips a wake or lease expiry that lost
to a concurrent move (a renewed snooze included) instead of failing the org's
tick. The investigation detail page disables resolve and restart on a run in
flight, matching the server's rules.
…beat

The lease clear is guarded on still being expired, and only an issue still
in_progress drops to todo. The detail page lets an unclaimed run be run again,
and a renewed snooze is covered by a test.
Anomaly tick resolves (returned to baseline, no-data sweep) now move only an
incident that is still open, at the reopen count or staleness the tick read, so
a manual resolve keeps its reason and an overlapping tick's reopen survives.
The sweep clears detector pointers only for rows it actually resolved.

Escalation claims take a lease (claimed_at) fenced by attempts; finalize is
guarded on the claim and the status it expects, so overlapping ticks page once.
A claim that dies before dispatch still spends an attempt and fails at the budget.
… resolve

The baseline resolve also guards on the lastTriggeredAt the tick read, and a
lost resolve on an incident that is open again keeps it in the tick's attach
index instead of dropping it.
@maple-review-bot

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 17 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d102235e-b083-4968-b424-e90d1c751ce0
Commits

Reviewing files that changed from the base of the PR and between a86d988 and be4a14b.

Files selected for processing: 7

  • packages/backend/src/services/alerts/AnomalyDetectionService.test.ts
  • packages/backend/src/services/alerts/AnomalyDetectionService.ts
  • packages/backend/src/services/alerts/EscalationService.test.ts
  • packages/backend/src/services/alerts/EscalationService.ts
  • packages/db/drizzle/20261106194851_escalation_claim_lease/migration.sql
  • packages/db/drizzle/20261106194851_escalation_claim_lease/snapshot.json
  • packages/db/src/tables/escalations.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@maple-review-bot maple-review-bot 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.

1 inline note from Maple's review. The score and summary are in the review comment above.

… race

A lost baseline resolve (an overlapping tick's lastTriggeredAt bump, or its
resolve and reopen) keeps the incident open, the detector pointer, and the
attach target for a co-onset fingerprint on the next tick.
@Makisuo

Makisuo commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

Added a test for review finding F1 (baseline resolve guard and keepPointer retention), in AnomalyDetectionService.test.ts > runTick baseline resolve races.

It seeds an open error-spike incident whose only series is one healthy tick from returned_to_baseline. A competing write lands right before the tick's resolve UPDATE (same interleaveBefore seam). There are two cases: a last_triggered_at bump, and a resolve and reopen (reopen_count + 1, same last_triggered_at). Each case checks:

  • the tick resolves nothing, and the incident stays open with no resolve reason
  • the detector state keeps open_incident_id and has no last_resolved_at
  • on the next tick, a co-onset fingerprint on the same service/env attaches to that incident (incidentsAttached 1, incidentsOpened 0, still one incident row)

Opens run before resolves inside a tick, so nothing reads the kept in-memory attach target later in the same tick. That is why the attach is checked on the next tick.

Mutation checks: removing the guard fails both cases. Removing only reopenCount fails the reopen case, and removing only triggeredBefore fails the bump case. Forcing keepPointer = false fails both cases on the detector pointer.

vitest run AnomalyDetectionService.test.ts: 13 passed. tsrs --noEmit in packages/backend is clean, and oxlint -c .oxlintrc.effect.json is clean.

@maple-review-bot

This comment has been minimized.

Base automatically changed from fix/guarded-status-transitions to main November 6, 2026 19:42
@maple-review-bot

This comment has been minimized.

The migration was generated before main added alert_silences, leaving two
migration leaves. Regenerated so it chains on the current leaf; the SQL is
unchanged (nullable claimed_at column).
@maple-review-bot

maple-review-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. The only change since the last review is the regenerated snapshot, which chains to alert_silences and carries both new columns.
no findings · tests covered · risk high

Guards the anomaly tick's resolves and turns escalation dispatch into a leased, attempts-fenced claim. The change since the last review is only the regenerated escalation_claim_lease snapshot, which is now correct.

  • Anomaly tick resolves are CAS'd on reopenCount/lastTriggeredAt
  • Escalation claims take a claimed_at lease fenced by attempts

Before merge

be4a14b · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@Makisuo
Makisuo merged commit 01dad9a into main Nov 6, 2026
35 checks passed
@Makisuo
Makisuo deleted the fix/alert-lifecycle-races branch November 6, 2026 20:19
@maple-review-bot

Copy link
Copy Markdown

Merged with 2 steps from "Before merge" still open. If they are done, tick them on the review comment; if not, now is the time.

  • Manual · Roll every alerting worker out together: a claim from an old worker leaves claimed_at null and can be claimed twice in that window
  • Migration · Check it is safe on production data and with the code still deployed

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.

1 participant