Skip to content

perf(alerting): fence a rule's state flush in one statement, run the tick tail together - #1552

Merged
Makisuo merged 2 commits into
mainfrom
perf/alerting-fenced-write
Oct 20, 2026
Merged

Makisuo merged 2 commits into
mainfrom
perf/alerting-fenced-write

Conversation

@Makisuo

@Makisuo Makisuo commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

US alerting ticks are bimodal: normal fires take 1.9-4 s, slow fires end at 28.5-30 s (2026-10-18, traces 2ea98b74a6330960b2828c8e2edfee38, 95eb784d9208c79c6af97bf9d0fd6350, 69562acd5d2372b724b2907ee11ddd86, 5750418cd0f9e0000ad53141db847126, c663bffbe1d8e36b3fa8c1888d5f338a). In slow fires every round trip has a floor of about 240 ms. The claim-fenced state write then costs 1,189-1,330 ms per rule (p50), 53-68 s of lane time per tick, more than the warehouse evaluations. That pushes rules past the 25 s deadline: 9-42 rules deferred per slow tick. The tail after the deadline adds another 3.5-5.5 s of dependent round trips. Normal ticks pay the same shape at a smaller floor (fence p50 26-35 ms).

Cause

  • flushRuleStates wrote through writeUnderClaim (AlertsService.ts:624-668 on main): BEGIN, SELECT ... FOR UPDATE on the claim, SELECT ... FOR UPDATE on the rule, the upsert, COMMIT. That is five dependent round trips for one write per rule.
  • The tail ran in order: releaseClaims, flushRuleStateHousekeeping, push digests, the disabled-rule sweep, and alert_checks ingest at concurrency 4 (:4419-4530), then processQueuedDeliveries.

