v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)
Deploying the three-tier series onto OHM crash-looped on migration 029: `NOT NULL constraint failed: cached_branches__new.collection_id`, then a UNIQUE collision. Root cause: the v0.39.0 default→ohm re-stamp updated cached_rfcs.project_id but NOT the entry-satellite tables, so ~1.3k cached_branches rows were stranded at project_id='default' — which 029's per-project collection backfill can't map (NULL), some of which also duplicate freshly-re-mirrored 'ohm' rows (UNIQUE), and some of which reference entries that no longer exist. Fix — a repair prologue at the top of 029 (no schema change): - drop stale rows that duplicate an already-correctly-stamped row (keep the fresh copy) for the branch-keyed tables; - re-derive each satellite's project_id from its entry (cached_rfcs, by slug); - drop rows whose entry no longer exists (stale cache, rebuildable from gitea). A no-op on clean/fresh deployments (empty or consistent satellites). Validated against a snapshot of the live OHM DB: 029→032 apply cleanly, zero NULL collection_id, FK check clean, watches/RFCs/branch_visibility preserved, cached_branches 1397 → 1291 (−67 no-RFC, −39 dups). Fresh-install path unchanged: existing 029 suite green + a new regression test for the stale/dup/orphan shape. Full backend suite 547 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -137,3 +137,63 @@ def test_memberships_table_replaces_project_members():
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
|
||||
|
||||
|
||||
# ── regression: §22.13 satellite re-stamp repair (the OHM-data deploy fault) ──
|
||||
# Reproduces the shape that crashed the v0.46.0 deploy: the default→ohm re-stamp
|
||||
# updated cached_rfcs but left cached_branches at the stale project_id='default',
|
||||
# with (a) a stale row duplicating a freshly-stamped one, (b) a stale row with no
|
||||
# fresh counterpart, and (c) a stale row whose RFC no longer exists. 029 must
|
||||
# repair all three rather than hit NOT NULL / UNIQUE on the rebuild.
|
||||
|
||||
def _apply_through(path, ceiling):
|
||||
conn = sqlite3.connect(path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))")
|
||||
done = {r["version"] for r in conn.execute("SELECT version FROM schema_migrations")}
|
||||
for p in sorted(db.MIGRATIONS_DIR.glob("*.sql")):
|
||||
v = p.stem
|
||||
if v in done or v > ceiling:
|
||||
continue
|
||||
sql = p.read_text()
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
|
||||
return conn
|
||||
|
||||
|
||||
def test_029_repairs_stale_duplicate_and_orphan_satellite_rows():
|
||||
d = tempfile.mkdtemp()
|
||||
path = str(Path(d) / "t.db")
|
||||
conn = _apply_through(path, "028_project_scoped_keys")
|
||||
# simulate the §22.13 re-stamp having renamed the default project + its RFCs
|
||||
# to 'ohm', but NOT the satellite tables (the actual prod fault).
|
||||
conn.execute("UPDATE projects SET id='ohm' WHERE id='default'")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES ('human','Human','active','ohm')")
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','ohm')") # fresh/correct
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','default')") # stale DUP of the fresh one
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','edit-1','default')")# stale, unique -> re-stamp+keep
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('ghost','main','default')") # no live RFC -> drop
|
||||
conn.close()
|
||||
|
||||
# apply 029+ (the patched migration). Must NOT raise.
|
||||
db.run_migrations(_Cfg(path))
|
||||
conn = db.connect(path)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT rfc_slug, branch_name, collection_id FROM cached_branches"
|
||||
).fetchall()
|
||||
got = {(r["rfc_slug"], r["branch_name"]) for r in rows}
|
||||
# every surviving row mapped to a collection (the single-project 'default' one)
|
||||
assert all(r["collection_id"] is not None for r in rows)
|
||||
assert {r["collection_id"] for r in rows} == {"default"}
|
||||
# the duplicate collapsed to exactly one human/main
|
||||
assert len([r for r in rows if (r["rfc_slug"], r["branch_name"]) == ("human", "main")]) == 1
|
||||
# the unique stale row survived (re-stamped)
|
||||
assert ("human", "edit-1") in got
|
||||
# the no-RFC stale row was dropped
|
||||
assert ("ghost", "main") not in got
|
||||
|
||||
Reference in New Issue
Block a user