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:
@@ -24,16 +24,23 @@ use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::group_dead_letter_repo::GroupDeadLetterRepo;
|
||||
use crate::group_docs_repo::GroupDocsRepo;
|
||||
use crate::group_files_repo::GroupFilesRepo;
|
||||
use crate::group_kv_repo::GroupKvRepo;
|
||||
use crate::group_repo::GroupRepository;
|
||||
|
||||
/// Default/max page size for the dead-letters listing (an operator view, not a
|
||||
/// bulk export).
|
||||
const DEAD_LETTERS_LIMIT_DEFAULT: i64 = 100;
|
||||
const DEAD_LETTERS_LIMIT_MAX: i64 = 500;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupBlobsState {
|
||||
pub kv: Arc<dyn GroupKvRepo>,
|
||||
pub docs: Arc<dyn GroupDocsRepo>,
|
||||
pub files: Arc<dyn GroupFilesRepo>,
|
||||
pub dead_letters: Arc<dyn GroupDeadLetterRepo>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
@@ -46,6 +53,7 @@ pub fn group_blobs_router(state: GroupBlobsState) -> Router {
|
||||
.route("/groups/{id}/docs/{collection}/{doc_id}", get(get_doc))
|
||||
.route("/groups/{id}/files", get(list_files))
|
||||
.route("/groups/{id}/files/{collection}/{file_id}", get(get_file))
|
||||
.route("/groups/{id}/dead-letters", get(list_dead_letters))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -64,6 +72,44 @@ struct ListKeysResponse {
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeadLettersQuery {
|
||||
#[serde(default)]
|
||||
pub unresolved: bool,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// One group dead-letter, as returned by the operator listing.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeadLetterDto {
|
||||
id: Uuid,
|
||||
collection: String,
|
||||
source: String,
|
||||
op: String,
|
||||
attempt_count: u32,
|
||||
last_error: String,
|
||||
created_at: String,
|
||||
resolved_at: Option<String>,
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<crate::group_dead_letter_repo::GroupDeadLetterRow> for DeadLetterDto {
|
||||
fn from(r: crate::group_dead_letter_repo::GroupDeadLetterRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into_inner(),
|
||||
collection: r.collection,
|
||||
source: r.source,
|
||||
op: r.op,
|
||||
attempt_count: r.attempt_count,
|
||||
last_error: r.last_error,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
resolved_at: r.resolved_at.map(|t| t.to_rfc3339()),
|
||||
payload: r.payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_kv(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -251,6 +297,34 @@ async fn get_file(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// §11.6 D3: list a group's shared-queue dead-letters (newest-first). Guarded by
|
||||
/// `GroupKvRead` (the same read tier as the shared collections above; no new
|
||||
/// capability). `?unresolved=true` filters to still-open rows; `?limit=` caps
|
||||
/// the page (default 100, max 500).
|
||||
async fn list_dead_letters(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(ident): Path<String>,
|
||||
Query(q): Query<DeadLettersQuery>,
|
||||
) -> Result<Json<Vec<DeadLetterDto>>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupKvRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let limit = q.limit.map_or(DEAD_LETTERS_LIMIT_DEFAULT, |n| {
|
||||
i64::from(n).clamp(1, DEAD_LETTERS_LIMIT_MAX)
|
||||
});
|
||||
let rows = s
|
||||
.dead_letters
|
||||
.list_for_group(group_id, q.unresolved, limit)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
|
||||
Ok(Json(rows.into_iter().map(DeadLetterDto::from).collect()))
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
|
||||
Reference in New Issue
Block a user