feat(stateful-templates): materialize cron+queue templates + hooks (M5.2-M5.4)

materialize::rematerialize_stateful_templates reconciles a group cron/queue
template into one app-owned copy per descendant (materialized_from = template),
via the all-apps app_chain CTE ⋈ group-owned stateful templates. A precise
create/delete diff preserves cron last_fired_at on unchanged copies; a queue
copy is skipped-with-warning when the app already fills that queue's consumer
slot (the one-consumer invariant). Called full-live at the route-rebuild
chokepoints: apply (single + tree), app create/delete (apps_api), group
reparent (groups_api) — AppsState/GroupsState gain a pool. Pinned by
tests/stateful_templates.rs (per-app copy, idempotent, new-app materializes,
reparent de-materializes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 21:47:27 +02:00
parent 456e972336
commit ddb3589c49
7 changed files with 425 additions and 0 deletions

View File

@@ -50,6 +50,10 @@ pub struct GroupsState {
/// apps inherit — so the route snapshot is rebuilt after a reparent.
pub routes: Arc<dyn RouteRepository>,
pub route_table: Arc<RouteTable>,
/// §4.5 M5: reparenting moves a subtree, changing which ancestor-group
/// stateful templates its apps inherit — their materialized cron/queue
/// copies are reconciled after a reparent.
pub pool: sqlx::PgPool,
}
pub fn groups_router(state: GroupsState) -> Router {
@@ -295,6 +299,11 @@ async fn reparent_group(
{
tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal");
}
// §4.5 M5: the moved subtree inherits a different set of ancestor-group
// stateful templates — reconcile its materialized copies.
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&s.pool).await {
tracing::warn!(error = %e, "groups: stateful-template materialization after reparent failed; it will self-heal");
}
Ok(Json(moved))
}