-- 008_dlq.up.sql -- M8: Dead-Letter Queue for failed deliveries. -- -- Concept: -- - When a deliverd-* worker exhausts its retry budget -- (BA_DELIVERD_MAX_ATTEMPTS attempts), it inserts one -- row here AND marks the live `deliveries` row as -- status='dlq' for the audit trail. -- - The row is then visible to operators via admind's -- DLQ endpoints and the /dlq HTML page; the operator -- can replay (re-INSERT the original NATS envelope -- into the same deliveries.. subject) -- or discard (set discarded=true, hidden from the -- default list). -- - The `original_subject` column captures the NATS -- subject the row was originally delivered on, so -- replay doesn't need to re-derive it from the payload. -- -- Schema choices: -- - Same shape as `deliveries` plus: -- * original_subject TEXT — `deliveries.fcm.` etc. -- * last_error TEXT — copy of the last failure -- * discarded BOOLEAN — set by operator via -- POST /v1/dlq/{id}/discard. Default false. -- * discarded_at TIMESTAMPTZ NULL. -- - PK is (id, created_at) so this table is also a -- Timescale hypertable (see step 2 below). 1d chunks, -- 7d retention matches the live deliveries table. -- - The DLQ is forensic data: archiverd ships rows -- older than 7d to ClickHouse `ba_archive.deliveries_dlq_archive` -- with a 2-year TTL (vs 1y for live deliveries). See -- PROMPT.md M8 "Loose ends". -- -- Why a separate table, not just status='dlq' on deliveries? -- - Operator UI: the DLQ list is a focused view, not a -- full-text search across all deliveries. -- - Replay needs the original_subject and a clean payload -- snapshot. Keeping that in a dedicated table avoids -- re-deriving it from deliveries.payload (which is -- the full NATS envelope). -- - ClickHouse-side, the DLQ gets a longer TTL (2y vs 1y) -- because DLQ entries are forensic — you want them -- around longer when triaging a regression. -- -- Why 7d retention (same as deliveries)? -- - DLQ rows are infrequent (only terminal failures). -- 7d is enough for the operator to notice + replay in -- the normal ops loop. After 7d the data lives on in -- ClickHouse for 2y, which is the long-term home. CREATE TABLE IF NOT EXISTS deliveries_dlq ( id BIGSERIAL, alert_id TEXT NOT NULL, company_id TEXT NOT NULL, individual_id TEXT NOT NULL, channel TEXT NOT NULL, -- fcm | telegram | sms | email | slack | teams | webhook target TEXT NOT NULL, -- the fcm_token, telegram_chat_id, phone_e164, … original_subject TEXT NOT NULL, -- deliveries.fcm. (for replay) attempts INT NOT NULL, -- total attempts before giving up last_error TEXT NOT NULL, -- last failure reason payload JSONB, -- snapshot at enqueue time (forensic) discarded BOOLEAN NOT NULL DEFAULT false, discarded_at TIMESTAMPTZ, discarded_by TEXT, -- operator id / token (M8: 'admind-cli' until auth lands) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- time of DLQ insert PRIMARY KEY (id, created_at) ); -- The operator UI is filter-driven: most queries are -- "show me non-discarded DLQ rows for company X in the -- last 24h". These two indexes cover that. CREATE INDEX IF NOT EXISTS idx_dlq_company_created ON deliveries_dlq(company_id, created_at DESC) WHERE discarded = false; CREATE INDEX IF NOT EXISTS idx_dlq_alert ON deliveries_dlq(alert_id); -- Convert to a Timescale hypertable on created_at, with -- 1-day chunks and the same 7-day retention policy as -- the live `deliveries` table. The composite PK -- (id, created_at) is already in place above, satisfying -- Timescale's "partition column must be in UNIQUE/PK". SELECT create_hypertable( 'deliveries_dlq', 'created_at', chunk_time_interval => INTERVAL '1 day', migrate_data => true, if_not_exists => true ); -- 7-day hot retention in Postgres. The archiver ships -- rows older than that to ClickHouse before Timescale -- drops the chunk. M8 ships BA_ARCHIVERD_DLQ_OLDER_THAN_HOURS=168. SELECT add_retention_policy( 'deliveries_dlq', INTERVAL '7 days', if_not_exists => true );