Fix

  • One fenced statement per flush (upsertStatesUnderClaim). A claim CTE takes FOR SHARE on the claim row matching this tick's claim time. A rule CTE takes FOR SHARE on the rule row matching the loaded updatedAt, only if claim matched. The upsert reads its rows from one jsonb_to_recordset parameter and inserts only where exists (select 1 from rule). The statement returns the fence count. 0 means lost, which keeps the existing AlertRuleClaimLostError. Only that path reads the claim again, to say whether the rule was re-claimed or edited. The retry-once on transient errors is unchanged.
  • Same concurrency guarantees. The locks are taken in the order the edit path uses (claim, then rule). A re-claim or edit in flight conflicts with FOR SHARE, so the statement waits for it. Postgres then re-checks the CTE condition against the committed row (READ COMMITTED), finds the new claim time or updatedAt, and writes nothing. A re-claim or edit that starts after the statement waits for it, as it waited for the old transaction. Incident writes still use writeUnderClaim; they happen only on transitions.
  • Tail runs together. Unstarted claims are released first, then the queued investigations start in a forked fiber (from perf(alerting): start alert investigations after the rule loop, 8 at a time #1548, joined before the tick returns). State housekeeping, push digests, the disabled-rule sweep and the checks ingest do not read each other's writes. Disabled rules are never in the housekeeping lists, and the sweep only resolves, which adds nothing to the digests. Each step runs under Effect.exit and the first failure is raised after all finish, so a failure still fails the tick without cutting the others short. Deliveries still run after, so the sweep's resolve notifications go out in the same tick.
  • Ingest concurrency stays at 4. A Worker invocation has six outbound slots, and the housekeeping and sweep statements now run beside these POSTs, so two slots stay free for them.
  • Rebased on perf(alerting): start alert investigations after the rule loop, 8 at a time #1548. Its order is kept: claims are released before triage starts, so a slow model call holds no claim, and triage keeps its per-org admission semaphore around the quota read and insert. Only the four steps after that run together.

Before / After

Metric Before After Change
State flush, one rule, 240 ms per statement 1,212 ms 245 ms -80%
Postgres statements, 6-rule tick 41 17 -59%
Tick, 6 rules, 240 ms per statement (PGlite, serialized) 9,966 ms 4,158 ms -58%
Tick, 1 rule, 240 ms per statement 3,906 ms 2,939 ms -25%
Tail after the last flush, 240 ms per Database.execute and per ingest 1,217 ms 731 ms -40%
Tick, 6 rules, 240 ms per Database.execute 2,948 ms 2,459 ms -17%

Method: all local, PGlite, through the real AlertsService.runSchedulerTick wiring. A scratch live-clock test (not committed) adds 240 ms (the slow-fire floor) to every PGlite statement, or to every Database.execute call and every warehouse ingest for the tail rows, after setup. Durations come from recorded span times. The same test ran against origin/main and this branch, 3 runs each; the medians are shown and the runs varied by under 20 ms. Production fence time was 1.2-1.3 s, and the one-rule local Before matches it (1,212 ms). Limits: PGlite has one connection and serializes every statement, so the 6-rule per-statement tick overstates lane contention on both sides. Only the per-execute mode can show the tail's concurrency, and it has one org. No preview deploy: a preview runs no alerting crons (cronGateFor), so it cannot exercise the tick.

Expected in production: slow fires lose about 1 s of lane time per evaluated rule (about 95-160 rules per slow tick, across 5 lanes), which should remove most deferred rules. Normal ticks save about 20 ms per rule (fence p50 26-35 ms becomes one statement), about 0.6 s over 166 rules on 5 lanes.

Tests: AlertsService fenced state flush has three PGlite cases through the tick. Unchanged claim and rule: the state row is written. A later tick re-claimed the rule mid-evaluation: nothing is written. The rule was edited mid-evaluation: nothing is written. Both negative cases fail when the fence condition is removed (checked by mutation). The existing claim fence test (a re-claim during a slow notification) still passes. A lock wait cannot be tested on PGlite: its single backend cannot release a prepared transaction while a statement waits on it, and lock_timeout does not fire there. bunx vitest run src/services/alerts/AlertsService.test.ts passes (111, after the rebase on #1548). bun run --filter=@maple/backend typecheck passes.

How to verify after deploy

Per 6 h, US: ticks over 25 s and 30 s, and deferred rules:

SELECT toStartOfInterval(Timestamp, INTERVAL 6 HOUR) AS w,
       countIf(SpanName = 'AlertsService.runSchedulerTick' AND Duration > 25e9) AS over_25s,
       countIf(SpanName = 'AlertsService.runSchedulerTick' AND Duration > 30e9) AS over_30s,
       sumIf(toUInt32OrZero(SpanAttributes['maple.alerting.rules_deferred']), SpanName = 'AlertsService.evaluateScheduledRules') AS rules_deferred,
       quantileIf(0.5)(Duration / 1e6, SpanName = 'AlertsService.flushRuleStates') AS flush_p50_ms
FROM traces
WHERE $__orgFilter
  AND SpanName IN ('AlertsService.runSchedulerTick', 'AlertsService.evaluateScheduledRules', 'AlertsService.flushRuleStates')
  AND ResourceAttributes['deployment.environment'] = 'production'
  AND ResourceAttributes['maple.region'] = 'us'
GROUP BY w ORDER BY w

flush_p50_ms should drop to about one round trip (under 10 ms normal, about 250 ms in slow fires). Also check that Alert rule claim lost warnings do not rise.


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


Devin Review

Summary by CodeRabbit

  • Bug Fixes
    • Alert rule state updates now respect newer rule edits and scheduler claims, preventing outdated evaluations from overwriting current state.
  • Performance
    • Background alert processing and related scheduled tasks can run more concurrently, helping reduce delays in alert handling.

@maple-review-bot

maple-review-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
Fence lock modes and order read against the edit path and pinned by the new lost-claim tests; the suite could not be run in the sandbox.
quality 100/100 · no findings · tests covered · risk medium · 1/2 new units observable

Replaces the per-rule state flush with one claim-fenced statement and runs the tick's five tail steps concurrently. The fence keeps the edit path's lock order and the new tests pin both lost-claim cases, so this is safe to merge.

  • upsertStatesUnderClaim writes every state row of a rule in one fenced statement
  • Flush drops RULE_STATE_FLUSH_CHUNK_SIZE; retry re-runs one idempotent statement
  • The tail's five steps run together under Effect.exit, first failure still re-raised
  • ALERT_CHECK_INGEST_CONCURRENCY 4 to 6, matching the Worker's outbound connections

Production impact

Production traffic of the changed files (last 7 days)
File Calls/day Busiest operations
packages/backend/src/services/alerts/AlertsService.ts 2.2k AlertsService.releaseClaims 772/day, 0.0% err
AlertsService.notifyIncidentDigests 772/day, 0.0% err
AlertsService.ingestAlertChecks 607/day, 0.0% err
What was checked
  • Fence takes FOR SHARE on claim then rule, the edit path's order; a re-claim or edit matches no row (AlertsService.ts:702-712)
  • lastValue is nullable, so non-finite values stored as NULL break no column constraint (packages/db/src/tables/alerts.ts:159)
  • Nothing appends to pendingChecks after the drain: all producers run during evaluation (AlertsService.ts:791, 3213, 4081)
Observability coverage: 1 of 2 changes observable
Change Kind Observable Evidence
fenced rule-state flush (upsertStatesUnderClaim) database write yes inside the AlertsService.flushRuleStates span with maple.alerting.state_rows annotated (AlertsService.ts:771-791)
tick tail steps (release, housekeeping, digests, disabled-rule sweep, checks ingest) background steps no four of the five carry a span; sweepDisabledRules (AlertsService.ts:4545) does not, as before the move

0fd7694 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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 39 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
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 49e0f37e-006b-489f-a164-9f017900c9a0
📥 Commits

Reviewing files that changed from the base of the PR and between 4bb573b and e215cc5.

📒 Files selected for processing: 1
  • packages/backend/src/services/alerts/AlertsService.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 73ff056b-935c-4014-be89-44cc0cc9e01d
📥 Commits

Reviewing files that changed from the base of the PR and between 0214234 and 4bb573b.

📒 Files selected for processing: 2
  • packages/backend/src/services/alerts/AlertsService.test.ts
  • packages/backend/src/services/alerts/AlertsService.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.

📝 Walkthrough

Alert state flushing now uses a claim- and rule-update-fenced SQL upsert. Scheduler tick tail tasks run concurrently, and alert-check ingestion concurrency increases from four to six.

Changes: Alert evaluation and scheduler tick

Layer / File(s) Summary
Claim-fenced state upsert
packages/backend/src/services/alerts/AlertsService.ts, packages/backend/src/services/alerts/AlertsService.test.ts
State rows are upserted only when the scheduler claim timestamp and rule updated_at match the tick snapshot. Tests cover unchanged and changed claims and rules. The existing one-time retry still excludes claim-loss errors.
Concurrent scheduler tick effects
packages/backend/src/services/alerts/AlertsService.ts
Housekeeping, suppressed-push digests, disabled-rule resolution, and alert-check ingestion run as collected effects. Ingestion runs even when no checks accumulated, with concurrency increased from four to six.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant SchedulerTick
  participant Housekeeping
  participant SuppressedPushDigests
  participant DisabledRuleResolution
  participant AlertCheckIngestion
  SchedulerTick->>Housekeeping: Start effect
  par Run effects concurrently
    SchedulerTick->>SuppressedPushDigests: Start effect
  and
    SchedulerTick->>DisabledRuleResolution: Start effect
  and
    SchedulerTick->>AlertCheckIngestion: Start effect
  end
  Housekeeping-->>SchedulerTick: Return exit
  SuppressedPushDigests-->>SchedulerTick: Return exit
  DisabledRuleResolution-->>SchedulerTick: Return exit
  AlertCheckIngestion-->>SchedulerTick: Return exit
  SchedulerTick->>SchedulerTick: Propagate collected failures
Loading

Suggested reviewers: jeremyfunk

Merge Risk: ⚪ Minimal · up to 4bb57

No additional actionable issue is established from the supplied evidence. The change is mergeable after normal checks.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title accurately summarizes both main changes: the fenced single-statement rule-state flush and concurrent tick-tail execution. It is specific and concise.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • 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

Comment @coderabbitai help to get the list of available commands.

…tick tail together

flushRuleStates went through writeUnderClaim: BEGIN, claim SELECT FOR UPDATE,
rule SELECT FOR UPDATE, the upsert, COMMIT. Five dependent round trips per
rule, 1.2-1.3 s each in slow fires, 53-68 s of lane time per tick.

The flush is now one statement. Two locking CTEs take FOR SHARE on the claim
row, then the rule row (the edit path's lock order), matching the current
claim and rule version; the upsert reads its rows from jsonb_to_recordset and
runs only when the fence matched. A concurrent re-claim or edit still waits on
the lock and then finds no fence row. A lost fence keeps the typed
AlertRuleClaimLostError; only that path reads the claim again to say why.

The steps after the rule pool (release unstarted claims, state housekeeping,
push digests, the disabled-rule sweep, alert_checks ingest) do not read each
other's writes, so they run concurrently, and each failure still fails the
tick. Ingest concurrency goes from 4 to 6, the Worker's outbound connection
limit.
@Makisuo
Makisuo force-pushed the perf/alerting-fenced-write branch from 0fd7694 to 4bb573b Compare October 19, 2026 11:45
@maple-review-bot

maple-review-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention
Fence semantics are pinned by three tests I traced against the edit path; the concurrency-6 ingest now queues the tail's own Postgres statements behind it.
quality 90/100 · 1 warning · tests partial · risk high · 2/2 new units observable

Fences each rule's state flush into one FOR SHARE-fenced Postgres statement and runs the tick tail's four independent steps together, raising the checks-ingest concurrency from 4 to 6. The fence is sound and tested; the ingest now competes for the invocation's six outbound connections.

  • upsertStatesUnderClaim writes rule state in one statement, fenced by FOR SHARE CTEs on claim and rule
  • State flush picks rows up through jsonb_to_recordset instead of chunked 11-parameter inserts
  • Tail runs housekeeping, digests, the disabled-rule sweep and checks ingest concurrently under Effect.exit
  • Checks ingest concurrency raised from 4 to 6

Findings

🟠 Warning · F1 · Checks ingest at 6 takes the outbound slots the tail's Postgres work needs

performance · packages/backend/src/services/alerts/AlertsService.ts:328

This tick's tail now runs these six Tinybird POSTs (AlertsService.ts:4648) beside housekeeping and sweepDisabledRules, whose Postgres sockets hold the same six simultaneous outbound slots per invocation (docs/persistence.md:38-41, lib/cache/src/outbound-slots.ts:5). With all six slots taken by POSTs, each tail statement queues for a slot before it dials, paying the 240 ms-plus floor this PR removes; the old order ran the ingest last, alone (4 POSTs, one serialized statement). Keep the constant at 4, or run ingestChecks after the three DB-bound steps.

	// Postgres sockets hold the same six outbound slots (docs/persistence.md), and the tail's
	// housekeeping and sweep statements now run beside these POSTs, so leave two slots free.
	const ALERT_CHECK_INGEST_CONCURRENCY = 4
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 4bb573be3e6514a30860b6d19d19f24825a6f8c8. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · performance · packages/backend/src/services/alerts/AlertsService.ts:328
Checks ingest at 6 takes the outbound slots the tail's Postgres work needs
This tick's tail now runs these six Tinybird POSTs (`AlertsService.ts:4648`) beside `housekeeping` and `sweepDisabledRules`, whose Postgres sockets hold the same six simultaneous outbound slots per invocation (`docs/persistence.md:38-41`, `lib/cache/src/outbound-slots.ts:5`). With all six slots taken by POSTs, each tail statement queues for a slot before it dials, paying the 240 ms-plus floor this PR removes; the old order ran the ingest last, alone (4 POSTs, one serialized statement). Keep the constant at 4, or run `ingestChecks` after the three DB-bound steps.
Replace those lines with:
	// Postgres sockets hold the same six outbound slots (docs/persistence.md), and the tail's
	// housekeeping and sweep statements now run beside these POSTs, so leave two slots free.
	const ALERT_CHECK_INGEST_CONCURRENCY = 4

Production impact

Production traffic of the changed files (last 7 days)
File Calls/day Busiest operations
packages/backend/src/services/alerts/AlertsService.ts 1.5k AlertsService.notifyIncidentDigests 828/day, 0.0% err
AlertsService.ingestAlertChecks 644/day, 0.0% err
What was checked
  • Fence locks are taken claim-then-rule, matching the edit path (AlertsService.ts:712-722 vs :645-668)
  • historyJson is Schema.Array(Schema.Number) (packages/db/src/tables/alerts.ts:13), so the jsonb param round-trips
  • Resolve claims never enter the digest list (alert-push-budget.ts:82-90); the four tail steps share no writes
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
Fenced rule-state upsert (one Postgres statement per flush) database yes Runs inside Effect.fn("AlertsService.flushRuleStates") and dbExecute's db span (AlertsService.ts:790)
Tick tail fan-out (housekeeping, digests, disabled-rule sweep, checks ingest) background work yes Each step carries its own AlertsService.* span (AlertsService.ts:4576-4649), all inside the tick's span

4bb573b · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@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.

Comment thread packages/backend/src/services/alerts/AlertsService.ts Outdated
…res steps

The tail now runs the checks ingest beside housekeeping and the
disabled-rule sweep. At 6 the POSTs could hold all six outbound slots and
queue those statements; 4 leaves them two.
@maple-review-bot

maple-review-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
Lock order matches the edit path and all three fence outcomes are pinned by tests; only the row-lock wait itself is unexercised locally.
quality 100/100 · no findings · tests covered · risk medium · 2/2 new units observable

Replaces the alerting state flush with one claim-fenced upsertStatesUnderClaim statement and runs the tick's four tail steps together, cutting Postgres round trips. The fence keeps its old outcomes and is pinned by tests; safe to merge.

  • upsertStatesUnderClaim fences claim and rule with FOR SHARE in one statement
  • flushRuleStates drops the five-statement writeUnderClaim transaction
  • The tail runs housekeeping, digests, the sweep and checks ingest together
  • ALERT_CHECK_INGEST_CONCURRENCY stays 4 beside the tail's Postgres work

Fixed since the last review

  • ✅ F1 · Checks ingest at 6 takes the outbound slots the tail's Postgres work needs

Production impact

Production traffic of the changed files (last 7 days)
File Calls/day Busiest operations
packages/backend/src/services/alerts/AlertsService.ts 1.5k AlertsService.notifyIncidentDigests 846/day, 0.0% err
AlertsService.ingestAlertChecks 655/day, 0.0% err
What was checked
  • Fence matches the edit path's claim-then-rule order (AlertRulesService.ts:269); no deadlock cycle
  • Three new tests pin flush-when-held, re-claimed and edited (AlertsService.test.ts:6511-6529)
  • Orm.sql binds values as parameters with explicit casts; msToSqlTimestamp is UTC ISO (platform/time.ts:20)
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
Fenced alert_rule_states upsert, one statement per rule db query yes Runs inside the AlertsService.flushRuleStates span (AlertsService.ts:782) with maple.alerting.state_rows
Tick tail: housekeeping, digests, disabled-rule sweep, checks ingest background steps yes Each keeps its own span (AlertsService.flushRuleStateHousekeeping:4577, notifyIncidentDigests:4591, ingestAlertChecks:4650)

e215cc5 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@Makisuo
Makisuo merged commit b1b93f2 into main Oct 20, 2026
26 checks passed
@Makisuo
Makisuo deleted the perf/alerting-fenced-write branch October 20, 2026 10:06
@maple-review-bot

Copy link
Copy Markdown

Maple: after this shipped

✅ Shipped clean. Nothing this change touches got worse in the hour after it deployed.

Deployed to alerting (pr-1595) at 2026-10-20 10:15 UTC as b7ce446, the first version after the merge (the merge commit itself never reported). Compared the 60 minutes before and after.

Operation Calls/h Errors p95
AlertsService.notifyIncidentDigests 186 → 237 0.0% → 0.0% 0 → 0 ms
AlertsService.ingestAlertChecks 117 → 175 0.0% → 0.0% 669 → 126 ms

Maple looks once, an hour after the first deploy that carries the merge.

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