008_dlq.up.sql 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. -- 008_dlq.up.sql
  2. -- M8: Dead-Letter Queue for failed deliveries.
  3. --
  4. -- Concept:
  5. -- - When a deliverd-* worker exhausts its retry budget
  6. -- (BA_DELIVERD_MAX_ATTEMPTS attempts), it inserts one
  7. -- row here AND marks the live `deliveries` row as
  8. -- status='dlq' for the audit trail.
  9. -- - The row is then visible to operators via admind's
  10. -- DLQ endpoints and the /dlq HTML page; the operator
  11. -- can replay (re-INSERT the original NATS envelope
  12. -- into the same deliveries.<channel>.<company> subject)
  13. -- or discard (set discarded=true, hidden from the
  14. -- default list).
  15. -- - The `original_subject` column captures the NATS
  16. -- subject the row was originally delivered on, so
  17. -- replay doesn't need to re-derive it from the payload.
  18. --
  19. -- Schema choices:
  20. -- - Same shape as `deliveries` plus:
  21. -- * original_subject TEXT — `deliveries.fcm.<co>` etc.
  22. -- * last_error TEXT — copy of the last failure
  23. -- * discarded BOOLEAN — set by operator via
  24. -- POST /v1/dlq/{id}/discard. Default false.
  25. -- * discarded_at TIMESTAMPTZ NULL.
  26. -- - PK is (id, created_at) so this table is also a
  27. -- Timescale hypertable (see step 2 below). 1d chunks,
  28. -- 7d retention matches the live deliveries table.
  29. -- - The DLQ is forensic data: archiverd ships rows
  30. -- older than 7d to ClickHouse `ba_archive.deliveries_dlq_archive`
  31. -- with a 2-year TTL (vs 1y for live deliveries). See
  32. -- PROMPT.md M8 "Loose ends".
  33. --
  34. -- Why a separate table, not just status='dlq' on deliveries?
  35. -- - Operator UI: the DLQ list is a focused view, not a
  36. -- full-text search across all deliveries.
  37. -- - Replay needs the original_subject and a clean payload
  38. -- snapshot. Keeping that in a dedicated table avoids
  39. -- re-deriving it from deliveries.payload (which is
  40. -- the full NATS envelope).
  41. -- - ClickHouse-side, the DLQ gets a longer TTL (2y vs 1y)
  42. -- because DLQ entries are forensic — you want them
  43. -- around longer when triaging a regression.
  44. --
  45. -- Why 7d retention (same as deliveries)?
  46. -- - DLQ rows are infrequent (only terminal failures).
  47. -- 7d is enough for the operator to notice + replay in
  48. -- the normal ops loop. After 7d the data lives on in
  49. -- ClickHouse for 2y, which is the long-term home.
  50. CREATE TABLE IF NOT EXISTS deliveries_dlq (
  51. id BIGSERIAL,
  52. alert_id TEXT NOT NULL,
  53. company_id TEXT NOT NULL,
  54. individual_id TEXT NOT NULL,
  55. channel TEXT NOT NULL, -- fcm | telegram | sms | email | slack | teams | webhook
  56. target TEXT NOT NULL, -- the fcm_token, telegram_chat_id, phone_e164, …
  57. original_subject TEXT NOT NULL, -- deliveries.fcm.<co> (for replay)
  58. attempts INT NOT NULL, -- total attempts before giving up
  59. last_error TEXT NOT NULL, -- last failure reason
  60. payload JSONB, -- snapshot at enqueue time (forensic)
  61. discarded BOOLEAN NOT NULL DEFAULT false,
  62. discarded_at TIMESTAMPTZ,
  63. discarded_by TEXT, -- operator id / token (M8: 'admind-cli' until auth lands)
  64. created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- time of DLQ insert
  65. PRIMARY KEY (id, created_at)
  66. );
  67. -- The operator UI is filter-driven: most queries are
  68. -- "show me non-discarded DLQ rows for company X in the
  69. -- last 24h". These two indexes cover that.
  70. CREATE INDEX IF NOT EXISTS idx_dlq_company_created
  71. ON deliveries_dlq(company_id, created_at DESC)
  72. WHERE discarded = false;
  73. CREATE INDEX IF NOT EXISTS idx_dlq_alert
  74. ON deliveries_dlq(alert_id);
  75. -- Convert to a Timescale hypertable on created_at, with
  76. -- 1-day chunks and the same 7-day retention policy as
  77. -- the live `deliveries` table. The composite PK
  78. -- (id, created_at) is already in place above, satisfying
  79. -- Timescale's "partition column must be in UNIQUE/PK".
  80. SELECT create_hypertable(
  81. 'deliveries_dlq',
  82. 'created_at',
  83. chunk_time_interval => INTERVAL '1 day',
  84. migrate_data => true,
  85. if_not_exists => true
  86. );
  87. -- 7-day hot retention in Postgres. The archiver ships
  88. -- rows older than that to ClickHouse before Timescale
  89. -- drops the chunk. M8 ships BA_ARCHIVERD_DLQ_OLDER_THAN_HOURS=168.
  90. SELECT add_retention_policy(
  91. 'deliveries_dlq',
  92. INTERVAL '7 days',
  93. if_not_exists => true
  94. );