FEAT-024: Production-Copy Parity Rehearsal¶
This rehearsal restores a copy of the production SyRF database into the isolated local e2e stack, builds every materialised statistics family for every project, and judges exact parity family by family. It tests the real, older data shapes that synthetic soak traffic cannot produce. Chris approved it on 2026-10-05. It runs on Bramble only. Production itself is never touched: the copy is a local MongoDB container with no network route to Atlas.
Flag decision: none. This is local test tooling (e2e/): it changes no product code, no
configuration of any deployed environment and no flag default. The --feat024-prodcopy mode of
e2e/run-local.sh is opt-in.
The data and where it may be¶
An Atlas scheduled snapshot of Cluster0 is the whole production cluster. Besides the SyRF database
(syrftest, about 10.8 GB on disk) it holds the other databases of the cluster (syrfdev, c3rs,
email-logs and others) and the Atlas database users. It contains personal data: investigator
names and email addresses, project names and membership, study titles, abstracts and authors,
screening decisions, annotation answers and reviewer notes, and email logs.
Therefore:
- Download it yourself in the Atlas UI. Never share Atlas credentials or a download link with anyone, an agent included.
- Keep it on Bramble only, at
~/.syrf/backups/. Never copy it to Juniper (a shared, multi-user machine), never into a repository checkout (the tooling refuses), never to cloud storage. - Delete it afterwards, together with the restored copy (step 6). The run keeps only pseudonymised evidence.
What the tooling guarantees¶
| Guarantee | How |
|---|---|
The copy is never named syrftest |
The restore renames syrftest to syrf_prodcopy (or E2E_PRODCOPY_DB) and refuses syrftest in any case, syrf_e2e (dropped by every e2e start) and MongoDB's own names. The product's production refusals key on syrftest, so they stay meaningful. |
| Only the SyRF database is kept | Every other database of the snapshot is dropped, and so are the replica-set local database and the Atlas users and roles. |
| The restore can reach nothing | Each restore container runs with --network none; the snapshot is mounted read-only. |
| Nothing listens beyond this machine | Every container port is published on 127.0.0.1 only (e2e/docker-compose.prodcopy.yml) and the API and project-management bind to localhost. Quartz is not started. |
| Nothing leaves this machine | No seeding, no mail recipients (RestrictEmailToDev with no dev address), no Sentry or APM, and AWS clients get LocalStack and dummy credentials, never a profile from this host. |
| Only this stack's copy is used | The harness refuses any MongoDB but 127.0.0.1 on this stack's port, the configured non-production database, with the restore's marker present and no syrftest database on the server. run-local.sh refuses ConnectionStrings__MongoConnection__* settings in the environment that could point elsewhere. |
| The administrator is synthetic | The harness signs in through the E2E sign-in seam as a synthetic administrator (feat024-prodcopy-admin@syrf-rehearsal.invalid); the sign-in creates its Investigator inside the local copy only. No real account is used. |
| Evidence holds no personal data | Every evidence record is checked against an allowlist before it is written: pseudonymised project ids (p- and 12 hex characters of a keyed hash), counts, verdict codes, durations and metric key names. An unknown field, free text, an email address or anything that looks like an id is refused, and nothing is written. |
| Run-time files are private | Service logs, launchers, the allowlist and the private run state are created mode 0600 (directories 0700). |
Before you start (Bramble)¶
- A worktree of this branch (or
mainonce merged), built once byrun-local.sh(or with--skip-buildafter a previous build). - Disk (estimate, not measured on production data): the tarball itself, plus about three times
the snapshot's unpacked size free on the Docker data root while the restore runs. A cross-database
rename copies each collection before dropping the source, so the peak is the unpacked snapshot plus
its largest collection. Allow 60 GB free; after the restore the copy settles at roughly the
10.8 GB of
syrftestplus a 2 GB oplog and the statistics rows. - Memory (estimate): the restore's mongod uses a 4 GB WiredTiger cache by default
(
E2E_PRODCOPY_WT_CACHE_GB; 8 to 16 on Bramble speeds the rename and the large projects). The running stack needs that cache plus about 1 to 2 GB each for the API and project-management and 3 to 4 GB for RabbitMQ, LocalStack, SQL Server and mock OIDC. Plan on 16 GB free. - Time (estimate): extraction a few minutes; the rename 15 to 60 minutes for about 11 GB; stack
start-up 2 to 5 minutes (longer once if the build adds indexes the copy does not have yet; the health
wait allows 30 minutes). The rehearsal itself took 1 to 14 seconds per synthetic project of 8 to 32
studies (about 20 HTTP calls each); most production projects are small, and the largest are
dominated by their backfills and parity audits. Calibrate with the
--limit 20pilot in step 3, then expect hours rather than minutes for every project. - MongoDB versions. Bramble runs the stack on
mongo:8.2(E2E_MONGO_IMAGE=mongo:8.2; MongoDB 8.0.21 and later refuse its kernel version string;e2e/scripts/setup.shapplies the variable through thee2e/docker-compose.mongo-image.ymloverlay when it is set, see the staging-proof runbook). A MongoDB 8.2 binary opens data at feature compatibility version 8.0 or 8.2 only. Note the cluster's MongoDB version in the Atlas UI when you download: - 8.0 or 8.2: nothing to set; the stack image opens it.
- 7.0: the restore opens it with an 8.0 binary and raises the copy's compatibility version to
8.0. The restore image is pinned to
mongo:8.0.20, the last 8.0 build before the kernel check, not the floatingmongo:8.0, so nothing needs setting (mongo:8.0.20is untested on Bramble; if it also refuses to start, setE2E_PRODCOPY_RESTORE_IMAGEto another build and report back).E2E_PRODCOPY_RESTORE_IMAGEalways overrides the pin.
Step 1: download the snapshot (Atlas UI, yourself)¶
Atlas → Cluster0 → Backup → Snapshots → choose a recent scheduled snapshot → Download. Note the cluster's MongoDB version shown in the Atlas UI.
Step 2: put it on Bramble¶
mkdir -p ~/.syrf/backups && chmod 700 ~/.syrf ~/.syrf/backups
mv ~/Downloads/Cluster0-*.tar.gz ~/.syrf/backups/
chmod 600 ~/.syrf/backups/Cluster0-*.tar.gz
Step 3: run a pilot, then every project¶
From the worktree root on Bramble, a pilot of 20 projects first (it also performs the one-off restore):
E2E_MONGO_IMAGE=mongo:8.2 bash e2e/run-local.sh --feat024-prodcopy ~/.syrf/backups/Cluster0-<…>.tar.gz -- --limit 20
The first run checks the refusals, restores the snapshot into the Docker volume
syrf-feat024-prodcopy (once; later runs reuse it), starts the stack on it, verifies that the stack's
MongoDB serves the copy on loopback with no syrftest database, chooses the projects, starts the API
and project-management with every statistics family on, and rehearses. For each project it:
- records the statistics rows, publication guards and control the copy already holds, and the fold state (production may already hold some rows);
- runs the read-only inclusion drift check and records drifted-Study counts by kind;
- with
--re-persist-driftonly, re-persists the drifted Studies in the local copy and checks drift again (without it, families that read a drifted field refuseInclusionStatusDrift, which is itself a finding); - backfills each family in runbook order (ProjectScreening, MembershipScreening, ReviewerScreening,
StageAnnotation, DomainReconciliation, MembershipStageAnnotation, ReviewerAnnotation,
QuestionAnswers, SearchPopulation), retrying only the typed retryable refusals
(
FamilyFenced,RetryExhausted), and records duration, outcome and scope dispositions; - opens the project's narrow gate (the fleet gate is opened once per run);
- takes exact per-family parity (
GET …/parity?family=<family>) and records the verdict, the inconclusive reason, scope and metric counts and the names of any mismatched metric keys.
Every 5xx, timeout and refusal is recorded against its step. When the pilot looks right, run every project (the restore is reused):
Harness options (after --):
| Option | Default | Meaning |
|---|---|---|
--limit N |
all | Rehearse the first N projects of the chosen order. |
--projects id,id / --projects-file f |
all | Only these projects (unknown ids are refused). |
--order pseudonym\|studies-asc\|studies-desc |
pseudonym |
Pseudonym order mixes sizes stably. |
--concurrency N |
2 | Projects in flight at once. |
--batch-size N |
3000 | Projects per allowlist batch (at most 3,500: one 128 KiB environment string). The API and project-management restart with each batch's allowlist. |
--re-persist-drift |
off | Re-persist drifted Studies in the local copy before backfilling. Fixed for the run. |
--request-timeout D |
30m |
Per-request timeout (90s, 15m, 1h). A timeout is recorded, not retried. |
--stop-after N |
none | Stop after N more projects (then --resume). |
Environment: E2E_PRODCOPY_DB (default syrf_prodcopy), E2E_PRODCOPY_VOLUME (default
syrf-feat024-prodcopy), E2E_PRODCOPY_RESTORE_IMAGE, E2E_PRODCOPY_WT_CACHE_GB (default 4),
E2E_PRODCOPY_OPLOG_MB (default 2048), SYRF_PRODCOPY_OUT (default
~/.local/state/syrf/feat024-prodcopy).
Step 4: stop and resume¶
Ctrl-C once stops after the projects in flight and writes the evidence so far; a second Ctrl-C aborts. State is saved after every project. Continue with:
A resumed run continues at the first unfinished batch; a project interrupted mid-way, or one that hit network errors (the stack went away), is rehearsed again, keeping its first record of pre-existing state. Three projects in a row with network errors stop the run. The containers and the restored copy stay up between runs (loopback only) until step 6.
Step 5: read the evidence¶
Under ~/.local/state/syrf/feat024-prodcopy/run-<time>/:
| Path | Contents |
|---|---|
evidence/summary.md |
The human summary: per-family verdict counts, inconclusive reasons, mismatched metric keys, backfill outcomes, backfill and parity p50/p95/max, projects with failures, the largest projects by studies, drift counts, the statistics state found in the copy, narrow-gate outcomes, errors and refusals. |
evidence/summary.json, evidence/projects.jsonl, evidence/run.json |
The same, machine-readable: one allowlisted record per project, plus run metadata (commit, restore versions and counts, options, progress). Safe to share: pseudonyms, counts, codes, durations and metric key names only. |
private/state.json, private/run.log |
Real project ids, for resuming. Stays on Bramble; deleted by cleanup --purge-private. |
pseudonym.key (one level up, shared by every run) |
The key behind the pseudonyms. Stays on Bramble; deleted by cleanup --purge-private, after which the evidence cannot be linked back to project ids. |
Verdict categories, per family and project: InParity (exact), NotInParity (Mismatch: at
least one served value differs from the authoritative calculation), Inconclusive (NotServed,
SourceAdvanced or ScopesTruncated), NothingToCompare (NoScopes with nothing materialised and
no scope state on either side, the soak's isEmptyParity rule: for example QuestionAnswers before any
annotation answer) and Error (an HTTP failure, or an answer for another family). A project counts as
failed for any NotInParity or Error, or any step that did not succeed (a refused or failed backfill,
drift check, re-persist or narrow gate). Inconclusive is listed separately: it is not a pass.
To look at a failing project yourself, map its pseudonym back on Bramble only, before you purge (it
needs the key and the run's private/state.json, both deleted by --purge-private):
node -e 'const { createHmac } = require("crypto"); const fs = require("fs");
const root = process.env.HOME + "/.local/state/syrf/feat024-prodcopy";
const key = fs.readFileSync(root + "/pseudonym.key", "utf8").trim();
const state = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
for (const id of Object.keys(state.projects))
if ("p-" + createHmac("sha256", key).update(id.toLowerCase()).digest("hex").slice(0, 12) === process.argv[2]) console.log(id);' \
~/.local/state/syrf/feat024-prodcopy/run-<time>/private/state.json p-<pseudonym>
Step 6: clean up¶
bash e2e/run-local.sh --feat024-prodcopy-cleanup --purge-private
command rm -i ~/.syrf/backups/Cluster0-<…>.tar.gz
docker volume ls | grep prodcopy # expect nothing
The cleanup stops the stack that served the copy, removes the restored data volume, deletes the service
logs, launchers and allowlist it left in /tmp, deletes every run's private state (with
--purge-private) and prints the exact command to delete the snapshot. The pseudonymised evidence is
kept. bash e2e/scripts/prodcopy.sh status shows what is still present.
--purge-private also deletes the pseudonym key (~/.local/state/syrf/feat024-prodcopy/pseudonym.key).
Anyone holding that key and a list of project ids could re-compute the pseudonyms and re-link the kept
evidence to projects, so after a purge the evidence can no longer be linked to project ids, by you or
anyone else. The consequence: a later run makes a new key, so pseudonyms are not stable across runs
after a purge (the same project gets a different p-… in the next run's evidence), and the mapping
snippet in step 5 no longer works for the purged runs. Without --purge-private the key and each run's
private/ directory stay on Bramble, and the evidence can still be mapped back there.
How the restore works¶
e2e/harness/statistics-prodcopy/restore.mjs, called by run-local.sh:
- Preflight. Refuses a production or reserved database name, a snapshot that is not a
.tar.gzor.tgz, a snapshot inside any checkout of this repository, and Mongo connection settings in the environment. Reuses the volume when it already holds a complete copy of the same snapshot file (name and size); refuses a different snapshot or an interrupted restore until you clean up. - Extract. Unpacks the tarball into the volume and finds the directory holding the
WiredTigerfile (Atlas nests it one level down). Reads the MongoDB version fromrestoreInfo.txtwhen the snapshot carries one. - Choose the binary. The stack image if it opens the snapshot's version; otherwise the one release
in between (MongoDB 8.0 for a 7.0 snapshot under an 8.2 stack), pinned to
mongo:8.0.20because 8.0.21 and later refuse Bramble's kernel version string (SERVER-125742). With no stated version it tries the stack image, thenmongo:8.0.20.E2E_PRODCOPY_RESTORE_IMAGEoverrides. - Prepare. Starts that binary standalone on the volume and renames every collection of
syrftestto the target database (recreating views), drops every other database, the replica-setlocaldatabase and the Atlas users and roles, raises the feature compatibility version only when the stack's binary could not open it otherwise, refuses if asyrftestdatabase remains, and writes a marker (versions and counts) intofeat024_prodcopy_meta.restore. - Shut down cleanly and write a completion file into the volume.
The stack then starts MongoDB on the volume as replica set rs0 (its health check initiates it), and
restore.mjs verify-stack checks the mount, the loopback-only port, the marker and the absence of
syrftest before anything else connects.
Troubleshooting¶
| Symptom | Cause and remedy |
|---|---|
The restore mongod (…) could not start or open the snapshot (a version, compatibility or kernel-version message) |
The restore image refused to start on this host, or the snapshot's MongoDB release needs another binary. The message names E2E_PRODCOPY_RESTORE_IMAGE: set it (see "MongoDB versions" above), run the cleanup, start again. |
Volume … holds an interrupted restore |
A restore was stopped part-way. Run the cleanup, start again. |
Refusing this MongoDB / Refusing this stack |
The harness or the stack check found something other than this stack's restored copy. Read the listed reasons; do not work around them. |
| Services not healthy | tail -50 /tmp/<stack>-api.log (the stack name is in bash e2e/scripts/prodcopy.sh status). The first start on a copy may build indexes. |
Many InclusionStatusDrift refusals |
Expected on older data: rerun with --re-persist-drift (local copy only) to see the families' parity after the remedy the statistics reference prescribes. |
Testing the tooling without production data¶
The tooling was built and tested on Juniper with synthetic data only:
node --test e2e/harness/statistics-prodcopy/lib.test.mjs: the pure rules (refusals, MongoDB version path and the pinned restore image, pseudonyms, the evidence allowlist, resume state, verdict roll-up).bash e2e/scripts/prodcopy.test.sh: the cleanup, against a stubdockerand a synthetic output root (--purge-privatedeletes everyprivate/and the pseudonym key, and keeps the evidence).bash e2e/harness/statistics-prodcopy/synthetic-snapshot.sh --out <dir outside the repo>builds an Atlas-like snapshot: a MongoDB 7.0 replica set with its own set name, asyrftestdatabase filled by project-management's own seed-data mode (five generated projects), other databases, an Atlas-style user and role and a view, shut down cleanly and tarred as one directory with arestoreInfo.txt. Run the rehearsal on it exactly as in step 3, then clean up.