Cutover
One command that moves v1 into v2 — schema, Mongo, buckets, templates — with a progress bar and a report you can read under pressure
What it is
python scripts/cb.py cutover --dry-run
python scripts/cb.py cutover --only mongo --collections configs,rules --yes
python scripts/cb.py cutover --skip bucket --yescb_worker.cutover composes the four tools that already existed — the schema
converger (cb_core.migrations), the Mongo importer (cb_worker.importer), the
GCS bucket export (cb_worker.bucket_export) and the meme template seeder
(cb_worker.meme_seed) — into one ordered run. It replaces none of them: each
still works standalone, which is what a mid-week delta sync or a --verify
re-check actually wants.
The reason it exists is the same reason a checklist exists. Cutover day is four
commands in a specific order, each with its own flags, run by someone watching a
group chat go quiet. Getting the order wrong is silent: import a configs
document for a chat before its groups row and the foreign key rejects it;
seed templates into memory:// and the run reports success and loses every
byte at exit.
The seven steps
| Step | Writes | What it does |
|---|---|---|
preflight | never | Postgres reachable, storage URI usable, Mongo source configured, GCS export credential configured and the bucket listable, v1 checkout present |
schema | Postgres | alembic upgrade head, reporting the revision before and after |
mongo | Postgres | v1 MongoDB (or a mongodump directory) → Citus |
random | Postgres + object storage | v1's randomdatabase pointers → real media_objects rows, by downloading what each one references from Telegram |
bucket | object storage | v1's private GCS bucket → v2 object storage |
memes | object storage | v1's checked-in meme templates → v2 object storage |
verify | never | row counts per table, objects at the destination, the alembic revision |
--only narrows to a subset and --skip removes from it; neither reorders
anything. A step that fails is recorded and the run continues to the next one —
the operator gets everything that could run this time, not just the first
failure — and the exit code is 1 if any step failed, 2 for a user error such as
a misspelled step name.
Why it is safe to run twice
Every underlying tool is idempotent, and that is what the whole design rests on:
- the importer upserts on the natural key, so a re-run rewrites nothing and duplicates nothing;
- the bucket export skips a blob whose content already landed, and appends to a manifest as it goes, so a killed run resumes;
- the seeder skips a template key already present at the same size;
- the random-media backfill skips a pointer whose
telegram_file_idis already recorded for that group, so a resumed run costs one query per already-imported pointer rather than one download.
So the intended usage is not one careful run. It is: run it now while v1 still serves, run it again next week, run it once more at cutover to catch the delta.
The random step, and why it can never be "complete"
Every other step moves data that exists. This one moves pointers: v1's
randomdatabase stores {chat_id, message_id, file_id} and nothing else, so a
row can only become a media_objects row by asking Telegram for the file and
downloading it (docs/contracts/fun_random.md, and
cb_worker/backfill/random_media.py for the mechanics). Three consequences an
operator should expect rather than debug:
- It runs after
mongo, becausemedia_objects.group_idis a foreign key to a group that step creates. A pointer for a group that has not been imported is skipped with exactly that reason. - A
file_idonly resolves for the bot that saw the message. The step uses the first token inCB_BOT_TOKENS;cb.py backfill-random --skin <name>picks a different one. The wrong brand's token fails every row, not some. - Old pointers are expected to fail. A deleted message or an expired file
id is a 400 from
getFile. Each is one counted row and never the step, and the summary reports how many — what fraction of years-old pointers still resolve is a property of v1's data, so there is no threshold here to fail against.
Preflight refuses the two mistakes that look like success
memory:// with a write step selected. The in-process store accepts every
upload and loses all of it when the process exits. A run that seeded 801
templates into it would print a green table and leave /meme exactly as dead as
before, which is why this is a hard failure rather than a warning.
Both CB_MONGO_URI and CB_MONGO_DUMP_DIR set. The importer picks exactly
one source; two configured means the operator believes they are importing
something they are not.
An absent Mongo source is not an error — that is the normal state once cutover is done, so the step skips and the run still exits 0.
Dry run
--dry-run reads and maps everything and writes nothing: the mongo step still
runs every mapper and every skip rule, and reports the rows each table would
have received. The schema step compares the current revision against head rather
than emitting SQL, because a preview that needs env.py's offline branch to be
exact for every revision is not a risk worth taking on the one day it matters.
Progress
Each step drives one rich progress bar with a total known before the work
starts — collections for mongo, catalog entries for memes, blobs per prefix
for bucket — and the run ends in a summary table of step, status, duration and
the one number that matters for that step. The pure logic layers never import
rich: the importer and seeder take optional callbacks and stay renderer-
agnostic, which is what keeps them testable without a terminal.
Running it inside the cluster
CB_SERVICE=cutover runs the same code from the image, so a Job can do the
migration with the DSN and the AWS_* the pods already have — nothing leaves
the cluster, and on cutover day v1's Mongo is usually reachable from inside it
and not from a laptop:
args: ["cutover", "--only", "mongo,verify", "--yes"]Two things caught the first real run of this in UAT, both worth knowing before you write the Job:
- The meme templates are not in the image. They are 112 MB checked into the
v1 repository (
cb_core/meme_templates.pyexplains why the bytes stay out of the wheel), so thememesstep needs them mounted — an init container that clones v1, or a volume. The other steps need nothing extra. - The namespace is default-deny egress. The chart's own policy allows
Telegram's DCs,
*.googleapis.com, R2 and the two LLM hosts, which covers a real cutover — v1's bucket is GCS. It does not covergithub.com, so an init container cloning v1 from there needs its own carve-out, scoped to the Job's label and deleted with it. Widening the namespace's egress permanently for a one-off migration step is the wrong trade.
The alternative, and what UAT's first seed actually used: port-forward the
object store and run cb.py cutover --only memes from outside. Fine for a
seed, wrong for the data move — that one should not carry the database
credential out of the cluster.
What this cannot do for you
The bucket step needs a read-only-scoped GCS credential for v1's private
bucket. Without one it is skipped, and the four features whose assets live only
there (fun_death, fun_partneredcons, x_custom_commands, and the one-tag
shapes of fun_battle) stay blocked — see
Cutover bucket export for the read-only
guarantee and the prefix inventory.