feat(stateful-templates): journey + docs + concurrency fix; email deferred (M5.6)

- 0063: partial unique index on (app_id, materialized_from) + ON CONFLICT DO
  NOTHING in the reconciler, so a materialized copy is idempotent under
  concurrent reconciles (two parallel app-creates no longer duplicate a copy).
- stateful_templates journey: a group cron template + a descendant app → a
  materialized cron copy in `pic triggers ls --app`; re-apply is a NoOp.
- docs (§4.5 + CLAUDE.md): cron+queue materialization implemented; group EMAIL
  templates deferred (per-descendant inbound-secret reseal needs the master key
  threaded through the hooks + a secret-ref schema) and cleanly rejected at
  apply for now, alongside a `materialized` ls column.

Completes the v1.2 near-term batch (M1-M5, cron+queue); group email is the one
scoped follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 21:54:34 +02:00
parent ddb3589c49
commit aa5bb3e8cc
7 changed files with 164 additions and 8 deletions

View File

@@ -0,0 +1,12 @@
-- §4.5 M5: exactly one materialized copy per (descendant app, source template).
--
-- The materialization reconciler (materialize::rematerialize_stateful_templates)
-- runs on every tree/apply mutation, and two of them can run concurrently (e.g.
-- two apps created in parallel each trigger a full reconcile). Both compute the
-- same "missing" (app, template) pair and both try to insert the copy — without
-- a constraint that races into a DUPLICATE. This partial unique index makes the
-- second insert a no-op (the reconciler uses `ON CONFLICT DO NOTHING`), so a
-- copy is idempotent under concurrency.
CREATE UNIQUE INDEX triggers_materialized_uidx
ON triggers (app_id, materialized_from) WHERE materialized_from IS NOT NULL;

View File

@@ -141,7 +141,10 @@ async fn materialize_one(
}
// Parent row: copy the template's settings, owned by the app, linked back.
let new_id: (Uuid,) = sqlx::query_as(
// `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, \
@@ -149,12 +152,17 @@ async fn materialize_one(
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 RETURNING id",
FROM triggers WHERE id = $2 \
ON CONFLICT DO NOTHING RETURNING id",
)
.bind(app_id)
.bind(template_id)
.fetch_one(&mut **tx)
.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" => {

View File

@@ -635,6 +635,7 @@ indexes on triggers:
idx_triggers_materialized_from: public.triggers USING btree (materialized_from) WHERE (materialized_from IS NOT NULL)
triggers_app_name_uniq: public.triggers USING btree (app_id, name) WHERE (app_id IS NOT NULL)
triggers_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL)
triggers_materialized_uidx: public.triggers USING btree (app_id, materialized_from) WHERE (materialized_from IS NOT NULL)
triggers_pkey: public.triggers USING btree (id)
indexes on vars:
@@ -946,3 +947,4 @@ constraints on vars:
0060: group suppressions
0061: shared triggers
0062: materialized triggers
0063: materialized unique