Migrate both databases
This guide brings both databases to the schema their binaries expect and rolls one chain back again. tally-reporting-admin owns the reporting chain, tally-engine owns the engine chain, and no other subcommand of either binary runs DDL.
The prod overlay applies both chains on every deploy with Job tally-migrate of the migrations kustomize component: an init container runs tally-reporting-admin migrate and the Job's container tally-engine migrate after it, as deploy the stack to a cluster shows. The commands below are what the two containers run, and what an operator runs by hand for a rollback or a repair.
Before you start
- Both databases reachable from the machine you run the CLIs on, with a connection string for a role that may run DDL on each.
tally-reporting-adminandtally-engineat the version the deployment runs, with their subcommands on the reporting admin CLI and the engine CLI pages.kubectlagainst the cluster that runs the hourlytally-engineCronJob, for the rollback below.- The engine settings page, which names
TALLY_ENGINE_DB_URL, beside the Reporting API settings page, which namesTALLY_REPORTING_DB_URL. pg_dump,pg_restoreandpsqlat the version of the server, and somewhere to put a dump, for the rollback below. Neither CLI copies anything before it drops, and a rollback drops what every migration above the target created.- A
~/.pgpassat mode0600with one line per database:db.internal:5432:tally_engine:tally:<password>and the same fortally_reporting. Thepsql,pg_dumpandpg_restorecalls below connect with a URI that carries no password and read it from there: a password in the URI is an argument, and arguments stand inpsoutput and in the world-readable/proc/<pid>/cmdlinefor the life of the process, which for a dump of a billing database is minutes. The CLIs of this repository are unaffected — they read their URL from the environment.
Apply both chains
Point the admin CLI at the reporting database and apply the reporting chain:
shexport TALLY_REPORTING_DB_URL='postgres://tally:password@db.internal:5432/tally_reporting?sslmode=require' tally-reporting-admin migratetextapplied migration 9 applied migration 10A chain already at its head answers
nothing to apply.The chain goes before the image that expects it. Migration 9 takes the reporting database to the version a Reporting API built from this tree requires, and that build's readiness check refuses a database below it, so rolling the image first leaves the new pods out of rotation until this command has run.
Point the engine at its own database and apply the engine chain:
shexport TALLY_ENGINE_DB_URL='postgres://tally:password@db.internal:5432/tally_engine?sslmode=require' tally-engine migratetextapplied migration 2
When an apply fails
Migration 9 builds
idx_events_receivedone chunk at a time and therefore outside a transaction, so a build that is interrupted leaves the chunks it finished indexed with the migration unrecorded, and the rerun fails on the index that is already there. Drop it and apply the chain again:shpsql 'postgres://tally@db.internal:5432/tally_reporting?sslmode=require' \ -c 'DROP INDEX IF EXISTS idx_events_received;' tally-reporting-admin migrateMigration 10 aborts by design in two ways. It counts the rows under the reserved
metaandpartnerplatforms first and refuses a database that holds any, naming them; which real cloud such a row belonged to is the operator's to decide, so the chain waits until they are corrected. It then adds its constraints underSET LOCAL lock_timeout = '3s', and a failure naming the lock is a run that queued behind a reader ofprojectsorcurrent_resources— a metering run holds one for its whole length. That one is rerun once the reader is gone, off the hour the CronJob fires on.
Roll a chain back
A rollback runs the Down direction of every migration above the target, and those directions drop what their Up created. tally-engine migrate-down-to 1 runs the down of migrations/engine/0002_adjustment_records.sql, which is DROP TABLE adjustment_records: the applied pricing adjustments of every run, one row per adjustment with its rate, the base it was applied to, the signed amount it produced and the partner a kickback is paid to. The trigger that keeps a finalized run immutable is a row trigger and does not fire on DDL, so the drop goes through. The migrate that follows re-creates the table empty and migrate-status then reports the chain healthy, so nothing after the rollback says the rows are gone. The dump taken first is what they come back from.
Take a restorable copy of the database first.
-Fcwrites the custom formatpg_restorereads:shexport TALLY_ENGINE_DB_URL='postgres://tally:password@db.internal:5432/tally_engine?sslmode=require' (umask 077; pg_dump -Fc \ 'postgres://tally@db.internal:5432/tally_engine?sslmode=require' \ > engine-pre-rollback.dump)A bare redirection creates the file under the umask the shell carries,
0022on a stock installation, which is mode0644on a complete copy of a billing database: every adjustment with its rate and the partner a kickback is paid to, and on the reporting side every ingested event. Theumask 077makes it0600instead, and the subshell keeps that umask off the shell you ran the command in. Nothing below removes the file.Suspend the hourly
tally-engineCronJob, wait out a tick that is already running, roll the chain back, apply it again and put the schedule back.suspendkeeps the controller from creating Jobs and does nothing to a Job it already created, and.status.activenames the Jobs the controller still counts as running:sh( set -e export TALLY_ENGINE_DB_URL='postgres://tally:password@db.internal:5432/tally_engine?sslmode=require' kubectl -n tally patch cronjob tally-engine -p '{"spec":{"suspend":true}}' empty=0 while [ "$empty" -lt 2 ]; do active="$(kubectl -n tally get cronjob tally-engine \ -o jsonpath='{.status.active[*].name}')" \ || { echo "cluster read failed; not rolling back" >&2; exit 1; } if [ -n "$active" ]; then empty=0 echo "a tick is still there ($active); waiting at $(date -u +%FT%TZ)" else empty=$((empty + 1)) fi sleep 10 done tally-engine migrate-down-to 1 --yes tally-engine migrate kubectl -n tally patch cronjob tally-engine -p '{"spec":{"suspend":false}}' )textcronjob.batch/tally-engine patched a tick is still there (tally-engine-29354280); waiting at 2026-07-09T14:22:00Z rolled back migration 2 applied migration 2 cronjob.batch/tally-engine patchedThe subshell exports the database it works on rather than taking it from the shell it runs in, and it runs under
set -e, so apatchthat did not land stops it before the firstmigrate-down-to. The read inside the loop carries an||with anexit 1of its own, whichset -edoes not give the commands of awhilecondition.suspend:falseis the last step in the chain, so a block that stopped leaves the CronJob suspended and the schedule goes back by hand.The loop wants two empty reads rather than one, because the controller writes
.status.activeafter it has created the Job rather than together with it: a tick that fires in the same second as thepatchhas a pod starting while its status update has not landed yet, and a single empty read would send the rollback into a run that is writing.The reporting chain takes the same argument and the same flag, and it needs a different set of writers stopped. Its writer is not a CronJob: the Reporting API serves the collectors' ingest path and
POST /internal/sync/{cloud}continuously, and its readiness check takes the pod out of the Service only after the schema has already gone backwards, so the writes in flight until then fail against objects that are being dropped. An event acknowledged on that path is gone from the collector's outbox, which was the only other copy it had. Suspend the engine's CronJob as well: the down ofmigrations/reporting/0008_engine_reader_role.sqlrevokes theSELECTthe engine reads this database through, so any target below 8 leaves an unsuspended tick failing withpermission denied for table eventsuntil the chain is applied again.sh( set -e export TALLY_REPORTING_DB_URL='postgres://tally:password@db.internal:5432/tally_reporting?sslmode=require' migrations="$(tally-reporting-admin migrate-status)" printf '%s\n' "$migrations" | tail -1 | grep -q ' applied$' \ || { echo "the chain is not at its head; finish the stopped rollback instead" >&2; exit 1; } (umask 077; pg_dump -Fc \ 'postgres://tally@db.internal:5432/tally_reporting?sslmode=require' \ > reporting-pre-rollback.dump) replicas="$(kubectl -n tally get deployment/reporting-api \ -o jsonpath='{.spec.replicas}')" restore() { kubectl -n tally scale deployment/reporting-api --replicas="$replicas" \ || echo "scaling back to $replicas failed; do it by hand" >&2 kubectl -n tally patch cronjob tally-engine -p '{"spec":{"suspend":false}}' \ || echo "unsuspending the CronJob failed; do it by hand" >&2 } trap restore EXIT kubectl -n tally patch cronjob tally-engine -p '{"spec":{"suspend":true}}' empty=0 while [ "$empty" -lt 2 ]; do active="$(kubectl -n tally get cronjob tally-engine \ -o jsonpath='{.status.active[*].name}')" \ || { echo "cluster read failed; not rolling back" >&2; exit 1; } if [ -n "$active" ]; then empty=0 echo "a tick is still there ($active); waiting at $(date -u +%FT%TZ)" else empty=$((empty + 1)) fi sleep 10 done kubectl -n tally scale deployment/reporting-api --replicas=0 kubectl -n tally wait --for=delete pod \ -l app.kubernetes.io/name=reporting-api --timeout=2m (umask 077; pg_dump -Fc --table=size_names \ 'postgres://tally@db.internal:5432/tally_reporting?sslmode=require' \ > size-names.dump) tally-reporting-admin migrate-down-to 9 --yes tally-reporting-admin migrate pg_restore --data-only --strict-names --table=size_names \ -d 'postgres://tally@db.internal:5432/tally_reporting?sslmode=require' \ size-names.dump )textcronjob.batch/tally-engine patched deployment.apps/reporting-api scaled pod/reporting-api-7d9f4c8b5c-2xk9v condition met rolled back migration 12 rolled back migration 11 rolled back migration 10 applied migration 10 applied migration 11 applied migration 12 deployment.apps/reporting-api scaled cronjob.batch/tally-engine patchedThe replica count is read before the scale to zero rather than written into the block, so the Deployment comes back at the count it ran at, and the
trapis what puts it and the schedule back. Every path out of the block runs it, including the one that matters most: the down of migration 10 drops its constraints under the same three-secondlock_timeoutthe previous section describes, so an abort there is routine rather than exceptional, and without the trap it would leave ingest refused, the query API down and metering stopped until somebody noticed. Each restoring command carries its own message, because aset -ethat fired once would otherwise stop the second one from being tried.The block waits for the pod to be gone rather than for
rollout status, which reads a Deployment's replica counts: the ReplicaSet controller stops counting a pod the moment it carries a deletion timestamp, so a pod that has been sentSIGTERMand is finishing an ingest batch is already uncounted androllout statusreturns while it still writes. The Deployment sets noterminationGracePeriodSeconds, so it has the default 30 seconds to finish, and the DDL below would run into the transaction the scale to zero exists to let end.wait --for=deletereturns once the pod object is gone, which is after the process has exited.Ingest is refused for the length of the block: a collector holds its events in its own outbox and delivers them when the API answers again.
The down of
migrations/reporting/0012_size_names.sqldropssize_names, the volume type names the last reconciliation run of each cloud stored, and themigrateafter it re-creates the table empty. Thepg_restoreputs the rows back before thetrapscales the Deployment up, because ingest replaces the type id of a volume event with its name through them. Without them, the backlog the collectors deliver first is stored under the id, stored events are never rewritten, and the intervals those events open stay billed under the id until the next run of their cloud, as Size names describes. The rows come fromsize-names.dump, taken once the pod is gone, rather than from the full dump: the API servesPOST /internal/sync/{cloud}until then, and a run that stored names while the block waited would be put back to the ones before it.--strict-namesmakes a dump without the table an error; without it,pg_restorerestores nothing and exits 0.The
migrate-statuscheck refuses a chain that is not at its head, because a rerun of a block that stopped would lose the names. Each down commits on its own, so a down of 10 that aborts leaves 12 and 11 rolled back, andmigrate-down-toreports apartial migration errorwithout arolled back migrationline.size-names.dumpis then the only copy of the names, and a rerun would write both dumps over from the rolled-back database. Finish a block that stopped inmigrate-down-toor after it by running it again without the check and the twopg_dumpcommands, so thepg_restorereads the dump the stopped run wrote. That includes a stop in thepg_restoreitself, which leaves the chain at its head and passes the check. The status is read into a variable before thegrepbecause a pipeline's status is that of its last command: piped straight into it, amigrate-statusthat cannot reach the database would read as a chain below its head and send you to finish a rollback that never started. The assignment fails with the command's own error instead, andset -estops the block there.migrate-down-towithout--yesis refused before the database is opened, and nothing is dropped. The refusal goes to stderr and the command exits 1:shtally-engine migrate-down-to 1text--yes: rolling back drops the data of every migration above the target
Check the result
Ask each chain what its database carries. Every migration of the chain gets a line,
appliedorpending:shtally-reporting-admin migrate-status | tail -2 tally-engine migrate-statustextmigration 11 applied migration 12 applied migration 1 applied migration 2 appliedA rollback prints one
rolled back migration <n>line per migration it undid, andnothing to roll backwhere the chain already sits at the target.A Reporting API left running across a reporting rollback goes unready rather than serving the older schema: its readiness check refuses a database below the version its build expects. That is the state the scale to zero and the wait above avoid, because readiness takes the pod out of the Service only after the DDL has run. Once the chain is back and the Deployment is scaled up again, the pod is ready:
shkubectl -n tally get pods -l app.kubernetes.io/name=reporting-apitextNAME READY STATUS RESTARTS AGE reporting-api-7d9f4c8b5c-2xk9v 1/1 Running 0 3h12m