Merge the accounting sequence twin counters at the production cutover
AI-generated content
This document was generated by an AI assistant. Verify accuracy before relying on the details.
adb-accounting.accounting_sequence holds the continuous journal-entry number (FEC field #3) and the per-event-type piece-reference chrono (FEC field #9). Each counter exists under two spellings of the organisation field: the legacy organizationId, written until about January 2025, and organisationId, which the JVM has written since about March 2025 (CustomSequenceRepoImpl.java:32). When both exist for one counter, whichever one is continued can re-issue numbers that are already used. migrate-accounting-sequence-org-spelling.mjs renames every counter to organisationId, merges each pair keeping the highest value, and then creates the two unique indexes that stop a counter from ever forking again. Run it once, against production, at the cutover, with the JVM accounting writes quiesced: after the production database is final, and before the Cloudflare accounting Workers serve traffic. It is step 2 of the production order in Data migrations: migrations 0002 and 0004 follow it. Its --apply is race-safe since 2026-10-10: every write is conditioned on the read it was planned from, a counter is only ever raised ($max), a write that finds its document changed makes it re-read and re-plan, and a canonical EJSON backup of the collection is written before the first write.
At a glance
| Fact | Value |
|---|---|
| Script | services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs (tests: services/accounting/test/migrate-accounting-sequence-org-spelling.test.mjs, the race scenarios on packages/data/test/fake-index-mongo.mjs) |
| When | production cutover, JVM accounting writes quiesced: after the production database is final, before Cloudflare accounting serves traffic; before migrations 0002 and 0004 |
| Runs as | an engineer, from a workstation, from the repository root with the workspace installed (it takes the mongodb driver of tools/scripts). Not from a Worker, not from CI |
| Target | database adb-accounting, collection accounting_sequence only |
| Default mode | dry-run. --apply is the only flag that writes |
| Production | a production cluster (an unknown host counts as one) needs --i-know-this-is-prod and its host typed back on a terminal, before any connection — the question pnpm migrate … production asks |
| Writes | renames the field, merges twins (raises the survivor to the MAX with $max, deletes the loser), creates uniq_accounting_sequence_org_year and uniq_accounting_sequence_org_eventtype; every write conditioned on the document as read, MAJORITY |
| Backup | --apply writes the collection as canonical EJSON (types kept) before its first write, to ~/accounting_sequence-backup-<host>-<time>.ejson (--backup-dir to choose), mode 0600, never overwriting; no backup, no write |
| Idempotent | yes. A second run reports renamed: 0 merged: 0 |
| Refuses | a British/British pair (merged at MAX, but no index, exit 1); a collection that keeps changing for 4 rounds (no index, exit 1, every conflict listed); an index build the server refuses (exit 1); an unknown option (exit 2) |
| JVM compatibility | the JVM reads and writes organisationId only (CustomSequenceRepoImpl.java:32). A JVM write during the run is never lost (conditioned writes, $max, re-plan), but a busy counter can exhaust the rounds, and a JVM upsert in the millisecond between a rename's check and its write leaves a British/British pair (detected, no index): quiesce the JVM accounting writes |
| Recorded | not in adb-system._migrations (a record without a migration file makes pnpm migrate refuse); pnpm migrate's 0002 preflight checks the result from the data |
| Rehearsed | staging, 2026-10-06 (the apply before race safety): 140 counters → 75 renamed, 12 merged, 2 indexes; second run 0/0 |
Steps
Run every command from the repository root (workspace installed: pnpm install --frozen-lockfile), in a terminal, with PROD_MONGO_URI exported in the shell. Never paste the URI into a file, a ticket or a command argument: the script reads it from MONGO_URI, set for the one command. Each command prints the cluster as scheme + hosts and asks you to type the host back.
Dry-run against production. It writes nothing. Read the plan: every
MERGEline must keep the largersequence, and the run must end withoutERRORS.MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prodQuiesce the JVM accounting writes, then apply, once. The apply writes its backup first and prints its path (
BACKUP ~/accounting_sequence-backup-….ejson), then works in rounds: each round re-reads the collection and applies the plan of that read, one conditioned write at a time; aCONFLICTline means a document moved under it, and the next round re-plans. It ends withresult: APPLIEDand the two indexes, or says why not (exit 1).MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prod --applyVerify. The next dry-run must print
renamed: 0 merged: 0, and the index listing must show both unique indexes. Thenpnpm migrate up production --only 0002 --cutover --i-know-this-is-prodmust show every starred preflight count at 0 (Data migrations).MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prodmongosh "$PROD_MONGO_URI" --quiet --eval 'print(db.getSiblingDB("adb-accounting").accounting_sequence.getIndexes().map(i=>i.name).join(", "))'
Rollback
The merge deletes the losing twin of each pair. To restore the exact pre-migration state, reload the backup the apply printed; --drop also drops the two new indexes:
mongoimport --uri "$PROD_MONGO_URI" --db adb-accounting --collection accounting_sequence --drop --jsonArray --file ~/accounting_sequence-backup-<host>-<time>.ejson
Do it only if the plan was wrong: once a new number has been issued after the migration, restoring the backup would let that number be issued again.
Details
- Why MAX. The higher counter of a pair is the one whose numbers have been issued furthest. Keeping the lower one would re-issue every number between the two.
- Race safety. Until 2026-10-10 the apply read once and wrote
$set: { sequence }/deleteOne({ _id })without checking what it had read; reproduced on the indexed fake, a JVM increment after the read was lowered back (2000 → 1102), a twin continued after the read was deleted, and a JVM upsert before a rename left a British/British pair. Now each write's filter pins the document as read (_id,sequence, both spellings,year/accountingEventType); the survivor takes$max; a rename, or an American survivor turning British, is held while a British counter exists for its key; a write that matches nothing ends the round, and the next one re-reads and re-plans (at most 4 rounds). The run converges when a fresh read has nothing left to do; the indexes are built only then. The remaining window — a JVM upsert in the millisecond between a rename's check and its write — leaves a British/British pair that the next round merges at MAX and reports (ERRORS, no index, exit 1). - Code safety net. Until the migration runs, the Cloudflare allocators (
getNewSequence,nextPieceReferenceinpackages/data/accounting/sequence/mutations.mjs) read both spellings and sort bysequencedescending, so they always continue the higher twin. The JVM does not: it only readsorganisationId, so on a database with twins it keeps continuing the newer, usually lower, counter. - Evidence on staging, 2026-10-06, read-only before the migration. There were 66 piece references shared by distinct event families (children legitimately share their parent's reference, so the count is per family). All of them were created between October 2024 and July 2025, and none since June 2026. Example: incoming supplier invoices had counters at 10 (
organizationId) and 16 (organisationId), and the 10 overlapping numbers are exactly the 10 duplicates. ensureAccountingIndexes()(packages/data/accounting/indexes.mjs) is not called anywhere at runtime. The migration creates the two sequence indexes itself.accounting_eventandinvoicesalready carry theirunique_1index from the JVM-era scripts.
Open questions
- The race-safe apply has been run on the indexed fake and as a dry-run on staging (2026-10-10), not as an apply on a real cluster.
- Production has not been probed. Numbers that were already issued twice stay duplicated after the migration; whether the FEC needs a correction or a documented note is for the accountant to decide.