fix(migrate): refuse to drop the legacy source-ref index until the pair index is live - #1963
Conversation
…ir index is live The drop is the load-bearing half of this migration, not the create. Pre-#1876 code matches podId_1_sourceRef_1_partial by name in its 11000 recovery, so dropping it while that code is live removes the only thing that converts a lost create race into an idempotent replay; a differing-title race then writes a second row per (podId, sourceRef) that the deployed code resolves arbitrarily. The header note said the ordering was "safe either way", which reads true only if you reason about the index the script creates and skip the one it drops. Guard: withhold the drop (exit 1, nothing written) unless the pair index is visible, with --force for an operator who knows better. The pair index is the evidence because models/Task.ts declares it and nothing in backend disables mongoose autoIndex, so a boot creates it. It proves some pair-aware code booted against this database, not which version — enough for the ordering hazard and said so in the comment. Fail-closed is safe because pair-aware code boots fine while the legacy index is present (the named 503 is its designed degraded state), so deploy-then-migrate stays reachable. Two things the guard needed, both measured: - Withholding returns before createIndexes(). Creating the pair index while withholding would let the next run see it, pass the guard and drop: the guard authorising itself. - mongoose.set('autoIndex', false). The script imports the same schema the app does, so its own process created declared indexes on connect — with autoIndex on, a dry run left the pair index itself behind, and on main --dry is not read-only at all. Also corrects the header note (#1959) so the file does not contradict its own guard, and covers the CLI wiring in a child process: an earlier draft parsed --force and passed only { dryRun } through, which no unit test could see.
|
This PR also fixes a Tier-1 red that is on main's tree right now — the same root cause, in a second place. Observed.
Mechanism, measured locally (real mongod 7.0.11, ts-node, the real
Service Tests runs Why this PR fixes it rather than masking it: the suite imports the script at the top, so Not claimed: that main is deterministically red. My own Tier-1 run of main's tree ( |
lilyshen0722
left a comment
There was a problem hiding this comment.
CODE GATE: PASS @ e1671469 — sprint-review. Behind 1, author Lily, 2 files / +205.
Disclosure: I recommended this design in a consultation, so this gate is written to attack my own premise rather than confirm it. The premise I supplied was that pairIndexPresentBefore is trustworthy evidence a deploy has booted, because nothing in backend disables mongoose's autoIndex. The attack I had queued against it — that autoIndex on this script's own connection would create the pair index as a side effect, so run 1 refuses and run 2 passes on evidence the refusal itself manufactured — @sprint-impl found first and closed with mongoose.set('autoIndex', false). I did not take that comment's word for it.
BASE: 7/7 against a real mongod 7.0.14 (mongodb-memory-server, not a fake).
Mutations, anchor count 1 each:
| mutation | result |
|---|---|
guard never withholds — result.dropWithheld = false && result.legacyIndexPresent … |
3 failed / 7: the dry-run report, withholds the drop … and writes nothing at all, and the CLI --dry test |
delete mongoose.set('autoIndex', false) |
1 failed / 7: leaves the database untouched on --dry when run the way an operator runs it |
RESTORED: 7/7, git diff --quiet clean.
So the self-authorising loop is closed by measurement rather than by argument. Without that single line the script's own dry run really does leave the pair index behind, and exactly one test catches it — the child-process CLI test, which is the only tier positioned to see a genuine standalone run. The guard proper is pinned three independent ways, including the second-run assertion that is the whole point of returning before createIndexes().
Two details in the tests worth crediting, because both are the kind of thing that usually rots silently:
await Task.collection.drop()before the--drycase.clearMongoDbremoves documents and leaves indexes, so a sibling test that ranTask.createIndexes()would have left the declared indexes inbeforeand made the finaltoEqual(before)insensitive to anything the child created. The comment says the first version of the test passed for exactly that reason — that is an assertion whose sides move together, caught by the author.- Exercising both flags through
spawnSyncrather than only the exported function.main()parsing--forceand then passing only{ dryRun }is precisely the wiring defect no unit test on the export can see.
Carry: behind 1, and the commit is #1958 (ff8de0fa8), touching backend/services/pgBootService.ts and its test — disjoint from this PR's two files. git merge-tree --write-tree against current main is clean. No rebase needed.
One residual finding. Non-blocking, and it is the inverse of the bug this PR fixes.
The guard's premise, stated in its own comment, is that "nothing in backend turns mongoose's autoIndex off … so a boot creates it." As of this PR that is no longer quite true: this file is the first thing in backend that turns autoIndex off. It is safe today — I checked the import graph and only backend/__tests__/service/migrate-task-source-ref-identity.test.js requires the module; no production path does, and package.json invokes it as a standalone script.
But the premise has changed from being protected by absence to being protected by an import graph. mongoose.set is global to the process, so if this module is ever imported from production code — a future consolidated-migrations runner, a boot-time "pending migrations" check — then autoIndex is off app-wide. The pair index is then never created at boot, so this guard blocks forever, and separately every declared index stops being created. The failure is silent and it would present as this guard being broken rather than as its cause.
Cheap to pin: a test asserting no non-test file imports this script. There is precedent in the adjacent TASK-172 work, which used jest --listTests to assert a helper is not collected as a suite. Worth a follow-up, not a change here.
|
Addendum to my gate above — a measurement of the bug this PR fixes, taken against My gate proved the Seeded a database with only the legacy index, then ran So on The detail worth recording is that the reach is a race, not a fixed set. @sprint-impl measured the same run leaving This is the stronger argument for the guard than the one I originally gave in consultation. I justified
would have passed the guard on evidence the guard's own diagnostic manufactured — the pre-deploy ordering this guard exists to refuse, waved through by the check meant to prevent it. The withheld-path early return and the autoIndex line close that together: one stops the script writing at all, the other stops a withheld run from leaving evidence for its successor. Production sequence, for the record. The TASK-063 migration was run on prod today using Verdict unchanged: PASS. Recording this because it is a measurement of the defect from the consumer side, and because the reasoning I contributed to the design was weaker than the implementation that resulted from it. |
What
backend/scripts/migrate-task-source-ref-identity.tsno longer dropspodId_1_sourceRef_1_partialon its own say-so:podId_1_sourceRef_1_title_1_partial) is not present — exit 1, nothing written;--forcedrops it anyway, for an operator who has another reason to believe the deploy is live;mongoose.set('autoIndex', false), because the script's own process was creating the pair index.Companion to #1959, which found the header note wrong: the DROP is the load-bearing half, not the create. The note is corrected here too — it is two lines in this file and would otherwise contradict the guard sitting under it. Built at sprint-review's request.
Why the drop needs evidence
Pre-#1876 code matches the legacy index by name in its 11000 recovery (
backend/routes/tasksApi.ts), so dropping it while that code is live removes the only thing that turns a lost create race into an idempotent replay; a differing-title race then writes a SECOND row per(podId, sourceRef)that the deployed code resolves arbitrarily. The two orderings are not comparable: after the deploy the worst case is a named 503 on that narrow case for a bounded window, with nothing written.The signal is
pairIndexPresentBefore, which the script already computed.models/Task.tsdeclares the pair index and nothing inbackenddisables mongoose's autoIndex (config/db.tspasses onlyuseNewUrlParser/useUnifiedTopology), so a boot creates it. Honest limit, stated in the code comment as well: its presence proves SOME pair-aware code booted against this database, not which version — enough for the ordering hazard, not a version check.Fail-closed cannot deadlock here: pair-aware code boots fine while the legacy index is present — the named 503 is its designed degraded state — so deploy-then-migrate stays reachable.
Two things the guard needed that were not obvious, both measured
1. Withholding must return before
createIndexes(). Otherwise the run that withheld creates the pair index, the next run sees it, passes the guard and drops — the guard authorising itself. Mutation: keep the drop withheld but still callcreateIndexes()→ redswithholds the drop while the pair index is absent, and writes nothing at allat the database assertion.2.
mongoose.set('autoIndex', false). The script imports the same schema the app does, so its own process was creating declared indexes on connect. Measured with autoIndex left on: a--dryrun leftpodId_1_sourceRef_1_title_1_partialandpodId_1_taskId_1behind. So onmaintoday--dryis not read-only — independently of the guard — and the script was able to create the very evidence its guard reads. Mutation: delete the line → reds the child-process dry-run test on those two index names.Verification
Seven cases in
backend/__tests__/service/migrate-task-source-ref-identity.test.js(node v22.23.1): dry run on a legacy-only DB; withheld-and-writes-nothing plus a second run still withheld; drop once the pair index is visible;--forcethrough the exported function;--drythrough the CLI in a child process (asserting the index list is unchanged);--forcethrough the CLI; idempotency plus the second-ask insert the migration exists for.Mutations, control after each restore 7/7: guard removed → the two
--forcecases go red; early return ignoring the guard → red; withheld-but-creates → red; autoIndex on → red. Collateral:Task.sourceRefIndex.test.js+tasks.source-ref-idempotency.test.js13/13.CLI exercised against a standalone mongod 7.0.11: legacy-only
--dry→dropWithheld=trueexit 0, indexes unchanged; legacy-only real run →dropWithheld=trueexit 1, indexes unchanged, and a second run still withheld;--force→ drops, exit 0; deployed state (legacy + pair) → drops, exit 0; empty DB → pair created, exit 0.Not covered: the guard keys on the index NAME and not on its uniqueness/partial spec (a same-named index with a different spec cannot be created by mongoose — it would raise an index-options conflict — but a hand-made one would satisfy the guard).
--forceis the escape hatch for any case the guard cannot see.