//! `DeadLetterRepo` — CRUD over the `dead_letters` table. //! //! The dispatcher writes new rows when an async trigger exhausts its //! retry policy. Admin endpoints (commit 8) read for the dashboard //! list view and write to mark rows resolved or replay them. The GC //! sweeper (commit 10) deletes expired rows by `created_at`. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::{AppId, DeadLetterId, ScriptId, TriggerId}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum DeadLetterRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("dead-letter row not found: {0}")] NotFound(DeadLetterId), #[error("invalid resolution {0:?}")] InvalidResolution(String), } #[derive(Debug, Clone)] pub struct NewDeadLetter { pub app_id: AppId, /// `outbox.id` that exhausted retries. Outbox row deleted at the /// same time. pub original_event_id: Uuid, pub source: String, pub op: String, pub trigger_id: Option, pub script_id: Option, pub payload: serde_json::Value, pub attempt_count: u32, pub first_attempt_at: DateTime, pub last_attempt_at: DateTime, pub last_error: String, } #[derive(Debug, Clone)] pub struct DeadLetterRow { pub id: DeadLetterId, pub app_id: AppId, pub original_event_id: Uuid, pub source: String, pub op: String, pub trigger_id: Option, pub script_id: Option, pub payload: serde_json::Value, pub attempt_count: u32, pub first_attempt_at: DateTime, pub last_attempt_at: DateTime, pub last_error: String, pub created_at: DateTime, pub resolved_at: Option>, pub resolution: Option, } #[async_trait] pub trait DeadLetterRepo: Send + Sync { /// Insert a new dead-letter row. Returns the assigned id. async fn insert(&self, row: NewDeadLetter) -> Result; async fn get(&self, id: DeadLetterId) -> Result, DeadLetterRepoError>; /// Lookup for the dashboard list view. `unresolved_only=true` /// filters to `resolved_at IS NULL`. async fn list_for_app( &self, app_id: AppId, unresolved_only: bool, limit: i64, offset: i64, ) -> Result, DeadLetterRepoError>; /// Hot path for the dashboard's per-app unresolved-count badge. async fn unresolved_count(&self, app_id: AppId) -> Result; /// Mark the row resolved with the given reason. The reason MUST /// be one of the four CHECK-constraint values /// (`replayed`, `ignored`, `handled_by_script`, `handler_failed`). async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError>; /// Retention sweep. Deletes rows with `created_at < older_than` /// up to `limit` at a time, using FOR UPDATE SKIP LOCKED to play /// nicely with concurrent dispatchers. Returns the count deleted. async fn gc(&self, older_than: DateTime, limit: i64) -> Result; } pub struct PostgresDeadLetterRepo { pool: PgPool, } impl PostgresDeadLetterRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } const ALLOWED_RESOLUTIONS: &[&str] = &["replayed", "ignored", "handled_by_script", "handler_failed"]; #[async_trait] impl DeadLetterRepo for PostgresDeadLetterRepo { async fn insert(&self, row: NewDeadLetter) -> Result { let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO dead_letters ( \ app_id, 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, $5, $6, $7, $8, $9, $10, $11) \ RETURNING id", ) .bind(row.app_id.into_inner()) .bind(row.original_event_id) .bind(row.source) .bind(row.op) .bind(row.trigger_id.map(TriggerId::into_inner)) .bind(row.script_id.map(ScriptId::into_inner)) .bind(row.payload) .bind(i32::try_from(row.attempt_count).unwrap_or(0)) .bind(row.first_attempt_at) .bind(row.last_attempt_at) .bind(row.last_error) .fetch_one(&self.pool) .await?; Ok(id.into()) } async fn get(&self, id: DeadLetterId) -> Result, DeadLetterRepoError> { let row: Option = sqlx::query_as( "SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \ payload, attempt_count, first_attempt_at, last_attempt_at, \ last_error, created_at, resolved_at, resolution \ FROM dead_letters WHERE id = $1", ) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; Ok(row.map(DeadLetterRowRaw::into_row)) } async fn list_for_app( &self, app_id: AppId, unresolved_only: bool, limit: i64, offset: i64, ) -> Result, DeadLetterRepoError> { let rows: Vec = sqlx::query_as( "SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \ payload, attempt_count, first_attempt_at, last_attempt_at, \ last_error, created_at, resolved_at, resolution \ FROM dead_letters \ WHERE app_id = $1 \ AND ($2::bool = FALSE OR resolved_at IS NULL) \ ORDER BY created_at DESC \ LIMIT $3 OFFSET $4", ) .bind(app_id.into_inner()) .bind(unresolved_only) .bind(limit) .bind(offset) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(DeadLetterRowRaw::into_row).collect()) } async fn unresolved_count(&self, app_id: AppId) -> Result { let (count,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM dead_letters \ WHERE app_id = $1 AND resolved_at IS NULL", ) .bind(app_id.into_inner()) .fetch_one(&self.pool) .await?; Ok(count) } async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError> { if !ALLOWED_RESOLUTIONS.contains(&reason) { return Err(DeadLetterRepoError::InvalidResolution(reason.to_string())); } let res = sqlx::query( "UPDATE dead_letters \ SET resolution = $2, resolved_at = NOW() \ WHERE id = $1", ) .bind(id.into_inner()) .bind(reason) .execute(&self.pool) .await?; if res.rows_affected() == 0 { return Err(DeadLetterRepoError::NotFound(id)); } Ok(()) } async fn gc(&self, older_than: DateTime, limit: i64) -> Result { // Tombstones picked under FOR UPDATE SKIP LOCKED so concurrent // sweepers (cluster mode) don't fight each other. let res = sqlx::query( "DELETE FROM dead_letters \ WHERE id IN ( \ SELECT id FROM dead_letters \ WHERE created_at < $1 \ FOR UPDATE SKIP LOCKED \ LIMIT $2 \ )", ) .bind(older_than) .bind(limit) .execute(&self.pool) .await?; Ok(res.rows_affected()) } } #[derive(sqlx::FromRow)] struct DeadLetterRowRaw { id: Uuid, app_id: Uuid, original_event_id: Uuid, source: String, op: String, trigger_id: Option, script_id: Option, payload: serde_json::Value, attempt_count: i32, first_attempt_at: DateTime, last_attempt_at: DateTime, last_error: String, created_at: DateTime, resolved_at: Option>, resolution: Option, } impl DeadLetterRowRaw { fn into_row(self) -> DeadLetterRow { DeadLetterRow { id: self.id.into(), app_id: self.app_id.into(), original_event_id: self.original_event_id, source: self.source, op: self.op, trigger_id: self.trigger_id.map(Into::into), script_id: self.script_id.map(Into::into), payload: self.payload, attempt_count: u32::try_from(self.attempt_count).unwrap_or(0), first_attempt_at: self.first_attempt_at, last_attempt_at: self.last_attempt_at, last_error: self.last_error, created_at: self.created_at, resolved_at: self.resolved_at, resolution: self.resolution, } } }