fix(§22/registry): prune projects absent from the registry on reconcile (v0.55.0)

The registry mirror was additive-only — it upserted the projects/collections a
deployment's projects.yaml declares but never removed ones it had dropped.
Re-pinning to a different registry stranded the old project's collections + its
cached entries as dead rows that, at scale, starved writes (PPE accumulated
~1.2k orphaned entry rows and had to be reset by hand).

`registry.refresh_registry(prune=True)` now calls `projects.prune_absent_projects`,
deleting a removed project with its collections and every project-scoped row
(the 7 project_id-keyed tables + 13 collection_id-keyed entry-corpus tables +
the non-keyed thread_messages descendant) in one FK-off transaction with a
`PRAGMA foreign_key_check` backstop that rolls back rather than leave a dangling
ref — mirroring restamp_default_project / reconcile_default_collection_id.

Scoped for safety: whole-project granularity (a present project is untouched);
the default project is never pruned; prune runs ONLY on the full-reconcile
triggers (startup + the periodic sweep), never on incidental refreshes (collection
create, webhook), so an in-app refresh can't wipe a project. Reached only after a
successful parse of a non-empty registry (parse_registry rejects empty; the read
raises on transport error), so a transient read can't trigger a wipe.

Second of the two §9-surfaced framework fragilities. No migration. backend 685
green (+5: prune removes-all/no-op/never-default/empty-rejected/incidental-no-prune
with FK-integrity backstop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-09 05:28:50 -07:00
parent f0533dc073
commit 9275348c45
8 changed files with 324 additions and 6 deletions
+92
View File
@@ -181,6 +181,98 @@ def reconcile_default_collection_id(config: Config) -> None:
)
def prune_absent_projects(config: Config, present_ids: set[str]) -> list[str]:
"""Delete projects the registry no longer declares, plus their collections
and every project-scoped row. Whole-project granularity only.
`present_ids` is the authoritative set of project ids from the just-parsed
registry. Safety is structural: the only caller (`registry.refresh_registry`)
raises on a read/transport error, and `parse_registry` rejects an empty
project list — so this is reached ONLY after a real, non-empty registry has
been parsed. A transient registry-read failure therefore can't trigger a wipe.
The default project is never pruned regardless of what the registry says.
Whole-project, not per-collection/entry: a project still present keeps all its
rows here. Pruning individual entries within a present project belongs to the
best-effort content cache (whose list can transiently fail), not to this.
Mirrors `restamp_default_project`'s FK-off + `foreign_key_check` pattern. After
029 the entry-corpus tables key on `collection_id` (not `project_id`), so the
delete spans three discovered sets — project_id-keyed tables, collection_id-
keyed tables (keyed to the stale projects' collections), and the lone non-keyed
descendant `thread_messages` (FK→threads) — in one transaction with FK
enforcement off and a `foreign_key_check` backstop that rolls back on any
dangling reference (so an incomplete delete fails loudly rather than corrupts).
Returns the pruned project ids (empty when nothing is stale).
"""
keep = set(present_ids)
keep.add(resolved_default_id(config))
keep.add(DEFAULT_PROJECT_ID)
conn = db.conn()
stale = [
r["id"] for r in conn.execute("SELECT id FROM projects").fetchall()
if r["id"] not in keep
]
if not stale:
return []
stale_collections = [
r["id"] for r in conn.execute(
f"SELECT id FROM collections WHERE project_id IN ({','.join('?' * len(stale))})",
stale,
).fetchall()
]
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
def _has(table: str, col: str) -> bool:
return any(c["name"] == col for c in conn.execute(f"PRAGMA table_info({table})"))
pid_tables = [t for t in tables if t != "projects" and _has(t, "project_id")]
cid_tables = [t for t in tables if _has(t, "collection_id")]
p_ph = ",".join("?" * len(stale))
c_ph = ",".join("?" * len(stale_collections)) if stale_collections else ""
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
# Non-keyed descendant: thread_messages hangs off threads(id), which is
# project_id-keyed below. Clear it first by thread lineage.
conn.execute(
f"DELETE FROM thread_messages WHERE thread_id IN "
f"(SELECT id FROM threads WHERE project_id IN ({p_ph}))",
stale,
)
if stale_collections:
for t in cid_tables:
conn.execute(
f"DELETE FROM {t} WHERE collection_id IN ({c_ph})", stale_collections
)
for t in pid_tables: # includes collections + cached_prs + threads/changes/…
conn.execute(f"DELETE FROM {t} WHERE project_id IN ({p_ph})", stale)
conn.execute(f"DELETE FROM projects WHERE id IN ({p_ph})", stale)
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"prune left foreign-key violations: {[tuple(v) for v in violations]}"
)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
log.warning(
"prune: removed %d project(s) absent from the registry: %s "
"(%d collection(s), across %d project- + %d collection-keyed tables)",
len(stale), stale, len(stale_collections), len(pid_tables), len(cid_tables),
)
return stale
def default_content_repo(config: Config) -> str | None:
"""The content repo the single-corpus mirror reads, from the default
project's row (filled by the registry mirror). Replaces the retired