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:
@@ -798,7 +798,7 @@ class Reconciler:
|
||||
log.info("reconciler: starting sweep")
|
||||
try:
|
||||
try:
|
||||
await registry_mod.refresh_registry(self._config, self._gitea)
|
||||
await registry_mod.refresh_registry(self._config, self._gitea, prune=True)
|
||||
except Exception:
|
||||
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
||||
await refresh_meta_repo(self._config, self._gitea)
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ async def lifespan(app: FastAPI):
|
||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||
# fresh thereafter and tolerates a later bad PR.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
await registry_mod.refresh_registry(config, gitea, prune=True)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-2
@@ -333,11 +333,18 @@ async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryD
|
||||
_upsert_named_collection(proj, subdir, ce, sha)
|
||||
|
||||
|
||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
async def refresh_registry(config: Config, gitea: Gitea, *, prune: bool = False) -> None:
|
||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||
|
||||
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
||||
on transport failure; the caller chooses fatal-vs-tolerated.
|
||||
|
||||
`prune=True` additionally removes projects the registry no longer declares
|
||||
(and their collections + entries) — see `projects.prune_absent_projects`.
|
||||
It is OFF by default and enabled only on the full-reconcile triggers
|
||||
(startup + the periodic sweep), never on incidental refreshes (a collection
|
||||
create, a webhook) that don't change the project set — keeping the
|
||||
destructive sweep on the few code paths that mean "reconcile to the registry."
|
||||
"""
|
||||
item = await gitea.get_contents(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
@@ -355,4 +362,18 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||
# §22 S2: discover + upsert named collections from each content repo.
|
||||
await _mirror_named_collections(config, gitea, doc, sha)
|
||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||
# Prune projects the registry no longer declares (additive-only was a §9
|
||||
# fragility — a re-pin to a new registry stranded the old projects' entries,
|
||||
# starving writes). Safe here: we only reach this line after a successful
|
||||
# parse of a non-empty registry (parse_registry rejects an empty project list
|
||||
# and the read above raises on transport failure), so a transient failure
|
||||
# never prunes. Whole-project granularity; the default project is never pruned.
|
||||
pruned = (
|
||||
projects_mod.prune_absent_projects(config, {e.id for e in doc.projects})
|
||||
if prune else []
|
||||
)
|
||||
log.info(
|
||||
"registry: mirrored %d project(s) at %s%s",
|
||||
len(doc.projects), sha,
|
||||
f"; pruned {len(pruned)} absent: {pruned}" if pruned else "",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user