feat(v1.1.1-triggers): triggers + outbox schema + repos
Migrations 0008-0011 lay down the triggers framework's storage: - `triggers` + `kv_trigger_details` + `dead_letter_trigger_details` (Layout E, design notes §2). Parent table carries common columns including `registered_by_principal` — the dispatcher uses this to run the trigger as the user that registered it (design notes §4). - `outbox`: universal async dispatch substrate. KV/cron/pubsub/queue/ email/dead-letter all write rows in the same shape; the dispatcher claims due rows via FOR UPDATE SKIP LOCKED. `reply_to` is the NATS-style inbox id for sync HTTP (commit 6) — its presence flags "don't retry" per the design. - `dead_letters`: exact schema from design notes §4 with the four- value `resolution` CHECK constraint (`replayed | ignored | handled_by_script | handler_failed`) and partial index on unresolved rows for the dashboard badge. - `abandoned_executions`: forensic table for the dispatcher's "tried to resolve a dropped inbox" edge case (design notes §3 #9). Repo surfaces with Postgres impls behind traits so unit tests can swap in-memory backings: - `TriggerRepo` — CRUD + the `list_matching_kv` / `list_matching_dead_letter` hot paths the dispatcher uses. Includes a `collection_matches` helper that handles `*`, `prefix:*`, and exact-name globs. - `OutboxRepo` — insert + claim-due + delete + reschedule. - `DeadLetterRepo` — insert + get + list + unresolved-count + resolve + GC. - `AbandonedRepo` — insert + GC. `TriggerConfig::from_env` (new module) follows the existing `SandboxCeiling` env-loading pattern for `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_*`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, and `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`. `Capability::AppManageTriggers(AppId)` and `AppDeadLetterManage(AppId)` join the enum. Both map onto the existing `Scope::AppAdmin` per the seven-scope commitment; `role_satisfies` grants them at the `AppAdmin` per-app role. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
233
crates/manager-core/src/outbox_repo.rs
Normal file
233
crates/manager-core/src/outbox_repo.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! `OutboxRepo` — universal trigger outbox CRUD. Hot writes come from
|
||||
//! the `OutboxEventEmitter` (KV mutations fan out via this) and the
|
||||
//! sync-HTTP path. Hot reads come from the dispatcher, which claims
|
||||
//! due rows via `FOR UPDATE SKIP LOCKED`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, ExecutionId, ScriptId, TriggerId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OutboxRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutboxSourceKind {
|
||||
Http,
|
||||
Kv,
|
||||
DeadLetter,
|
||||
}
|
||||
|
||||
impl OutboxSourceKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Http => "http",
|
||||
Self::Kv => "kv",
|
||||
Self::DeadLetter => "dead_letter",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_wire(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"http" => Some(Self::Http),
|
||||
"kv" => Some(Self::Kv),
|
||||
"dead_letter" => Some(Self::DeadLetter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert payload — what each event source writes when fanning out
|
||||
/// to the outbox. `payload` is the serialized `TriggerEvent` (plus
|
||||
/// any extra context the dispatcher needs to reconstruct an
|
||||
/// `ExecRequest`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewOutboxRow {
|
||||
pub app_id: AppId,
|
||||
pub source_kind: OutboxSourceKind,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub origin_principal: Option<AdminUserId>,
|
||||
pub trigger_depth: u32,
|
||||
pub root_execution_id: Option<ExecutionId>,
|
||||
}
|
||||
|
||||
/// Row as the dispatcher sees it after a claim.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxRow {
|
||||
pub id: Uuid,
|
||||
pub app_id: AppId,
|
||||
pub source_kind: OutboxSourceKind,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub origin_principal: Option<AdminUserId>,
|
||||
pub trigger_depth: u32,
|
||||
pub root_execution_id: Option<ExecutionId>,
|
||||
pub attempt_count: u32,
|
||||
pub next_attempt_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OutboxRepo: Send + Sync {
|
||||
async fn insert(&self, row: NewOutboxRow) -> Result<Uuid, OutboxRepoError>;
|
||||
|
||||
/// Claim up to `limit` due rows. Wraps the claim in a single
|
||||
/// transaction so two concurrent dispatchers (cluster mode) can't
|
||||
/// double-pick a row. Empty Vec when nothing is due.
|
||||
async fn claim_due(
|
||||
&self,
|
||||
claimed_by: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, OutboxRepoError>;
|
||||
|
||||
/// Remove a row after a terminal outcome (success or dead-letter).
|
||||
async fn delete(&self, id: Uuid) -> Result<(), OutboxRepoError>;
|
||||
|
||||
/// Failure path: bump attempt_count, clear the claim, set the
|
||||
/// next attempt time. The dispatcher computes the delay (with
|
||||
/// backoff + jitter) and passes it in.
|
||||
async fn reschedule(
|
||||
&self,
|
||||
id: Uuid,
|
||||
attempt_count: u32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
) -> Result<(), OutboxRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresOutboxRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresOutboxRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for PostgresOutboxRepo {
|
||||
async fn insert(&self, row: NewOutboxRow) -> Result<Uuid, OutboxRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO outbox ( \
|
||||
app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, root_execution_id \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.source_kind.as_str())
|
||||
.bind(row.trigger_id.map(TriggerId::into_inner))
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.reply_to)
|
||||
.bind(row.payload)
|
||||
.bind(row.origin_principal.map(AdminUserId::into_inner))
|
||||
.bind(i32::try_from(row.trigger_depth).unwrap_or(0))
|
||||
.bind(row.root_execution_id.map(ExecutionId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn claim_due(
|
||||
&self,
|
||||
claimed_by: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, OutboxRepoError> {
|
||||
let rows: Vec<OutboxRowRaw> = sqlx::query_as(
|
||||
"WITH due AS ( \
|
||||
SELECT id FROM outbox \
|
||||
WHERE claimed_at IS NULL AND next_attempt_at <= NOW() \
|
||||
ORDER BY next_attempt_at \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $1 \
|
||||
) \
|
||||
UPDATE outbox SET claimed_at = NOW(), claimed_by = $2 \
|
||||
WHERE id IN (SELECT id FROM due) \
|
||||
RETURNING id, app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, \
|
||||
root_execution_id, attempt_count, next_attempt_at, created_at",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(claimed_by)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().filter_map(OutboxRowRaw::hydrate).collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), OutboxRepoError> {
|
||||
sqlx::query("DELETE FROM outbox WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reschedule(
|
||||
&self,
|
||||
id: Uuid,
|
||||
attempt_count: u32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
) -> Result<(), OutboxRepoError> {
|
||||
sqlx::query(
|
||||
"UPDATE outbox SET attempt_count = $2, next_attempt_at = $3, \
|
||||
claimed_at = NULL, claimed_by = NULL \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(i32::try_from(attempt_count).unwrap_or(0))
|
||||
.bind(next_attempt_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct OutboxRowRaw {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
source_kind: String,
|
||||
trigger_id: Option<Uuid>,
|
||||
script_id: Option<Uuid>,
|
||||
reply_to: Option<Uuid>,
|
||||
payload: serde_json::Value,
|
||||
origin_principal: Option<Uuid>,
|
||||
trigger_depth: i32,
|
||||
root_execution_id: Option<Uuid>,
|
||||
attempt_count: i32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl OutboxRowRaw {
|
||||
fn hydrate(self) -> Option<OutboxRow> {
|
||||
Some(OutboxRow {
|
||||
id: self.id,
|
||||
app_id: self.app_id.into(),
|
||||
source_kind: OutboxSourceKind::from_wire(&self.source_kind)?,
|
||||
trigger_id: self.trigger_id.map(Into::into),
|
||||
script_id: self.script_id.map(Into::into),
|
||||
reply_to: self.reply_to,
|
||||
payload: self.payload,
|
||||
origin_principal: self.origin_principal.map(Into::into),
|
||||
trigger_depth: u32::try_from(self.trigger_depth).unwrap_or(0),
|
||||
root_execution_id: self.root_execution_id.map(Into::into),
|
||||
attempt_count: u32::try_from(self.attempt_count).unwrap_or(0),
|
||||
next_attempt_at: self.next_attempt_at,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user