Cookiebot

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 --yes

cb_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

StepWritesWhat it does
preflightneverPostgres reachable, storage URI usable, Mongo source configured, GCS export credential configured and the bucket listable, v1 checkout present
schemaPostgresalembic upgrade head, reporting the revision before and after
mongoPostgresv1 MongoDB (or a mongodump directory) → Citus
randomPostgres + object storagev1's randomdatabase pointers → real media_objects rows, by downloading what each one references from Telegram
bucketobject storagev1's private GCS bucket → v2 object storage
memesobject storagev1's checked-in meme templates → v2 object storage
verifyneverrow 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_id is 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, because media_objects.group_id is 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_id only resolves for the bot that saw the message. The step uses the first token in CB_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.py explains why the bytes stay out of the wheel), so the memes step 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 cover github.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.

On this page