The one-consumer-per-(app_id, queue_name) invariant has no DB unique index (the parent's app_id and the detail's queue_name live in different tables), so it is enforced by a pg_advisory_xact_lock on hashtext(app_id||queue_name) around a SELECT-then-INSERT. But `materialize` guarded its own check-then-insert only with the GLOBAL rematerialize lock, not that per-(app,queue) key — so a tree-apply / app-create rematerialize could interleave with an interactive `create_queue_trigger` for the same (app, queue): each SELECT sees no consumer (the other's INSERT uncommitted), both INSERT, and the app ends up with TWO enabled consumers draining the same queue, splitting messages unpredictably. Fix: `materialize_one` now resolves the template's queue_name and takes the SAME `advisory_lock_key(app_id, queue_name)` (exposed `pub(crate)`) before its slot check + insert, so the two paths mutually exclude. No deadlock: the interactive path never acquires the global rematerialize lock, so there is no lock cycle, and the rematerialize lock already serializes reconcilers against each other. No schema change. stateful_templates DB + journey coverage stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
292 lines
13 KiB
Rust
292 lines
13 KiB
Rust
//! §4.5 M5: materialization of STATEFUL group trigger templates.
|
||
//!
|
||
//! A group-owned cron / queue / email trigger is a TEMPLATE that is never
|
||
//! dispatched directly (the scheduler / queue consumer / email webhook all
|
||
//! filter `t.app_id IS NOT NULL`). Instead this reconciler expands each template
|
||
//! into an app-owned copy — `materialized_from = template.id` — for every
|
||
//! descendant app, and removes copies whose (app, template) pair no longer
|
||
//! inherits. The copy owns its per-app state (cron `last_fired_at`, the queue
|
||
//! advisory lock), so the unchanged dispatch paths work against it as if it were
|
||
//! hand-authored. An EMAIL copy is a verbatim byte-copy of the group template's
|
||
//! sealed inbound secret — the group resolved + sealed it against its own store
|
||
//! once at apply (shared-group-secret model), and email secrets are v0/no-AAD so
|
||
//! the ciphertext is portable across rows; no master key is needed here.
|
||
//!
|
||
//! Full-live like `rebuild_route_table`: called at the same chokepoints (apply,
|
||
//! app create/delete, group reparent). A precise create/delete diff (not
|
||
//! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies;
|
||
//! a DISABLED cron template keeps its copies (their `enabled` is synced off)
|
||
//! rather than deleting them, so a later re-enable resumes from the preserved
|
||
//! `last_fired_at` instead of firing from a fresh reference.
|
||
//!
|
||
//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken)
|
||
//! — the caller surfaces them; materialization of the other pairs still
|
||
//! proceeds.
|
||
|
||
use std::collections::HashSet;
|
||
|
||
use picloud_shared::AppId;
|
||
use sqlx::PgPool;
|
||
use uuid::Uuid;
|
||
|
||
/// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email).
|
||
const MATERIALIZED_KINDS: &[&str] = &["cron", "queue", "email"];
|
||
|
||
/// Fixed advisory-lock key serializing all materialization reconcilers (§4.5
|
||
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
|
||
/// would otherwise (a) read `should` and `existing` at different READ-COMMITTED
|
||
/// snapshots — a copy created by one between the two reads could be spuriously
|
||
/// DELETEd by the other (a lost cron copy that only self-heals on the next
|
||
/// mutation), and (b) both pass the queue one-consumer check and double-fill a
|
||
/// slot. An xact-scoped advisory lock makes each reconcile atomic w.r.t. the
|
||
/// others; reconciles are infrequent + fast, so serializing is cheap.
|
||
const REMATERIALIZE_LOCK_KEY: i64 = 0x0004_0005_0041_054d; // "M5" materialize marker
|
||
|
||
/// (descendant app, source template, kind) that SHOULD have a materialized copy.
|
||
#[derive(sqlx::FromRow)]
|
||
struct ShouldRow {
|
||
effective_app_id: Uuid,
|
||
template_id: Uuid,
|
||
kind: String,
|
||
/// §11.6 D3: a `shared` queue template's copies drain the GROUP store as
|
||
/// COMPETING consumers, so the one-consumer-per-(app, queue) slot check is
|
||
/// skipped for them (each descendant intentionally gets a consumer).
|
||
shared: bool,
|
||
}
|
||
|
||
/// Reconcile all materialized stateful-template copies. Idempotent; safe to call
|
||
/// on every tree/apply mutation. Best-effort per pair — a pair that can't
|
||
/// materialize (queue slot taken, missing email secret) yields a warning and is
|
||
/// skipped, not an error.
|
||
pub async fn rematerialize_stateful_templates(pool: &PgPool) -> Result<Vec<String>, sqlx::Error> {
|
||
let mut tx = pool.begin().await?;
|
||
|
||
// Serialize reconcilers (see REMATERIALIZE_LOCK_KEY): the lock is held until
|
||
// this transaction commits, so the `should`/`existing` reads below are
|
||
// snapshot-consistent relative to any other reconcile and the queue slot
|
||
// check can't race.
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(REMATERIALIZE_LOCK_KEY)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Every (descendant app × ancestor-group stateful template) that should
|
||
// exist — the all-apps `app_chain` CTE (each app × its ancestor chain) ⋈
|
||
// group-owned stateful templates.
|
||
let kinds: Vec<String> = MATERIALIZED_KINDS
|
||
.iter()
|
||
.map(|s| (*s).to_string())
|
||
.collect();
|
||
let should: Vec<ShouldRow> = sqlx::query_as(
|
||
"WITH RECURSIVE app_chain AS ( \
|
||
SELECT a.id AS effective_app_id, a.group_id AS owner_group, \
|
||
a.group_id AS next_group, 0 AS depth \
|
||
FROM apps a WHERE a.group_id IS NOT NULL \
|
||
UNION ALL \
|
||
SELECT ac.effective_app_id, g.parent_id, g.parent_id, ac.depth + 1 \
|
||
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
|
||
WHERE ac.depth < 64 AND g.parent_id IS NOT NULL \
|
||
) \
|
||
SELECT DISTINCT ac.effective_app_id, t.id AS template_id, t.kind, t.shared \
|
||
FROM app_chain ac \
|
||
JOIN triggers t ON t.group_id = ac.owner_group \
|
||
WHERE t.group_id IS NOT NULL AND t.kind = ANY($1) \
|
||
AND (t.enabled = TRUE OR t.kind = 'cron')",
|
||
)
|
||
.bind(&kinds)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
|
||
let should_set: HashSet<(Uuid, Uuid)> = should
|
||
.iter()
|
||
.map(|r| (r.effective_app_id, r.template_id))
|
||
.collect();
|
||
|
||
// Existing managed copies.
|
||
let existing: Vec<(Uuid, Uuid)> = sqlx::query_as(
|
||
"SELECT app_id, materialized_from FROM triggers WHERE materialized_from IS NOT NULL",
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
let existing_set: HashSet<(Uuid, Uuid)> = existing.iter().copied().collect();
|
||
|
||
// Delete stale copies (template gone or app no longer a descendant).
|
||
for (app_id, template_id) in &existing_set {
|
||
if !should_set.contains(&(*app_id, *template_id)) {
|
||
sqlx::query("DELETE FROM triggers WHERE app_id = $1 AND materialized_from = $2")
|
||
.bind(app_id)
|
||
.bind(template_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
// Create missing copies.
|
||
let mut warnings = Vec::new();
|
||
for r in &should {
|
||
if existing_set.contains(&(r.effective_app_id, r.template_id)) {
|
||
continue;
|
||
}
|
||
if let Some(w) = materialize_one(
|
||
&mut tx,
|
||
r.effective_app_id,
|
||
r.template_id,
|
||
&r.kind,
|
||
r.shared,
|
||
)
|
||
.await?
|
||
{
|
||
warnings.push(w);
|
||
}
|
||
}
|
||
|
||
// Sync each CRON copy's `enabled` to its template's, in place. A cron
|
||
// template's copies are kept across a disable (the `should` query includes
|
||
// disabled cron templates), so a disable→re-enable toggles this flag rather
|
||
// than delete+recreate — preserving each copy's per-app `last_fired_at` (and
|
||
// its `created_at` reference). Without this, a re-enabled cron would reset to
|
||
// a fresh reference and lose/shift its schedule state. Cron-only: queue/email
|
||
// keep delete-on-disable so the queue one-consumer slot is freed on disable.
|
||
sqlx::query(
|
||
"UPDATE triggers c SET enabled = t.enabled \
|
||
FROM triggers t \
|
||
WHERE c.materialized_from = t.id AND t.kind = 'cron' AND c.enabled <> t.enabled",
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
Ok(warnings)
|
||
}
|
||
|
||
/// Create one app-owned copy of a group template + its per-kind detail. Returns
|
||
/// `Some(warning)` when the pair is intentionally skipped (e.g. a queue slot the
|
||
/// app already fills). `name` uses the column default (a fresh UUID) so it can't
|
||
/// collide with the app's own trigger names.
|
||
async fn materialize_one(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
app_id: Uuid,
|
||
template_id: Uuid,
|
||
kind: &str,
|
||
shared: bool,
|
||
) -> Result<Option<String>, sqlx::Error> {
|
||
// §M5.4: a per-app queue copy would violate the one-consumer-per-(app_id,
|
||
// queue_name) invariant if the app already has a consumer on that queue —
|
||
// skip with a warning. §11.6 D3: a SHARED queue copy drains the group store
|
||
// as a competing consumer (a different store), so the slot check is skipped
|
||
// — every descendant intentionally gets a consumer.
|
||
if kind == "queue" && !shared {
|
||
// Resolve the queue name so we can take the SAME per-(app, queue)
|
||
// advisory lock the interactive `create_queue_trigger` uses. Without it,
|
||
// this check-then-insert (serialized only against other reconcilers by
|
||
// REMATERIALIZE_LOCK) could race an interactive consumer-create and
|
||
// double-fill the slot — the two paths held different locks.
|
||
let queue_name: Option<(String,)> =
|
||
sqlx::query_as("SELECT queue_name FROM queue_trigger_details WHERE trigger_id = $1")
|
||
.bind(template_id)
|
||
.fetch_optional(&mut **tx)
|
||
.await?;
|
||
if let Some((queue_name,)) = queue_name {
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(crate::trigger_repo::advisory_lock_key(
|
||
AppId::from(app_id),
|
||
&queue_name,
|
||
))
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
let taken: Option<(Uuid,)> = sqlx::query_as(
|
||
"SELECT t.id FROM triggers t \
|
||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
|
||
)
|
||
.bind(app_id)
|
||
.bind(&queue_name)
|
||
.fetch_optional(&mut **tx)
|
||
.await?;
|
||
if taken.is_some() {
|
||
return Ok(Some(format!(
|
||
"queue template not materialized for one app — it already has a \
|
||
consumer on that queue (app_id={app_id})"
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parent row: copy the template's settings, owned by the app, linked back.
|
||
// `ON CONFLICT DO NOTHING` (against the (app_id, materialized_from) partial
|
||
// unique index) makes this idempotent under concurrent reconciles — the
|
||
// loser gets no RETURNING row and skips the detail insert.
|
||
let new_id: Option<(Uuid,)> = sqlx::query_as(
|
||
"INSERT INTO triggers ( \
|
||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||
registered_by_principal, materialized_from) \
|
||
SELECT $1, script_id, kind, enabled, dispatch_mode, \
|
||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||
registered_by_principal, id \
|
||
FROM triggers WHERE id = $2 \
|
||
ON CONFLICT DO NOTHING RETURNING id",
|
||
)
|
||
.bind(app_id)
|
||
.bind(template_id)
|
||
.fetch_optional(&mut **tx)
|
||
.await?;
|
||
let Some(new_id) = new_id else {
|
||
// A concurrent reconcile already created this copy.
|
||
return Ok(None);
|
||
};
|
||
|
||
match kind {
|
||
"cron" => {
|
||
// A first-ever copy starts with last_fired_at NULL → its reference
|
||
// is created_at (now), so it fires at the next scheduled slot, not
|
||
// immediately. Re-enabling a previously-materialized template does
|
||
// NOT hit this path — that copy is kept and its `enabled` synced, so
|
||
// last_fired_at survives (see the enabled-sync UPDATE above).
|
||
sqlx::query(
|
||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||
SELECT $1, schedule, timezone FROM cron_trigger_details WHERE trigger_id = $2",
|
||
)
|
||
.bind(new_id.0)
|
||
.bind(template_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
"queue" => {
|
||
sqlx::query(
|
||
"INSERT INTO queue_trigger_details \
|
||
(trigger_id, queue_name, visibility_timeout_secs) \
|
||
SELECT $1, queue_name, visibility_timeout_secs \
|
||
FROM queue_trigger_details WHERE trigger_id = $2",
|
||
)
|
||
.bind(new_id.0)
|
||
.bind(template_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
"email" => {
|
||
// §M5.5: the group template sealed its inbound HMAC secret against
|
||
// the GROUP's own store once at apply (shared-group-secret model).
|
||
// The secret's AAD (§M5.5 / audit H-D1) is bound to the GROUP owner,
|
||
// NOT this copy's row, so a verbatim byte-copy stays openable — the
|
||
// inbound path recovers the group via `materialized_from`. Copy the
|
||
// version verbatim too. Each copy has its own trigger_id (its own
|
||
// inbound webhook URL).
|
||
sqlx::query(
|
||
"INSERT INTO email_trigger_details \
|
||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce, \
|
||
inbound_secret_version) \
|
||
SELECT $1, inbound_secret_encrypted, inbound_secret_nonce, \
|
||
inbound_secret_version \
|
||
FROM email_trigger_details WHERE trigger_id = $2",
|
||
)
|
||
.bind(new_id.0)
|
||
.bind(template_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(None)
|
||
}
|