//! `AbandonedExecutionsRepo` — forensic table written by the //! dispatcher when it tries to resolve a sync-HTTP inbox channel //! that's already been dropped (orchestrator timed out and gave up). //! //! Schema: see `migrations/0011_abandoned_executions.sql`. //! //! Tiny surface: insert + GC. Reading happens via direct SQL when //! correlating the metric counter spike. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::{AppId, ScriptId}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum AbandonedRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), } #[derive(Debug, Clone)] pub struct NewAbandonedExecution { pub app_id: AppId, pub outbox_id: Uuid, pub script_id: Option, pub inbox_id: Uuid, pub status_code: u16, pub result_summary: Option, } #[async_trait] pub trait AbandonedRepo: Send + Sync { async fn insert(&self, row: NewAbandonedExecution) -> Result; /// Retention sweep — deletes rows older than `older_than` up to /// `limit` at a time. async fn gc(&self, older_than: DateTime, limit: i64) -> Result; } pub struct PostgresAbandonedRepo { pool: PgPool, } impl PostgresAbandonedRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } const SUMMARY_CAP_BYTES: usize = 4096; #[async_trait] impl AbandonedRepo for PostgresAbandonedRepo { async fn insert(&self, row: NewAbandonedExecution) -> Result { // Truncate the summary at write-time. The forensic table // doesn't need megabytes; the original outbox row may have // been arbitrary size but we lose nothing useful by clipping. let summary = row.result_summary.map(|s| truncate(s, SUMMARY_CAP_BYTES)); let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO abandoned_executions ( \ app_id, outbox_id, script_id, inbox_id, status_code, result_summary \ ) VALUES ($1, $2, $3, $4, $5, $6) \ RETURNING id", ) .bind(row.app_id.into_inner()) .bind(row.outbox_id) .bind(row.script_id.map(ScriptId::into_inner)) .bind(row.inbox_id) .bind(i32::from(row.status_code)) .bind(summary) .fetch_one(&self.pool) .await?; Ok(id) } async fn gc(&self, older_than: DateTime, limit: i64) -> Result { let res = sqlx::query( "DELETE FROM abandoned_executions \ WHERE id IN ( \ SELECT id FROM abandoned_executions \ WHERE created_at < $1 \ FOR UPDATE SKIP LOCKED \ LIMIT $2 \ )", ) .bind(older_than) .bind(limit) .execute(&self.pool) .await?; Ok(res.rows_affected()) } } fn truncate(mut s: String, max_bytes: usize) -> String { if s.len() <= max_bytes { return s; } // Walk back from `max_bytes` to a UTF-8 char boundary so we never // panic on `truncate` mid-codepoint. let mut cut = max_bytes; while cut > 0 && !s.is_char_boundary(cut) { cut -= 1; } s.truncate(cut); s } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_respects_char_boundaries() { // 3-byte UTF-8 chars; cap inside the middle char should walk // back to the start. let s = "héllo".to_string(); let t = truncate(s, 2); assert!(t.is_char_boundary(t.len())); assert_eq!(t, "h"); } #[test] fn truncate_passthrough_for_short_strings() { assert_eq!(truncate("ok".into(), 100), "ok"); } }