feat(backend): implement Repository Lineage per RFC-0002 (#299) - #372
Conversation
Adds the durable, owner-scoped logical grouping above repository revisions that RFC-0002 defines: a new `repository_lineages` table, nullable `repositories.lineage_id`/`sequence` columns, and race-free transactional allocation on live GitHub import, wired against the plan in docs/architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md (owner-approved via PR #328, authorized for implementation per that document's executive verdict; the #322 rehearsal/recovery prerequisite is already closed). This is the highest-risk change of the whole backlog this pass, and it was treated that way throughout: implementation only started after the owner's explicit go-ahead given the specific risk (a production schema migration introducing cyclic deferred foreign keys), with three explicit conditions -- real-PostgreSQL concurrency verification (not just SQLite), a full disposable-database rehearsal via the existing rehearsal script, and extra-thorough test coverage on the FK-cycle handling specifically. All three are satisfied, in detail, below. ## What's new - `repository_lineages`: id, owner-scoped canonical (`canonical_source_key`, `canonical_branch`) key, `display_name`, `latest_repository_id`, `next_sequence`, `created_at`. A partial unique index is the owner-scoped canonical lookup and the sole race-arbiter if two imports try to create the same lineage at once. - `repositories.lineage_id` / `repositories.sequence`: permanently nullable. Uploads and unresolved-ref legacy GitHub rows are unlineaged standalone imports by design (RFC §4.3/§6), not a transitional state. - The cyclic integrity boundary: `repository_lineages.latest_repository_id` (+`id`) -> `repositories.id`/`lineage_id`, and `repositories.lineage_id` (+`owner_id`) -> `repository_lineages.id`/`owner_id`. Both are deferrable/ initially-deferred composite foreign keys, verified empirically (raw SQL, SQLAlchemy `create_all()`, and the actual Alembic migration) on both SQLite and real PostgreSQL before writing a line of the real implementation, per the plan's own explicit "if SQLite can't preserve this, stop" instruction. - Two Alembic revisions (`0013_lineage_expand`, `0014_lineage_constraints` -- the plan suggested 0011/0012, but 0011/0012 were taken by unrelated work that landed on dev since the plan was written): revision A creates the lineage table, expands `repositories`, runs a strict deterministic backfill of resolvable historical GitHub commits (grouped by owner + case-folded canonical source + exact ref, sorted by created_at/id, UUIDv5-keyed for idempotent reconciliation), verifies every §6.4 invariant, then closes the non-cyclic constraints; revision B closes the cyclic one. Uploads, unresolved refs, and any URL outside the exact accepted historical grammar stay standalone -- nothing is guessed. - `RepositoryRepository.add_with_lineage`: the transactional allocator (RFC §5.2) -- find-or-create the lineage, atomically claim the next sequence via `UPDATE ... SET next_sequence = next_sequence + 1`, check for a same-lineage duplicate commit, insert, update the latest pointer, commit once. Bounded retry (5, matching `AiConversationRepository`) on the create-lineage race. - `RepositoryRepository.delete_with_lineage_update`: rolls the latest pointer back to the next-highest surviving sequence before deleting, never decrements the counter, keeps an empty lineage (not garbage collected) so a later re-import never reuses a sequence. - `RepositoryService.import_github_repository` now always computes a canonical pair (a resolved ref is guaranteed by the time this runs) and persists through the transactional allocator instead of a plain insert. `import_uploaded_repository` is unchanged in behaviour -- it already never set lineage fields. ## A real bug found and fixed along the way (not previously present) Removed the old pre-clone `find_by_source_revision_for_owner` duplicate check. It matched on `(source_url, revision_value, owner_id)` with no branch/ref component -- structurally impossible to make branch-aware, since the ref isn't resolved until after cloning -- and it incorrectly rejected re-importing the same commit under a different branch, which RFC-0002 explicitly requires to succeed. The new transactional, lineage-scoped duplicate check is now the sole and correct authority; a regression test (`test_same_commit_is_allowed_in_two_different_branch_lineages`) covers exactly this case. ## A real SQLite/Alembic bug found and fixed along the way `alembic/env.py` now disables `PRAGMA foreign_keys` for the connection Alembic itself uses to run migrations, SQLite only. Root cause, confirmed by direct reproduction: SQLite refuses to toggle that pragma mid-transaction (a documented no-op once a transaction is open), and Alembic's own per-migration transaction is already open by the time a revision's `upgrade()` runs. Revision B's batch-mode recreation of `repository_lineages` (required to add the cyclic FK -- SQLite has no `ALTER TABLE ADD CONSTRAINT`) drops and rebuilds a table that `repositories` already has a deferred FK pointing at from revision A; SQLite's deferred-FK bookkeeping does not correctly reconcile that recreation against the still-open transaction, so a fully self-consistent final state still fails at COMMIT with a generic "FOREIGN KEY constraint failed" (`PRAGMA foreign_key_check` reports zero violations immediately beforehand -- confirmed directly). This only reproduced once `app.core.database` had been imported earlier in the same process (registering the global `PRAGMA foreign_keys=ON` connect-event listener), so it silently passed in isolated runs and only surfaced in a full-suite run -- exactly the kind of thing "extra-thorough" coverage was meant to catch. The regression test now forces the listener's registration itself rather than depending on incidental test-file ordering, and was verified to actually fail without the fix (confirmed by temporarily reverting it). Runtime enforcement for the real application is unaffected: every normal app connection still gets `PRAGMA foreign_keys=ON` as before; this change touches only the connection Alembic itself uses while migrating. ## Verification (the three explicit conditions) 1. **Real-PostgreSQL concurrency verification**: `test_repository_lineage_concurrency.py`, real threads against a real, separate-connection PostgreSQL database with `threading.Barrier` synchronization (matching the existing `test_concurrent_refresh_on_postgres_mints_one_successor` pattern) -- two different commits racing into an existing lineage get unique consecutive sequences; two first-imports racing create exactly one lineage; two identical commits racing produce one repository and one correctly-rejected conflict with no burned sequence number; a forced duplicate `(lineage_id, sequence)` is rejected; a forced cross-owner attachment is rejected by the composite FK; a forced cross-lineage latest pointer is rejected by the composite FK; account deletion cascades a user's lineages without touching another owner's. All 7 pass reliably (verified across 5 repeated runs) and clean up every row they create. 2. **Full disposable rehearsal**: `python scripts/rehearse_migrations.py` (SQLite) and `--postgres` (real, disposable database) both pass -- clean upgrade -> clean downgrade -> re-upgrade, and the representative `0004_ai_provider_configs` baseline reaches head. `HEAD_REVISION` and `REQUIRED_HEAD_TABLES` updated for the new head. 3. **Extra-thorough FK-cycle coverage**: beyond the concurrency file's direct FK-forcing tests, `test_repository_lineage_migration.py` verifies the deferred FK's exact shape (constrained/referred columns, `deferrable`) after a fresh migration, and a populated-database backfill test exercises every §6 grouping/exclusion case in one pass (same source/ref groups; different ref/repo/owner separate; a URL variant -- mixed-case host, `.git` suffix, trailing slash -- still canonicalizes into the same lineage; malformed URL, unresolved ref, and upload all stay standalone; a deterministic timestamp tie breaks on repository id). Full backend suite (`pytest`, no `-k`) green on both SQLite and real PostgreSQL. `ruff check`/`ruff format --check`/`mypy` clean on every changed file. `npm run generate:api-contract -- --check` confirms zero frontend contract drift, matching the plan's "no API contract change, no frontend surface" scope. Docs updated: the migration plan's revision-ID note now reflects the actual 0013/0014 numbering, and the rehearsal runbook's stated head revision is current.
| ) | ||
| with results_lock: | ||
| results.append(persisted.sequence) | ||
| except BaseException as exc: # noqa: BLE001 -- captured for the assertion below, not swallowed |
| ) | ||
| with results_lock: | ||
| results.append(persisted.sequence) | ||
| except BaseException as exc: # noqa: BLE001 |
) CI's Backend job failed on test_cross_owner_lineage_attachment_is_rejected_ by_the_database_even_if_forced ("DID NOT RAISE IntegrityError") on Linux; it passed reliably every time locally (isolated, full suite, and with CI's exact pytest/coverage command reproduced) on macOS. Not reproducible locally, so rather than guess at the platform difference, this removes the test's dependency on app.core.database's process-wide connect-event listener having already fired for this exact connection -- it now sets `PRAGMA foreign_keys=ON` explicitly, as the first statement on its own session, before asserting the deferred FK rejects the forced cross-owner attachment at commit. This does not touch application or migration code, and does not change what's being verified: only PostgreSQL (not SQLite) is the documented deployment/CI dialect, and the equivalent real-Postgres proof (test_cross_owner_composite_membership_is_rejected_on_real_postgres in test_repository_lineage_concurrency.py) already passes reliably across repeated runs. This is strictly a local-SQLite-dev-path test reliability fix.
|
Pushed a fix for the CI failure: |
…commit (#299) The previous fix (explicitly setting PRAGMA foreign_keys=ON on this exact connection) did not resolve the CI failure: confirmed from the actual CI log that the fix's code ran, the pragma read back as intended, and COMMIT still did not raise IntegrityError on the Linux CI runner -- while the identical scenario raises reliably every time on macOS. This is a genuine SQLite deferred-foreign-key enforcement difference between platforms/ SQLite builds, not a test-ordering or pragma-timing issue. Rather than keep guessing at platform-specific COMMIT-time deferred enforcement, this now verifies the same underlying fact a different way: PRAGMA foreign_key_check, which SQLite documents as detecting a violation regardless of the connection's foreign_keys/deferred state. The row is flushed (visible within the still-open transaction) and foreign_key_check is asserted to find it, instead of relying on commit to raise. Also asserts the pragma reads back as enabled, so if this ever silently stops being set at all, the test fails on that explicitly rather than on an unrelated missing violation. This still proves the fact this test exists to prove -- a repository row attached to another owner's lineage is a genuine constraint violation -- without depending on whichever platform-specific commit-time behavior was making the previous assertion unreliable in CI. The equivalent real- Postgres proof (test_cross_owner_composite_membership_is_rejected_on_real_postgres) is unaffected and continues to pass reliably.
|
Update on the CI failure: the first fix (explicitly setting Rather than keep guessing at that platform-specific commit-time behavior, I rewrote the assertion to use |
…#299) Root cause found for the CI failure the previous two commits tried to patch around: PRAGMA foreign_key_check itself found no violation for the forced cross-owner row on CI's SQLite build -- not a deferred-enforcement timing quirk, but the constraint genuinely not being tracked for enforcement there, while introspection still reports it correctly. The actual cause: Base.metadata.create_all() (used by the client/ auth_client test fixtures, and by AUTO_CREATE_TABLES=true in local development) must resolve this table pair's genuine foreign-key cycle by emitting one of repositories/repository_lineages with an inline FK referencing the other table before it exists -- there is no ALTER TABLE ADD CONSTRAINT to add it afterward on SQLite. Confirmed directly via echo=True: repositories was created first in this run, its fk_repositories_lineage_owner FK referencing a repository_lineages table that didn't exist yet. At least one real SQLite build does not enforce a deferred FK declared that way. The Alembic migration never does this -- 0013 creates repository_lineages and the non-cyclic repositories columns/ constraints first, 0014 adds each cyclic FK in its own revision only after both tables already exist -- so a database built by migrating, including every real deployment and this repo's own CI rehearsal, is unaffected. Moves the proof from test_repository_lineage_service.py (auth_client, create_all()) to test_repository_lineage_migration.py (real Alembic migration against a temp SQLite file, the same construction already used for every other test in that file), using direct ORM/Core writes instead of the HTTP auth API. Verified passing on both SQLite and real PostgreSQL, including with CI's exact pytest/coverage command and env vars reproduced locally (999/999, zero failures). Documents the underlying create_all()-on-SQLite limitation directly on both cyclic FK declarations (app/models/repository.py, app/models/repository_lineage.py) so it isn't a silent surprise for a future reader: it is a development/test schema-bootstrap gap only, not a production one, since AUTO_CREATE_TABLES is false outside development/ test and every real deployment runs the migrations.
|
Root cause found and fixed. The actual cause: Moved the proof onto a database built via the real migration instead of |
Closes #299.
Context
This is the highest-risk item in the backlog: a production schema migration introducing cyclic deferred foreign keys between a new
repository_lineagestable and the existingrepositoriestable. Given that risk, I stopped and flagged it back before starting, and only began implementation after explicit owner go-ahead with three conditions: real-PostgreSQL concurrency verification (not just SQLite), a full disposable-database rehearsal via the existing rehearsal script, and extra-thorough test coverage on the FK-cycle handling specifically. All three are satisfied — see Verification below.The design itself is not new: it follows RFC-0002 and the already-owner-approved migration plan (approved via PR #328), with the
#322rehearsal/recovery prerequisite already closed. This PR is that plan turned into working, tested code — I verified every one of its non-trivial claims empirically before relying on it (see below), rather than assuming the plan alone was sufficient proof.What's new
repository_lineages: owner-scoped canonical key (canonical_source_key+canonical_branch, case-folded GitHub owner/repo),display_name,latest_repository_id,next_sequence,created_at. A partial unique index is both the owner-scoped canonical lookup and the sole arbiter if two imports race to create the same lineage.repositories.lineage_id/.sequence: permanently nullable. Uploads and unresolved-ref legacy GitHub rows are unlineaged standalone imports by design (RFC §4.3/§6), not a transitional state.repository_lineages.latest_repository_id(+id) →repositories.id/lineage_id, andrepositories.lineage_id(+owner_id) →repository_lineages.id/owner_id. Both deferrable/initially-deferred composite FKs — verified empirically (raw SQL, SQLAlchemycreate_all(), and the real Alembic migration) on both SQLite and real PostgreSQL before writing the actual implementation, per the plan's own "if SQLite can't preserve this, stop" instruction.0013_lineage_expandand0014_lineage_constraints(the plan suggested0011/0012, but those numbers were taken by unrelated work — invite tokens, waitlist — that landed ondevsince the plan was written). Revision A creates the lineage table, expandsrepositories, runs a strict deterministic backfill of resolvable historical GitHub commits (grouped by owner + case-folded canonical source + exact ref, sorted bycreated_at/id, UUIDv5-keyed for idempotent reconciliation), verifies every §6.4 invariant, then closes the non-cyclic constraints. Revision B closes the cyclic one. Uploads, unresolved refs, and any URL outside the exact accepted historical grammar stay standalone — nothing is guessed.RepositoryRepository.add_with_lineage: the transactional allocator (RFC §5.2) — find-or-create the lineage, atomically claim the next sequence viaUPDATE ... SET next_sequence = next_sequence + 1, check for a same-lineage duplicate commit, insert, update the latest pointer, commit once. Bounded retry (5, matchingAiConversationRepository's own pattern) on the create-lineage race.RepositoryRepository.delete_with_lineage_update: rolls the latest pointer back to the next-highest surviving sequence before deleting, never decrements the counter, keeps an empty lineage (not garbage collected) so a later re-import never reuses a sequence.RepositoryService.import_github_repositorynow always computes a canonical pair (a resolved ref is guaranteed by this point) and persists through the transactional allocator.import_uploaded_repositoryis unchanged in behaviour — it already never set lineage fields; added a clarifying comment.Two real bugs found and fixed along the way
1. A pre-existing duplicate-detection bug, now fixed. I removed the old pre-clone
find_by_source_revision_for_ownercheck. It matched on(source_url, revision_value, owner_id)with no branch/ref component — structurally impossible to make branch-aware, since the ref isn't resolved until after cloning — and it incorrectly rejected re-importing the same commit under a different branch, something RFC-0002 explicitly requires to succeed. This was already broken before this PR; my new lineage-scoped duplicate check is now the sole, correct authority. Covered bytest_same_commit_is_allowed_in_two_different_branch_lineages.2. A SQLite/Alembic interaction bug, found and fixed.
alembic/env.pynow disablesPRAGMA foreign_keysfor the connection Alembic itself uses to run migrations, SQLite only. Root cause (confirmed by direct reproduction): SQLite refuses to toggle that pragma mid-transaction, and Alembic's own per-migration transaction is already open by the time a revision'supgrade()runs. Revision B's batch-mode recreation ofrepository_lineages(required to add the cyclic FK — SQLite has noALTER TABLE ADD CONSTRAINT) drops and rebuilds a table thatrepositoriesalready has a deferred FK pointing at from revision A; SQLite's deferred-FK bookkeeping doesn't correctly reconcile that recreation against the still-open transaction, so a fully self-consistent final state still fails at COMMIT with a generic "FOREIGN KEY constraint failed" (PRAGMA foreign_key_checkreports zero violations immediately beforehand — confirmed directly). This only reproduced onceapp.core.databasehad already been imported elsewhere in the process, so it silently passed in isolated test runs and only surfaced in a full-suite run. The regression test now forces that registration itself rather than depending on incidental test-file ordering, and I verified it actually fails without the fix by temporarily reverting it. Runtime enforcement for the real application is unaffected — every normal app connection still getsPRAGMA foreign_keys=ON; this only touches the connection Alembic uses while migrating.Verification (the three explicit conditions)
test_repository_lineage_concurrency.py: real threads against a real, separate-connection PostgreSQL database withthreading.Barriersynchronization (matching the existingtest_concurrent_refresh_on_postgres_mints_one_successorpattern). Two different commits racing into an existing lineage get unique consecutive sequences; two first-imports racing create exactly one lineage; two identical commits racing produce one repository and one correctly-rejected conflict with no burned sequence number; a forced duplicate(lineage_id, sequence)is rejected; a forced cross-owner attachment is rejected by the composite FK; a forced cross-lineage latest pointer is rejected by the composite FK; account deletion cascades a user's lineages without touching another owner's. All 7 pass reliably (verified across 5 repeated runs) and clean up every row they create.python scripts/rehearse_migrations.py(SQLite) and--postgres(real, disposable database) both pass: clean upgrade → clean downgrade → re-upgrade, and the representative0004_ai_provider_configsbaseline reaches head.HEAD_REVISION/REQUIRED_HEAD_TABLESupdated for the new head.test_repository_lineage_migration.pyverifies the deferred FK's exact shape (constrained/referred columns,deferrable) after a fresh migration, plus a populated-database backfill test exercising every §6 grouping/exclusion case in one pass (same source/ref groups; different ref/repo/owner separate; a URL variant — mixed-case host,.gitsuffix, trailing slash — still canonicalizes into the same lineage; malformed URL, unresolved ref, and upload all stay standalone; a deterministic timestamp tie breaks on repository id).Full backend suite (
pytest, no-k) green on both SQLite and real PostgreSQL.ruff check/ruff format --check/mypyclean on every changed file.npm run generate:api-contract -- --checkconfirms zero frontend contract drift — matches the plan's "no API contract change, no frontend surface" scope exactly.Docs updated: the migration plan's revision-ID note now reflects the actual
0013/0014numbering, and the rehearsal runbook's stated head revision is current.