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:
43
crates/manager-core/migrations/0068_group_dead_letters.sql
Normal file
43
crates/manager-core/migrations/0068_group_dead_letters.sql
Normal 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);
|
||||
Reference in New Issue
Block a user