feat(queue): dead-letter store for group shared queues (§11.6 D3 / Track A M2)

An exhausted SHARED durable-queue message was `drop_exhausted()`-ed with a
warning — silent data loss. Per-app queues persist to `dead_letters` (0010);
add the symmetric group store so an exhausted shared-queue message is preserved
and operator-visible.

- Migration 0068: `group_dead_letters`, keyed by (group_id, collection),
  CASCADE on the group (an app delete leaves the data — it belongs to the
  group, not the consuming app).
- `GroupQueueRepo::dead_letter` (replaces `drop_exhausted`): one tx that INSERTs
  the dead-letter + DELETEs the live message, filtered by claim_token so a lost
  lease can't dead-letter a re-claimed message (mirrors queue_repo::dead_letter).
- Dispatcher `q_terminal` shared arm now dead-letters instead of dropping. It
  returns None (not the dl id) so the per-app `fan_out_dead_letter` is SKIPPED —
  firing the consuming app's per-app handlers on a shared message (competing
  consumers → nondeterministic app) would be wrong. Fan-out to a *shared*
  dead_letter trigger is deferred (needs a new trigger kind).
- Read side: `GroupDeadLetterRepo::list_for_group` backs a new read-only
  operator endpoint GET /api/v1/admin/groups/{id}/dead-letters (GroupKvRead,
  mirrors the M4 group-blobs surface). `pic dead-letters ls --group` deferred
  (optional; the HTTP endpoint is the operator surface).

Pinned by group_queue::dead_letter_moves_an_exhausted_message_to_the_group_store
(store move + claim-token-mismatch guard + operator read). Schema golden
reblessed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:41:04 +02:00
parent c06a9e801e
commit fd4336e883
9 changed files with 536 additions and 28 deletions

View File

@@ -0,0 +1,43 @@
-- §11.6 D3 (dead-letter store for group SHARED durable queues).
--
-- Previously an exhausted shared-queue message was `drop_exhausted()`-ed with a
-- warning — silent data loss. Per-app queues persist to `dead_letters` (0010);
-- this is the symmetric group store, keyed by `group_id` (the shared-queue owner)
-- + `collection` (a group has many shared queues) instead of `app_id`.
--
-- CASCADE on the group mirrors `group_queue_messages` (0065): deleting the group
-- drops its dead-letters; an app delete leaves them (the data belongs to the
-- group, not the consuming app). No `app_id` — a shared-queue message is not
-- owned by whichever descendant happened to consume it.
--
-- Fan-out to a *shared* dead-letter TRIGGER is deferred (would need a new shared
-- dead-letter trigger kind); M2 stops the data loss + makes exhausted messages
-- operator-visible (GET /api/v1/admin/groups/{id}/dead-letters).
CREATE TABLE group_dead_letters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- The shared-queue collection name the exhausted message came from.
collection TEXT NOT NULL,
-- The group_queue_messages.id row that exhausted retries (already deleted).
original_event_id UUID NOT NULL,
source TEXT NOT NULL,
op TEXT NOT NULL,
-- The materialized consumer copy's trigger/script (nullable, mirrors 0010).
trigger_id UUID,
script_id UUID,
payload JSONB NOT NULL,
attempt_count INT NOT NULL,
first_attempt_at TIMESTAMPTZ NOT NULL,
last_attempt_at TIMESTAMPTZ NOT NULL,
last_error TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolution TEXT
CHECK (resolution IN
('replayed', 'ignored', 'handled_by_script', 'handler_failed'))
);
-- Operator listing: newest-first per group (GET .../groups/{id}/dead-letters).
CREATE INDEX idx_group_dead_letters_group
ON group_dead_letters (group_id, created_at DESC);