Files
PiCloud/crates/manager-core/src/materialize.rs
MechaCat02 fc70f5e224 fix(materialize): take the per-(app,queue) lock so a consumer slot can't double-fill
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>
2026-07-13 22:31:42 +02:00

292 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! §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)
}