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:
@@ -9,9 +9,10 @@
|
||||
//! all claiming this one store with `FOR UPDATE SKIP LOCKED`, so each message is
|
||||
//! delivered at-most-once across the subtree).
|
||||
//!
|
||||
//! Deferred (documented): shared-queue dead-lettering — an exhausted message is
|
||||
//! nacked with backoff by the dispatcher (never silently dropped), but there is
|
||||
//! no group dead-letter store yet.
|
||||
//! §11.6 D3 (dead-lettering): an exhausted shared-queue message moves to the
|
||||
//! group dead-letter store (`group_dead_letters`, 0068) via [`GroupQueueRepo::dead_letter`]
|
||||
//! instead of being dropped — operator-visible at GET .../groups/{id}/dead-letters.
|
||||
//! Deferred: fan-out to a *shared* dead-letter TRIGGER (needs a new trigger kind).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -83,13 +84,24 @@ pub trait GroupQueueRepo: Send + Sync {
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Drop a message that exhausted its attempts (no group dead-letter store
|
||||
/// yet — see the module deferral note). Deletes iff `claim_token` matches.
|
||||
async fn drop_exhausted(
|
||||
/// §11.6 D3: a message exhausted its attempts — move it to the group dead-
|
||||
/// letter store (`group_dead_letters`) and delete it from the live queue, in
|
||||
/// one transaction (mirrors `queue_repo::dead_letter`). Filtered by
|
||||
/// `claim_token` so a lost lease can't dead-letter a re-claimed message.
|
||||
/// Returns the new dead-letter id.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
trigger_id: Option<picloud_shared::TriggerId>,
|
||||
script_id: Option<picloud_shared::ScriptId>,
|
||||
attempt: u32,
|
||||
first_attempt_at: DateTime<Utc>,
|
||||
last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, GroupQueueRepoError>;
|
||||
|
||||
/// Periodic safety net: clear claims older than a shared-queue consumer's
|
||||
/// `visibility_timeout_secs`. Returns the number of rows reclaimed.
|
||||
@@ -209,18 +221,69 @@ impl GroupQueueRepo for PostgresGroupQueueRepo {
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn drop_exhausted(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
trigger_id: Option<picloud_shared::TriggerId>,
|
||||
script_id: Option<picloud_shared::ScriptId>,
|
||||
attempt: u32,
|
||||
first_attempt_at: DateTime<Utc>,
|
||||
last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, GroupQueueRepoError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Pull the row inside the tx for its payload; filtered by claim_token so
|
||||
// a lost lease can't dead-letter a re-claimed message.
|
||||
let row: DeadLetterSourceRow = sqlx::query_as(
|
||||
"SELECT id, payload, enqueued_at FROM group_queue_messages \
|
||||
WHERE id = $1 AND claim_token = $2",
|
||||
)
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"source": "queue",
|
||||
"queue_name": collection,
|
||||
"message": row.payload,
|
||||
"enqueued_at": row.enqueued_at,
|
||||
"attempt": attempt,
|
||||
"message_id": row.id.to_string(),
|
||||
});
|
||||
|
||||
let dl_id = picloud_shared::DeadLetterId::new();
|
||||
sqlx::query(
|
||||
"INSERT INTO group_dead_letters ( \
|
||||
id, group_id, collection, original_event_id, source, op, \
|
||||
trigger_id, script_id, payload, attempt_count, \
|
||||
first_attempt_at, last_attempt_at, last_error \
|
||||
) VALUES ($1, $2, $3, $4, 'queue', 'receive', $5, $6, $7, $8, $9, NOW(), $10)",
|
||||
)
|
||||
.bind(dl_id.into_inner())
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(row.id)
|
||||
.bind(trigger_id.map(picloud_shared::TriggerId::into_inner))
|
||||
.bind(script_id.map(picloud_shared::ScriptId::into_inner))
|
||||
.bind(&payload)
|
||||
.bind(i32::try_from(attempt).unwrap_or(0))
|
||||
.bind(first_attempt_at)
|
||||
.bind(last_error)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(dl_id)
|
||||
}
|
||||
|
||||
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
|
||||
@@ -292,3 +355,11 @@ struct ClaimedRow {
|
||||
attempt: i32,
|
||||
max_attempts: i32,
|
||||
}
|
||||
|
||||
/// Minimal projection of a to-be-dead-lettered message: its payload + timing.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeadLetterSourceRow {
|
||||
id: Uuid,
|
||||
payload: serde_json::Value,
|
||||
enqueued_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user