feat(routes): live group-route expansion + full-live invalidation (§11 tail R3)

The runtime half. Group route TEMPLATES now expand into every descendant
app's in-memory match slice, live, with no per-app materialization.

- `compile_effective_routes` consumes the `list_effective` expansion and
  applies NEAREST-OWNER-WINS shadowing: for one app, identical binding
  tuples (method+host+path) owned at multiple chain levels collapse to the
  nearest (an app's own route shadows an ancestor-group template); a nearer
  group beats a farther one. Non-identical bindings coexist and the
  existing matcher precedence resolves each request. `compile_route` now
  takes the effective app_id, so the matcher + dispatch are unchanged.
- `rebuild_route_table` is the single refresh chokepoint (list_effective →
  compile_effective_routes → replace_all). Route CRUD, the apply reconcile,
  and startup all route through it; the apply guards drop the "a group node
  touches no routes" assumption (a group apply may now create templates).
- FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent
  (groups_api) rebuild the snapshot, so inherited routes take effect the
  instant the tree changes — matching the live model of triggers/vars.
  Best-effort, mirroring apply: a failed rebuild self-heals on the next
  write or restart, never failing the committed mutation.

The isolation boundary is the chain expansion: a template lands only in the
slices of apps whose ancestor chain contains the owning group. Pinned by a
deterministic live-DB integration test (descendant inherits, sibling
subtree does not, app shadows by identical binding, non-identical coexists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 21:49:58 +02:00
parent 469e8526d7
commit 96277e1e7c
7 changed files with 354 additions and 39 deletions

View File

@@ -11,9 +11,9 @@ use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router,
email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations,
rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router,
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
@@ -33,9 +33,9 @@ use picloud_manager_core::{
PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository,
PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo,
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState,
SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
};
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -328,12 +328,12 @@ pub async fn build_app(
// route_repo, then re-populated whenever the admin layer writes
// routes.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;
// Lenient: a single un-compilable stored route (e.g. one whose path
// became reserved under stricter validation) is skipped-with-warning
// inside compile_routes, never aborting boot. (H1)
let compiled = compile_routes(&initial);
route_table.replace_all(compiled);
// §11 tail: rebuild via the live expansion so group route TEMPLATES land in
// each descendant app's slice at boot. Lenient: a single un-compilable
// stored route (e.g. one whose path became reserved under stricter
// validation) is skipped-with-warning inside the compile, never aborting
// boot. (H1)
rebuild_route_table(route_repo.as_ref(), &route_table).await?;
// v1.1.9 function composition. InvokeServiceImpl resolves targets
// by Id, Name (via get_by_name), or Path (via the orchestrator's
@@ -555,8 +555,9 @@ pub async fn build_app(
let apps_state = AppsState {
apps: apps_repo.clone(),
domains: domains_repo,
routes: route_repo,
routes: route_repo.clone(),
domain_table: app_domain_table.clone(),
route_table: route_table.clone(),
authz: authz.clone(),
groups: groups_repo.clone(),
};
@@ -604,6 +605,8 @@ pub async fn build_app(
apps: apps_state.apps.clone(),
users: auth.users.clone(),
authz: authz.clone(),
routes: route_repo.clone(),
route_table: route_table.clone(),
};
let vars_state = VarsApiState {
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),