Repository navigation
ci(schema-parity): an install built by the merge-base, upgraded by the PR, must end at schema.py (#3395) - #3974
Conversation
…e PR, must end at schema.py (#3395) test_schema_parity.py models a fresh install only. On that path a migration whose target table does not exist yet raises `no such table`, which the runner skips as a fresh install, and init_schema then creates the table with every column schema.py lists. So a schema.py column whose migration was never registered (or never written) is present on every fresh install and missing on every existing one, and no fresh-install comparison can see it. 213 of the 306 ADD COLUMN sites in db/migrations.py target a table only init_schema creates (schedule_executions, users, chat_messages, ...), so a reverse diff on the fresh path, as the issue proposes, is blind on 70% of them. scripts/ci/check_sqlite_upgrade_parity.py models the existing install: it resolves the merge-base of the PR's base and head, git-archives the merge-base's src/backend, runs that tree's lifecycle (migrations, init_schema, migrations) on a temp file DB in its own interpreter, then runs the checkout's lifecycle on the same DB in a second interpreter, and requires every table, column, index and trigger the checkout's init_schema declares to exist, with migration_health at (n, n, None). Pure stdlib. Exit 0 pass, 1 gap (each object named; a head lifecycle that raises on the upgraded install counts), 2 could not run (no base/head, no merge-base, the base tree failing to build its install) - never a silent pass. tests/unit/test_sqlite_upgrade_parity_guard.py drives the comparison core over synthetic two-file backends (no git, no backend deps), so it runs in the unit tier: registered migration passes; written-but-unregistered and no-migration-at-all are gaps naming t.b; a new init_schema-only table is no false red; an unbuildable base exits 2. Plus head-lifecycle-raises, unrecorded-migration, the git fail-closed paths, and main() over a throwaway git repo. Registered in tests/registry.json. Mutation (the issue's exact class): replay PR #3913 (17adf5a against 17adf5a^) with `("a2a_caller_identity", _migrate_a2a_caller_identity),` deleted from MIGRATIONS while the function and the schema.py column stay: exit 1, "column schedule_executions.a2a_caller_identity: ... never gets it on upgrade". With the function deleted too (R1'): exit 1, same column. As shipped (R0): exit 0, 1 migration newly recorded, (170, 170, None). dev against its own merge-base: exit 0. A PR adding an init_schema-only table plus index on dev (N1): exit 0, 93 tables / 199 indexes.
…a-relevant PR (#3395) One step in the existing schema-parity job, after the dependency install (the merge-base's lifecycle runs under them) and behind the job's self-skip, on pull_request only (the merge-base comes from the PR's base/head SHAs; the checkout already uses fetch-depth: 0). Same dummy env as the parity pytest. The script and its self-test join the change-detection regex and push.paths, the way check_alembic_parity.py is listed. The job id, and so the required check name `schema-parity`, is unchanged. Header comment gains one paragraph. Mutation: see the previous commit - the PR #3913 replay with its MIGRATIONS line deleted exits 1 naming schedule_executions.a2a_caller_identity.
…does and states its limits (#3395) Review fixes on the guard and its self-test. Guard: - The lifecycle connection uses row_factory = sqlite3.Row, as db/connection.py::get_db_connection does, so a migration that reads a row by column name is not reported as a boot failure. - A checkout whose db/schema.py or db/migrations.py does not import is "could not run" (exit 2), not a schema gap. - Paths are resolved, tar extraction errors exit 2, and a single positional argument is a usage error rather than being silently ignored. - The base-failure message says what to do (land a dependency bump on its own; update an old branch). - The docstring states the limits it had left implicit: the install it builds has no rows (init_database's seeding steps are not run, so backfills, rebuild copies and constraints on existing rows are not exercised); only missing objects are reported; the ledger check is stricter than /health on purpose (a renamed migration re-runs, a removed one keeps being counted). The 213-of-306 count names its method. Self-test (11 -> 22 tests): an index, a trigger and a table dropped by a migration after init_schema; a renamed migration; a duplicate registration; the restated order (migrations before init_schema, the second pass); rows by name; a head that does not import; relative paths; a one-argument call. Twelve guard mutations (each finding loop, both ledger checks, the first_pending check, the second pass, the order, base failure -> pass, merge-base -> head, head import -> gap, no row_factory) each turn at least one test red; before this commit seven of the first ten survived.
…iggered by (#3395) The self-test was in push.paths and the self-skip regex but not in the parity pytest command, so a PR touching only it ran the job without running it. It now sits next to test_alembic_parity_guard.py on that line. The test also runs in backend-unit-test, as before.
…it (#3395) When the merge-base's own lifecycle raised, the upgrade-parity guard exited 2, so the PR that repairs a broken dev (whose merge-base is that broken commit) was blocked by a required check, with branch protection the only way out. The guard now walks back the merge-base's first-parent ancestors, at most MAX_BASE_WALKBACK (20), to the newest one that builds, upgrades from it, and warns naming every commit skipped. Not fail-open: the head still has to upgrade over the longer range. Exit 2 stays the answer when the bound is exhausted. Also from review: the stated limits and workflow header say the guard runs the open-source runner only, never the enterprise one; database.md and Invariant #3 name the guard; the script is executable (ruff EXE001); the exhausted-walk message no longer suggests the circular "land the bump first" order.
|
Review pass (fresh reviewer, 2026-10-27), each finding resolved in 9e83235:
Broken-dev scenario: pre-fix guard exit 2, fixed guard exit 0 with the warning naming the skipped commit. Self-tests 26 passed (3 red against the pre-fix guard). Guard against current dev: exit 0, same counts. R1 mutation still exit 1 with the same message. |
dolho
left a comment
There was a problem hiding this comment.
/validate-pr — Lane C (.github/workflows/schema-parity.yml)
| Check | Result |
|---|---|
| Commits / issue | conventional; Closes #3395 (this slice closes it; #3972 and #3973 say Part of) ✅ |
| Workflow change | one PR-only step behind the existing self-skip; job id (required context schema-parity) unchanged; SHAs via env: only; pull_request, not pull_request_target; dummy secrets only ✅ |
| Script safety | subprocess.run with argv lists, no shell; tar.extractall(..., filter="data"); merge-base is an ancestor of dev, so the old tree it executes is trusted ✅ |
| Docs | architecture.md Invariant #3 gains one clause; architecture/database.md a paragraph ✅ |
| Secrets / host paths / file modes | none (the script is now +x, intended) ✅ |
| Self-test on the PR head | test_sqlite_upgrade_parity_guard.py: 26 passed ✅ |
Guard on the PR head vs dev f754dfb |
exit 0; merge-base 40b3295; 92 tables / 1111 columns / 198 indexes / 2 triggers; (170, 170, None), the numbers the PR states ✅ |
| Mutation, reproduced here | added zz_probe TEXT to schema.py's users with no migration → exit 1: "column users.zz_probe: schema.py declares it, but an install that already has users never gets it on upgrade. Write the migration…" ✅ |
| CI | 18 pass / 6 skipped; schema-parity green in 4m06s with the new step ✅ |
Execution coverage (§5.4)
The guard executes two real lifecycles (the merge-base tree's, then the checkout's) on a file database and compares live PRAGMA reads, and CI runs it on every schema-relevant PR. Its self-test drives the comparison core over synthetic backends. Both execute the path rather than read source.
Non-blocking
MAX_BASE_WALKBACK = 20is a judgement call, as you note. Ifdevis ever broken longer than that, the exit-2 message already says what to do.- F5 (a data-bearing base install) and F6 (the self-skip regex missing the modules migrations import) are the right follow-ups; F6 is the one I'd pick up first, since it silently skips both this guard and the parity pytest.
APPROVE.
🤖 Reviewed with Claude Code
# Conflicts: # tests/registry.json
|
@dolho thanks. This merged with the train before I got to your notes, so here is where each one landed:
|
Closes #3395. This is one of three independent slices and the one that closes the issue. The other two are the fresh-install reverse-parity assertions in
tests/unit/test_schema_parity.py(slice A) and theMIGRATIONSregistry bijection intests/unit/test_migrations.py(slice C). The three share no file and merge in any order.The finding: the issue's own check is blind on 70% of
ADD COLUMNsitesThe issue proposes adding the reverse direction (
schema − production) totest_schema_parity.py. That test models a fresh install only: an empty database throughrun_all_migrations→init_schema→run_all_migrations.On that path, a migration whose target table does not exist yet raises
no such table. The runner skips that as a fresh install (migrations.py:170-174).init_schemathen creates the table with every columnschema.pylists, including the one whose migration was never registered. So the fresh database is complete and the reverse diff stays clean, while every existing install never gets the column.That holds for every table only
init_schemacreates and no migration does. There are 31 such tables of 92, among themschedule_executions,users,chat_messagesandmcp_api_keys. 213 of the 306ALTER TABLE <t> ADD COLUMN/_safe_add_column(cursor, "<t>", ...)sites indb/migrations.pytarget one of them (counted on dev at 40b3295). The issue's headline case is on one of them:schedule_executions.a2a_caller_identity, from #3913.What changed
scripts/ci/check_sqlite_upgrade_parity.py(new, pure stdlib) models the existing install instead. Steps:git archivethe merge-base'ssrc/backend. In its own interpreter, run that tree's lifecycle on a temporary file database. This builds the tables, indexes andschema_migrationsledger the merge-base would have left.init_schemadeclares exists in the upgraded database, and thatmigration_healthis(n, n, None).Each missing object is named with what to do. Exit codes:
0: pass.1: gap. A head lifecycle that raises on the upgraded install also counts here, because every existing install would crash-loop the same way.2: the check could not run. Causes: no base/head, no merge-base, the merge-base tree failing to build its install, or the checkout's db modules not importing. Exit 2 never reads as "no drift".Using the merge-base as the baseline means nothing is frozen and nothing goes stale. A table added since any release is covered as soon as it exists on
dev. The pattern is the onecheck_alembic_parity.pyalready uses: compare the PR's base and head.tests/unit/test_sqlite_upgrade_parity_guard.py(new, 22 tests) drives the comparison core over synthetic two-file backends:db/schema.pyplusdb/migrations.pyintmp_path, with no git and no backend dependencies. It runs in the unit tier on every PR. The five cases the guard exists for:t.b;schema.pycolumn with no migration at all is a gap namingt.b;init_schema-only table is not a false red;The other tests cover:
init_schema(an index, a trigger, a table);init_schema; the second pass);sqlite3.Rowasget_db_connectiondoes;main()end to end over a throwaway git repository..github/workflows/schema-parity.ymlgains one step, "SQLite upgrade parity (merge-base → head)".pull_requestonly, behind the job's existing self-skip, after the dependency install, with the same dummy env as the parity pytest. The checkout already usesfetch-depth: 0.push.paths.schema-parity, is unchanged.tests/registry.jsongains one entry for the self-test.No docs: this is a CI and test change on the bug-fix tier.
docs/memory/learnings.md:1238("do not count ontest_schema_parity… one-directional") is a dated ledger entry and is left as it is. Its instruction still holds.Stated limits (also in the script's docstring)
NOT NULL, keys and index/trigger SQL are not compared (F2).init_database's seeding steps are not run. So a backfill, a rebuild's row copy, or a constraint an existing row would violate is never exercised (F5)./health. A renamed or removed registered migration is red: a rename re-runs it on every existing install, and a removal keeps being counted by/health.Follow-ups
init_schema-only table needs no SQLite migration, socheck_alembic_parity.pynever forces its Alembic revision. That script's docstring claim ("caught … by the existing schema-parity pytest") becomes true for a column on an existing table and stays false for a new table.init_databaselifecycle order, for example by running the realinit_database()in a subprocess.services/credential_encryption.py,services/secret_settings.py,services/platform_alerts.py,config.py, and whatservices/__init__.pyimports). A PR changing only those skips both the parity pytest and this guard. Today the unit tier's conftest still catches a fresh-install crash there, because it runsinit_database()at load.Review and security
An independent code review (of the code, not this description) and a diff-scoped security review ran on the slice.
Findings fixed in
3900b7016,a2d972094andf1b533aac:row_factory. Production usessqlite3.Row.Declined, with reasons:
.gitattributes export-ignoreundersrc/backend. None exists today.grep -c.grep -ccounts lines; the docstring now names the counting method.Filed as #3979 (the no-rows install) and #3980 (the self-skip regex scope).
Security review of the diff: no findings.
env:only; git is called with argv lists, never a shell string.pull_request, notpull_request_target, undercontents: read, with dummy secrets only.devcode.filter="data".--offlinereports the same 3 findings on this workflow before and after. All are pre-existing lines (actions/checkout@v7andactions/setup-python@v7not SHA-pinned;persist-credentialsdefault).Journey Impact: none: CI and test infrastructure only. No runtime code, endpoint, UI, migration or boot behaviour changes, so no user or operator journey is affected.
Validation: guard on this branch vs
origin/dev(merge-base 40b3295) → exit 0, 92 tables / 1111 columns / 198 indexes / 2 triggers,migration_health = (170, 170, None). The same holds with the branch merged into the currentdevtip f754dfb (the CI shape). The same holds underpython:3.13-slim(Python 3.13.17, SQLite 3.46.1, deps fromtests/requirements-test.txt, worktree and.gitmounted read-only); in that container, R0, R1, R1′ and N1 give the results in the Mutation line, and the self-test gives 22 passed (measured before the review fixes). N1 (a fresh install of a table the PR adds:n1_probeplus an index,init_schemaonly, no migration, on dev) → exit 0, 93 tables / 199 indexes, no false red. A real table-adding PR (5034304,room_gate_holds, migration-created) → exit 0. The reviewer independently ran older dev bases (1 week, 3 weeks, 6 weeks, 3 months; up to 71 migrations newly recorded) → all exit 0. Brokendev(review finding 1): commit A registers a migration that raises; commit B reverts it as the fix PR. Before the fix,guard A B→ exit 2, CANNOT RUN. After it → exit 0, with a WARNING naming A as skipped and the install built by A's first-parent ancestor.pytest test_sqlite_upgrade_parity_guard.py test_schema_parity.py test_migrations.py test_alembic_parity_guard.py test_2338_journey_catalog.py→ 122 passed (-p no:randomly, Python 3.12). Before the review fixes, the same set gave 118 passed under pytest-randomly seeds 11, 222 and 3333.tests/lint_sys_modules.pyandtests/lint_root_test_placement.pypass. ruff (EXE001 gone, the script is now executable) andmypy --strictare clean on the script. actionlint is clean.Mutation: replay PR #3913 (17adf5a against 17adf5a^, merge-base 0742886) with
("a2a_caller_identity", _migrate_a2a_caller_identity),deleted fromMIGRATIONSwhile the function and theschema.pycolumn stay (R1) → exit 1, "column schedule_executions.a2a_caller_identity: schema.py declares it, but an install that already has schedule_executions never gets it on upgrade",(169, 169, None). The same replay with the function deleted too (R1′) → exit 1, same column; slice C cannot see this one, because no function exists. As shipped (R0) → exit 0, 1 migration newly recorded,(170, 170, None). R0 and R1 were re-run after the review fixes, with the same results. Today's suite and slice A stay green on R1 (measured at planning). Reviewer: deleting("held_gate_turns", …)against a 1-week-old base → exit 1 namingenterprise_portal_messages.gate_holdandpublic_chat_messages.gate_hold. Self-test: 12 guard mutations, each red in at least one test. They cover each missing-object loop, both ledger checks,first_pending, the second pass, the lifecycle order, base failure → pass, merge-base → head, head import → gap, and norow_factory. Walk-back: with the four new self-tests against the pre-fix guard, 3 failed / 23 passed.walks_back_to_the_newest_ancestor_that_buildsandstill_finds_a_gap_over_the_longer_rangegot exit 2.is_bounded_and_fails_closed_when_exhaustedhit a missingMAX_BASE_WALKBACK. Against the fixed guard, 26 passed.Unverified:
pull_requestmerge ref. Run 40407332949, at checkout339c1194d Merge f1b533aac into 40b329593, gavePASSwith the same 92 / 1111 / 198 / 2 counts and(170, 170, None), and the self-tests ran in the parity pytest step. That was the pre-review head. The walk-back added in review is shown locally only (scratch clone and self-tests); no real brokendevwas observed on GitHub Actions.tests/unittier was not run on this branch alone; it runs once on the integration branch for the batch of slices..claude/agents/test-runner.md(the private submodule's test catalog) was not updated. This PR leaves gitlinks untouched.