Alert Staging Table + Batched Triage Architecture
Issue: #3895
Status: Plan — awaiting /review-plan before implementation
Author: worker agent (bms4-cw-1), 2026-07-11
Labels: plan, worker-queue, monitoring
Planning document. Introduces a staging table (
dev_r_alert_events) as the single ingestion point for every alert source, a 2-minute correlation scheduler, and a batchedalert-triage-batchworker that files one correlated issue per incident instead of N independent issues. No code ships until plan review passes.
1. Problem statement
Every alert path currently calls gh issue create (or the GitHub API) immediately and
independently, with no staging point between “alert fired” and “issue exists”. Two failure
modes follow directly from that:
1.1 Evidence from the live queue (2026-07-12)
-
7 separate
EndpointDownissues (#3865–#3871) for*.w4.pinbox24.comsubdomains, all created inside a 5-second window. This is one incident (the w4 edge / upstream went down, taking every probed subdomain with it) filed as 7 unrelated issues with no cross-reference and no shared root-cause hypothesis. -
5 orphaned duplicate issues (#3854–#3858) that never entered the
Triagemilestone (milestone=null), created because two independent code paths both issuegh issue createfor the same condition:- the Alertmanager → n8n webhook path
(
monitoring/alertmanager/alertmanager.yml.tpl→ n8nalertmanager-to-incidents-v3), and - the
nightly-infra-checkcron (.claude/commands/nightly-infra-check.md,New-InfraIssue→gh issue create ... --milestone "Triage").
When both fire for the same underlying condition in the same window, they race and neither sees the other’s issue.
- the Alertmanager → n8n webhook path
(
1.2 Root cause
There is no staging point. Each writer is stateless with respect to the others, so deduplication and correlation are structurally impossible: you cannot dedup against issues you cannot see, and you cannot correlate alerts that were never collected in one place.
1.3 What already exists (reuse, do not rebuild)
| Asset | Relevance |
|---|---|
dev_r_worker_queue (migration 034_dev_r_worker_queue.sql, extended 036) | Job queue. job_type CHECK constraint already gates types; we extend it. |
nc-alert-batch pseudo-job-type (scripts/queue-dispatcher-loop.py:87, host-affined to vps-i1 via _HARD_PREF_JOB_TYPES) | Precedent for exactly this pattern — a batch triage job hard-affined to vps-i1 with an issue_number=0 sentinel. The new alert-triage-batch type mirrors it and should share the affinity + sweep logic (cancel_unroutable_nc_alert_batch, dispatcher line ~1587). |
nc-alert-orchestrator skill (.claude/commands/nc-alert-orchestrator.md) | Existing investigation-only orchestrator for p24-infra-nc-alert issues. The batch worker’s output feeds this, not replaces it. |
p24-infra-nc-alert label + <!-- affected-service: … --> / <!-- alert-source: … --> HTML markers (nightly-infra-check.md) | The affected-service slug is already computed and embedded — reuse it as the correlation key. |
meta-dispatcher enqueue API (infra-src/meta-dispatcher/src/index.ts singleDispatch, shouldSkipBatchJobDispatch in supabase.ts) | Batch-aware dedup guard on dispatch already exists for nc-alert-batch; extend it to alert-triage-batch. |
EndpointDown rule (monitoring/prometheus/rules/synthetic.yml, severity=critical, labels instance, alertname) | The alert that produced the 7-issue burst. Its labels define the fingerprint fields. |
Discord error webhook P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (pattern in queue-dispatcher-loop.py:139) | Reuse for scheduler/worker error notification. |
2. Target architecture
Alertmanager ─┐
n8n webhook ──┼──► dev_r_alert_events ──► scheduler (2 min) ──► dev_r_worker_queue
nightly-check ┘ (staging + dedup) group by (job_type=
(all WRITE here, by fingerprint) alertname/service alert-triage-batch)
never gh issue in 5-min window │
create directly) ▼
alert-triage-batch worker
(ONE issue per group,
all correlated alerts
listed + root-cause
hypothesis)
critical & alone ─────────────────────────────────────────────► instant dispatch
(bypass batching)
Three moving parts: (A) the staging table + a thin write shim every source uses, (B) a correlation scheduler, (C) the batch worker. Plus the critical-path bypass.
3. Design
3.1 Schema — dev_r_alert_events
New migration monitoring/supabase/migrations/<ts>_alert_staging.sql (timestamp prefix via
date +%Y%m%d_%H%M, per the current naming convention — sequential 0NN numbers are frozen at
049).
CREATE TABLE IF NOT EXISTS public.dev_r_alert_events (
id BIGSERIAL PRIMARY KEY,
fingerprint TEXT NOT NULL, -- dedup key (see 3.3)
alertname TEXT NOT NULL, -- e.g. 'EndpointDown'
affected_service TEXT, -- correlation key (see 3.4)
instance TEXT, -- e.g. 'https://api.w4.pinbox24.com'
severity TEXT NOT NULL DEFAULT 'warning'
CHECK (severity IN ('critical','warning','info')),
source TEXT NOT NULL
CHECK (source IN ('alertmanager','n8n','nightly-infra-check')),
status TEXT NOT NULL DEFAULT 'staged'
CHECK (status IN ('staged','batched','bypassed','resolved')),
payload JSONB, -- full alert labels + annotations
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
dedup_count INT NOT NULL DEFAULT 1, -- times this fingerprint re-fired
batched_at TIMESTAMPTZ, -- when the scheduler grouped it
batch_queue_id BIGINT REFERENCES public.dev_r_worker_queue(id),
gh_issue_number INT -- filled by the worker after issue creation
);
-- Dedup: one live row per fingerprint while it is still open.
CREATE UNIQUE INDEX IF NOT EXISTS dev_r_alert_events_open_fingerprint_idx
ON public.dev_r_alert_events (fingerprint)
WHERE status IN ('staged','batched');
-- Scheduler scan: unprocessed events by correlation key + time.
CREATE INDEX IF NOT EXISTS dev_r_alert_events_scan_idx
ON public.dev_r_alert_events (status, alertname, affected_service, first_seen_at)
WHERE status = 'staged';
ALTER TABLE public.dev_r_alert_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_all" ON public.dev_r_alert_events
FOR ALL TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "grafana_readonly_select" ON public.dev_r_alert_events
FOR SELECT TO grafana_readonly USING (true);
GRANT SELECT ON public.dev_r_alert_events TO grafana_readonly;Same migration extends the queue’s job-type gate:
ALTER TABLE public.dev_r_worker_queue DROP CONSTRAINT IF EXISTS dev_r_worker_queue_job_type_check;
ALTER TABLE public.dev_r_worker_queue ADD CONSTRAINT dev_r_worker_queue_job_type_check
CHECK (job_type IN ('dev-issue','infra-alert','infra-task','nc-alert-batch',
'subagent','review-pr','review-plan','alert-triage-batch'));3.2 Ingestion — every source writes to the table, none calls gh issue create
An idempotent upsert helper (scripts/alert-ingest.py, plus a REST-only shell twin for n8n)
is the single write path. On fingerprint conflict it increments dedup_count and bumps
last_seen_at instead of inserting — this alone kills the #3854–#3858 orphan-duplicate class,
because the second racing writer merges into the first row rather than creating a parallel issue.
# scripts/alert-ingest.py (sync; error path → Discord + GH issue per §Error Notification Standard)
def ingest(alert: dict) -> None:
fp = fingerprint(alert) # see 3.3
supabase PATCH dev_r_alert_events
?fingerprint=eq.{fp}&status=in.(staged,batched) # merge into open row
{ last_seen_at: now, dedup_count: dedup_count + 1, payload: merged }
if 0 rows updated:
supabase POST dev_r_alert_events { fingerprint: fp, ...fields, status: 'staged' }Cutover per source:
| Source | Today | After |
|---|---|---|
| Alertmanager | alertmanager.yml.tpl → n8n alertmanager-to-incidents-v3 → GitHub API | n8n node calls POST /alert-ingest (or the meta-dispatcher gains an /alert-ingest route) — no GitHub node |
| n8n resource/worker webhooks | gh issue create inside the workflow | same ingest call |
nightly-infra-check | New-InfraIssue → gh issue create --milestone Triage | New-InfraEvent → POST /alert-ingest (drop the direct create) |
New-InfraIssue already computes the affected-service slug and alert-source marker; those
map 1:1 onto affected_service and source columns, so the cutover is mechanical.
3.3 Deduplication — the fingerprint
fingerprint = sha1( alertname + '|' + (instance || affected_service) + '|' + severity )
Mirrors Alertmanager’s own fingerprint semantics (label-set identity), so a flapping probe that
re-fires every scrape collapses onto one staged row (dedup_count climbs) rather than N rows.
The partial-unique index on (fingerprint) WHERE status IN ('staged','batched') enforces this at
the DB level — concurrent writers serialise on the index and the loser’s INSERT becomes a merge.
Once an event’s issue is closed/resolved a fresh occurrence is a new incident (the old row is
resolved, so the unique index no longer blocks it).
3.4 Scheduler — correlate every 2 minutes
Location decision (open question 3): a pg_cron RPC, not a new bms-4 cron script.
Rationale: the grouping is a single SQL GROUP BY over one table plus N queue inserts — it has no
filesystem, SSH, or Docker dependency, so a bms-4 python loop would add a process to babysit for no
benefit. pg_cron keeps it co-located with the data, survives a bms-4 restart, and there is
precedent (049_server_heartbeat.sql uses cron.schedule for a 1-minute status refresh). The
dispatcher (queue-dispatcher-loop.py) still owns dispatch; the scheduler only owns grouping.
CREATE OR REPLACE FUNCTION public.batch_stage_alerts() RETURNS void LANGUAGE plpgsql AS $$
DECLARE grp RECORD; new_qid BIGINT;
BEGIN
FOR grp IN
SELECT alertname, affected_service,
array_agg(id) AS event_ids,
bool_or(severity = 'critical') AS has_critical,
count(*) AS n
FROM public.dev_r_alert_events
WHERE status = 'staged'
AND first_seen_at > NOW() - INTERVAL '5 minutes' -- correlation window (open question 1)
GROUP BY alertname, affected_service
LOOP
-- Critical-path bypass (3.6): a lone critical never waits for a batch.
IF grp.has_critical AND grp.n = 1 THEN
-- leave it 'staged'; the bypass path (3.6) dispatches it immediately.
CONTINUE;
END IF;
INSERT INTO public.dev_r_worker_queue
(issue_number, repo, job_type, server_preference, status, metadata)
VALUES
(0, 'radieu/p24-infra', 'alert-triage-batch', 'vps-i1', 'queued',
jsonb_build_object('event_ids', grp.event_ids,
'alertname', grp.alertname,
'affected_service', grp.affected_service))
RETURNING id INTO new_qid;
UPDATE public.dev_r_alert_events
SET status = 'batched', batched_at = NOW(), batch_queue_id = new_qid
WHERE id = ANY(grp.event_ids);
END LOOP;
END; $$;
SELECT cron.schedule('alert-batch-stage', '*/2 * * * *', $$SELECT public.batch_stage_alerts()$$);Grouping key is (alertname, affected_service). For the #3865–#3871 burst every probe shares
alertname='EndpointDown' and (via the shared edge) affected_service='w4-pinbox24', so all 7
collapse into one alert-triage-batch row carrying 7 event_ids.
server_preference='vps-i1' is required — alert-triage-batch joins _HARD_PREF_JOB_TYPES
alongside nc-alert-batch/infra-task so only vps-i1 (the monitoring node) claims it; without
it a keyless host could claim and strand the row (cf. #3746). The dispatcher’s existing
unroutable-sweep (cancel_unroutable_nc_alert_batch) is generalised to cover the new type.
3.5 Worker — alert-triage-batch
A new skill (.claude/commands/alert-triage-batch.md, Haiku via frontmatter — cheap, since it
summarises pre-collected data rather than searching the codebase). spawn-worker.sh passes the
queue row; the worker:
- Loads
metadata.event_idsand reads thosedev_r_alert_eventsrows (all correlated alerts). - Builds one issue:
- Title:
[alert-batch] {alertname} — {affected_service} ({n} endpoints). - Body: root-cause hypothesis + action plan + a table of every correlated alert
(
instance,severity,dedup_count,first_seen_at), plus the standard<!-- affected-service: … -->/<!-- alert-source: batch -->markers sonc-alert-orchestratorstill recognises it. - Labels:
p24-infra-nc-alert, milestoneTriage(unchanged downstream contract).
- Title:
- Writes
gh_issue_numberback onto every event row and flips theirstatus→resolvedonce the issue exists (so a re-fire opens a fresh incident). - Marks the queue row
done(standard Step 11.5 path).
Net result for the evidence case: 1 issue listing 7 endpoints instead of #3865–#3871.
3.6 Critical-path bypass
A lone severity=critical alert (grp.has_critical AND grp.n = 1 in 3.4) must not wait up to
2 min for the next scheduler tick. Two viable triggers — recommend (a):
- (a) DB trigger on INSERT (recommended): an
AFTER INSERTtrigger ondev_r_alert_eventsthat, whenseverity='critical'and no other open event shares the fingerprint, inserts thealert-triage-batchqueue row immediately withmetadata.bypass=trueand marks the eventbypassed. Latency = one dispatcher poll (~seconds), independent of the 2-min scheduler. - (b) let the ingest helper POST straight to meta-dispatcher for
critical— simpler but splits the dispatch logic across two code paths, which is the very coupling this design removes.
Batching a burst of criticals (e.g. the 7-endpoint case, which are all severity=critical) is
still correct — those are n>1, so they group and file one issue. The bypass targets the
genuinely isolated critical (single service hard-down) where waiting 2 min is unacceptable.
4. Files changed
| File | Change |
|---|---|
monitoring/supabase/migrations/<ts>_alert_staging.sql | New: dev_r_alert_events table + indexes + RLS; extend dev_r_worker_queue.job_type CHECK; batch_stage_alerts() fn + pg_cron schedule; critical bypass trigger |
scripts/alert-ingest.py | New: idempotent upsert helper (fingerprint dedup) + Discord/GH error path |
infra-src/meta-dispatcher/src/index.ts | Add /alert-ingest route (or document the direct Supabase PATCH n8n uses); extend shouldSkipBatchJobDispatch to alert-triage-batch |
.claude/commands/alert-triage-batch.md | New worker skill (Haiku) — one issue per batch |
scripts/queue-dispatcher-loop.py | Add alert-triage-batch to _HARD_PREF_JOB_TYPES; generalise the unroutable-sweep to both batch types |
monitoring/alertmanager/alertmanager.yml.tpl | Point receivers at ingest instead of the GitHub-issue n8n branch |
n8n-workflows/alertmanager-to-incidents-v3_*.json | Replace GitHub-issue node with an ingest HTTP node |
.claude/commands/nightly-infra-check.md | New-InfraIssue → New-InfraEvent (POST to ingest; stop calling gh issue create) |
monitoring/grafana/provisioning/dashboards/*.json | New panel: staged vs batched vs bypassed event counts, dedup_count top-N |
docs/alert-staging-operations.md | New ops doc (required for new table/service — §Compliance) |
Supabase dev_r_services | New row for dev_r_alert_events staging service (compliance_workbook='yes') |
5. Open questions — recommendations
| # | Question | Recommendation |
|---|---|---|
| 1 | 5-minute correlation window | Ship at 5 min; expose as a batch_stage_alerts constant and add a Grafana panel of “events per batch” so it can be tuned against real bursts. The #3865–#3871 burst was a 5-second window, so 5 min is generous headroom without over-merging unrelated incidents. |
| 2 | vps-i1 hard affinity vs bms-4 failover | Start hard-affined to vps-i1 (reuse nc-alert-batch affinity — zero new dispatch code). bms-4 failover is a follow-up: it needs the batch worker to be host-agnostic (no vps-i1-local file deps), which it already is, but adding a second affined host is a separate, testable change. Do not couple it into this migration. |
| 3 | Scheduler location (pg_cron vs bms-4 cron) | pg_cron RPC (see 3.4 rationale): pure SQL grouping, no host deps, survives restarts, precedent in 049_server_heartbeat.sql. |
6. Out of scope
- Migrating non-alert issue creation (dispatch callbacks,
/new-issue) — this design only touches alert→issue paths. - Rewriting
nc-alert-batch— the existing Prometheus-alert batch type stays;alert-triage-batchis the source-agnostic staging-backed successor and the two coexist untilnc-alert-batchis retired in a later issue. - Auto-resolution of events when the underlying alert clears (Alertmanager
resolvedwebhook → flipstatus='resolved') — valuable, but a follow-up once staging is proven. - bms-4 failover for the batch worker (open question 2 — deferred).
7. Risks
| Risk | Mitigation |
|---|---|
| Ingest endpoint down → alerts silently lost | Ingest failure raises the standard Discord + GH issue error path; Alertmanager retries its webhook; add a synthetic.yml blackbox probe on the ingest route |
Over-merging distinct incidents sharing alertname+service | Grouping key includes affected_service; window is only 5 min; worker lists every instance so a human sees the spread even inside one issue |
| pg_cron job wedged / not scheduled | Grafana alert on staged events older than 10 min with no batched_at; nc-alert if the count climbs |
| A genuinely-critical lone alert waits for the 2-min tick | Critical-path bypass (3.6) via INSERT trigger — dispatch in seconds, not minutes |
Cutover leaves a source still calling gh issue create | Migration + cutover land together; a grep-guard CI check asserts no gh issue create remains in the three alert paths |
| Unique fingerprint index blocks a legitimate re-fire after resolution | Partial index only covers status IN ('staged','batched'); a resolved row never blocks a new incident |
alert-triage-batch row unroutable if vps-i1 down | Generalised unroutable-sweep cancels stale rows (as nc-alert-batch does today); follow-up bms-4 failover removes the single point |
8. Test plan
- Migration applies cleanly (
supabase db push);dev_r_alert_events+ indexes + CHECK exist. - Ingest dedup: POST the same alert 5× → 1 row,
dedup_count=5. - Race guard: two concurrent ingests of one fingerprint → 1 row (index serialises).
- Correlation: stage 7
EndpointDown/w4-pinbox24events → scheduler creates 1alert-triage-batchqueue row with 7event_ids; all 7 flip tobatched. - Worker: consume that row → 1 GH issue listing all 7 endpoints,
p24-infra-nc-alertlabel,Triagemilestone; events flip toresolvedwithgh_issue_numberset. - Critical bypass: insert a lone
severity=criticalevent → queue row appears within one dispatcher poll (no 2-min wait), event markedbypassed. - No-regression:
nc-alert-orchestratorstill recognises the batched issue via markers. - Cutover guard: grep asserts
gh issue createremoved from Alertmanager/n8n/nightly paths. - Re-fire after resolution: resolve an event, re-ingest same fingerprint → new
stagedrow.
9. Rollout
- Land the migration (table + job-type + scheduler + bypass) — inert until sources write to it.
- Ship
alert-ingest.py+ meta-dispatcher route; deploy thealert-triage-batchskill. - Cut over one source first —
nightly-infra-check(lowest volume, easiest to observe) — and confirm one-issue-per-batch on the next nightly run. - Cut over the Alertmanager/n8n path; run the grep-guard CI check.
- Watch the Grafana staging panel for a week; tune the 5-min window if bursts under-/over-merge.
- File the follow-ups: Alertmanager
resolvedauto-resolution, bms-4 failover,nc-alert-batchretirement.
Auto-generated by worker agent (bms4-cw-1) for issue #3895. Review with /review-plan 3895.