From 2f507e5721a59e3676237131fc8fd55164097667 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 6 Jun 2026 12:02:53 -0700 Subject: [PATCH] =?UTF-8?q?v0.46.1=20=E2=80=94=20fix=20migration=20029=20f?= =?UTF-8?q?or=20the=20=C2=A722.13=20re-stamp=20aftermath=20(OHM=20deploy?= =?UTF-8?q?=20fault)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 27 +++++++++ VERSION | 2 +- backend/migrations/029_collections.sql | 47 +++++++++++++++ .../tests/test_migration_029_collections.py | 60 +++++++++++++++++++ frontend/package.json | 2 +- 5 files changed, 136 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0eea8f..56310bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,33 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.46.1 — 2026-06-06 + +**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a +crash deploying the three-tier series (v0.40.0+) onto a deployment that went +through the v0.39.0 `default`→`` project re-stamp: the re-stamp updated +`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at +the stale `project_id` that migration 029's per-project collection backfill could +not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus +stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale +rows whose entry no longer exists. No operator action; **no schema change** — 029 +gains a repair prologue only. + +Fixed: + +- **Migration 029 repair prologue** — before rekeying, each entry-satellite table + (`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has + its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose + entry no longer exists are dropped (stale cache, rebuildable from gitea), and + stale rows that duplicate an already-correctly-stamped row are dropped (keeping + the fresh copy). A no-op on a clean/fresh deployment (empty or already- + consistent satellites), so fresh installs are unaffected — the existing 029 + test suite passes unchanged, plus a new regression test for the stale/dup/ + orphan shape. + +No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair +only mutates the rebuildable `cached_*` caches. + ## 0.46.0 — 2026-06-06 **Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder): diff --git a/VERSION b/VERSION index 3010923..620104d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.0 +0.46.1 diff --git a/backend/migrations/029_collections.sql b/backend/migrations/029_collections.sql index bde568f..bb819d4 100644 --- a/backend/migrations/029_collections.sql +++ b/backend/migrations/029_collections.sql @@ -26,6 +26,53 @@ -- they carry a project-grain tag, untouched in S1. See -- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E. +-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ────── +-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`) +-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving +-- rows with a stale `project_id` (e.g. 'default') that the per-project collection +-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild +-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each +-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs +-- are unique per collection and, pre-rebuild, globally), and drop rows whose +-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from +-- gitea). On a clean/fresh deployment every satellite is empty or already +-- consistent, so this whole block is a no-op. (Discovered on the OHM data: +-- ~1.3k `cached_branches` rows stranded at project_id='default'.) +-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same +-- branch cached under both the stale and the real project_id) — re-stamping them +-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly- +-- stamped copy is kept (it carries the current head_sha / visibility). Only the +-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id) +-- /(scope,pr_number) and have no stale data here, so they need no dedup. +DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects) + AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects)); +DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects) + AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects)); +UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); +UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs); +DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs); + -- ── collections: the new typed-corpus grain beneath projects ─────────────── CREATE TABLE collections ( id TEXT NOT NULL, diff --git a/backend/tests/test_migration_029_collections.py b/backend/tests/test_migration_029_collections.py index ab3f8bb..59df33b 100644 --- a/backend/tests/test_migration_029_collections.py +++ b/backend/tests/test_migration_029_collections.py @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 8a8b588..47ec95a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.46.0", + "version": "0.46.1", "type": "module", "scripts": { "dev": "vite",