9275348c45
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>
164 lines
8.3 KiB
Python
164 lines
8.3 KiB
Python
"""§9 hardening — the registry reconcile prunes projects the registry no longer
|
|
declares (was additive-only).
|
|
|
|
A re-pin to a new registry used to strand the old projects' rows (PPE's
|
|
stale-ecomm 1238-row entry cache starving writes). `prune_absent_projects` now
|
|
deletes a removed project together with its collections and every project-scoped
|
|
row, at whole-project granularity, with a `foreign_key_check` backstop. The
|
|
safety guard is structural: `refresh_registry` raises on a read/transport error
|
|
and `parse_registry` rejects an empty project list, so the prune is reached only
|
|
after a real, non-empty registry parse — a transient failure can't wipe data.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import app.db as db
|
|
from app import projects, registry
|
|
|
|
|
|
class _Cfg:
|
|
def __init__(self, path, default_id):
|
|
self.database_path = path
|
|
self.default_project_id = default_id
|
|
|
|
|
|
def _two_project_db(monkeypatch, default_id="ohm"):
|
|
"""A multi-project DB: default project 'ohm' (collection 'default') + a second
|
|
project 'ecomm' (collection 'ecomm'), each with a rich set of project- and
|
|
collection-keyed rows, plus the non-keyed `thread_messages` descendant."""
|
|
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
|
cfg = _Cfg(path, default_id)
|
|
db.run_migrations(cfg)
|
|
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
|
conn = db.conn()
|
|
conn.execute("DELETE FROM collections")
|
|
conn.execute("DELETE FROM projects")
|
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ohm','OHM','ohm-content','public')")
|
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ecomm','Ecomm','ecomm-content','public')")
|
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
|
"VALUES ('default','ohm','document','','super-draft','public','OHM')")
|
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
|
"VALUES ('ecomm','ecomm','bdd','ecomm','super-draft','public','Ecomm')")
|
|
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
|
|
|
def seed(proj, coll, slug):
|
|
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES (?,?,'active',?)",
|
|
(slug, slug.title(), coll))
|
|
conn.execute("INSERT INTO cached_branches (rfc_slug,branch_name,collection_id) VALUES (?, 'main', ?)",
|
|
(slug, coll))
|
|
conn.execute("INSERT INTO cached_prs (rfc_slug,pr_kind,repo,pr_number,title,state,project_id) "
|
|
"VALUES (?, 'rfc_branch', ?, 1, 't', 'open', ?)", (slug, proj, proj))
|
|
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,?,?)", (slug, coll))
|
|
conn.execute("INSERT INTO watches (user_id,rfc_slug,state,set_by,collection_id) VALUES (1,?,'watching','explicit',?)",
|
|
(slug, coll))
|
|
conn.execute("INSERT INTO changes (rfc_slug,branch_name,kind,original,proposed,project_id) "
|
|
"VALUES (?, 'main', 'ai', 'o', 'p', ?)", (slug, proj))
|
|
conn.execute("INSERT INTO notifications (recipient_user_id,event_kind,project_id) VALUES (1,'x',?)", (proj,))
|
|
cur = conn.execute("INSERT INTO threads (rfc_slug,anchor_kind,thread_kind,project_id) "
|
|
"VALUES (?, 'whole-doc', 'chat', ?)", (slug, proj))
|
|
conn.execute("INSERT INTO thread_messages (thread_id,role,text) VALUES (?, 'user', 'hi')", (cur.lastrowid,))
|
|
|
|
seed("ohm", "default", "human")
|
|
seed("ecomm", "ecomm", "cart")
|
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] # seed is FK-clean
|
|
return cfg, conn
|
|
|
|
|
|
_PRUNED_TABLES = [
|
|
"cached_rfcs", "cached_branches", "cached_prs", "stars", "watches",
|
|
"changes", "notifications", "threads",
|
|
]
|
|
|
|
|
|
def test_prune_removes_absent_project_and_all_its_data(monkeypatch):
|
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
|
pruned = projects.prune_absent_projects(cfg, {"ohm"}) # ecomm absent
|
|
assert pruned == ["ecomm"]
|
|
|
|
# ecomm: project, collection, and every keyed row gone.
|
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ecomm'").fetchone() is None
|
|
assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is None
|
|
for t in _PRUNED_TABLES:
|
|
col = "collection_id" if t in ("cached_rfcs", "cached_branches", "stars", "watches") else None
|
|
if col:
|
|
assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE collection_id='ecomm'").fetchone()["n"] == 0, t
|
|
else:
|
|
assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE project_id='ecomm'").fetchone()["n"] == 0, t
|
|
# The non-keyed descendant (thread_messages on ecomm's thread) is gone too.
|
|
assert conn.execute("SELECT COUNT(*) n FROM thread_messages").fetchone()["n"] == 1 # only ohm's remains
|
|
|
|
# ohm (the default) is fully intact.
|
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
|
assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs WHERE slug='human'").fetchone()["n"] == 1
|
|
assert conn.execute("SELECT COUNT(*) n FROM threads WHERE rfc_slug='human'").fetchone()["n"] == 1
|
|
|
|
# FK integrity intact after the prune.
|
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
|
|
|
|
|
def test_prune_is_noop_when_all_projects_present(monkeypatch):
|
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
|
assert projects.prune_absent_projects(cfg, {"ohm", "ecomm"}) == []
|
|
assert conn.execute("SELECT COUNT(*) n FROM projects").fetchone()["n"] == 2
|
|
assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs").fetchone()["n"] == 2
|
|
|
|
|
|
def test_prune_never_removes_the_default_project(monkeypatch):
|
|
# Even with an EMPTY present set (which the real caller can't produce —
|
|
# parse_registry rejects an empty registry), the default project survives.
|
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
|
pruned = projects.prune_absent_projects(cfg, set())
|
|
assert "ohm" not in pruned and "ecomm" in pruned
|
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
|
|
|
|
|
def test_empty_registry_is_rejected_so_prune_is_never_reached():
|
|
# The structural safety guard: a parse that would yield no projects raises,
|
|
# so refresh_registry never reaches the prune with an empty present set.
|
|
with pytest.raises(registry.RegistryError):
|
|
registry.parse_registry("deployment:\n name: x\nprojects: []\n")
|
|
|
|
|
|
def test_incidental_refresh_does_not_prune(app_with_fake_gitea): # noqa: F811
|
|
"""An incidental re-mirror (the registry webhook, prune=False) must NOT prune
|
|
a project absent from projects.yaml — only the full-reconcile triggers
|
|
(startup + periodic sweep) prune. Guards the scoping decision so an in-app
|
|
refresh can't wipe a project."""
|
|
import hashlib
|
|
import hmac
|
|
import json as _json
|
|
|
|
from fastapi.testclient import TestClient
|
|
from app import db
|
|
|
|
app, _fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
# A project present in the DB but not in the fake registry's projects.yaml.
|
|
db.conn().execute(
|
|
"INSERT INTO projects (id,name,content_repo,visibility) "
|
|
"VALUES ('ghost','Ghost','ghost-content','public')"
|
|
)
|
|
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
|
|
secret = "test-webhook-secret-for-signature-verification"
|
|
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
r = client.post(
|
|
"/api/webhooks/gitea",
|
|
content=body,
|
|
headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig,
|
|
"Content-Type": "application/json"},
|
|
)
|
|
assert r.status_code == 200
|
|
# The incidental re-mirror left the ghost in place (prune is off here).
|
|
assert db.conn().execute("SELECT 1 FROM projects WHERE id='ghost'").fetchone() is not None
|
|
|
|
|
|
# app_with_fake_gitea fixture import for the integration test above.
|
|
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: E402,F401
|