<env>-db-copy Cloud Run job dumps Azure and restores into Cloud SQL in one transaction, and api-events comes back on a new DatabaseUrl version that points at Cloud SQL.
qa and prod use this runbook (decision 2026-11-03: dump and restore, not DMS). Dev moved with DMS on 2026-11-02; that path is in the git history of this page and in infra-gcp/modules/database-migration. Dev and qa are approved to run. Prod needs its own scheduled go.
Timings
About 2.5 minutes of each job is startup and the
ca-certificates install. The qa cutover froze writes from 17:32:15 to 17:49:48 UTC (17.5 minutes): 9 minutes waiting for the Azure connection metric to settle after the drain, 6 minutes of copy, 1.5 minutes for the new revision. Adding the Cloud SQL DatabaseUrl version during the drain wait, as qa did, keeps it off the critical path.
Prod used the read-only variant below instead of the drain, and froze writes from 00:37:57 to 00:48:55 UTC (11 minutes). A first attempt at 00:15 was aborted after 10 minutes because the job’s ca-certificates install could not reach the Debian mirrors twice in a row; a MODE=fresh dry run 5 minutes later reached them. Run that dry run right before every window.
Read-only variant
Prod kept api-events serving reads during the window instead of draining it:-
Run the job once in its default
MODE=fresh. On a target that already holds a copy it connects to both servers and stops attarget already has <n> tables, which proves its egress works without writing anything. -
Check that no role or database setting overrides the server default:
select * from pg_db_role_setting where array_to_string(setconfig, ',') ilike '%read_only%'may list only=onentries. -
Record the time, then
az postgres flexible-server parameter set ... --name default_transaction_read_only --value on. In a new session,show default_transaction_read_onlyreadson. The setting applies only to transactions that start after it, and a transaction that began earlier can write later even if it has not written yet, so wait for every one of them to end. Repeat this, with the recorded time, until it reads 0:The longest api-events transaction is the unlinked-file sweep (15-minute timeout). If a session stays, end it withpg_terminate_backend(<pid>); its transaction rolls back and the job retries on Cloud SQL. -
Step 3 below (copy). If it fails before step 4, set the parameter back to
offand nothing is lost. -
Add the Cloud SQL
DatabaseUrlversion only after the copy succeeds. The service is serving, so any instance that starts after the add readslatestand would connect to Cloud SQL before the copy. - Step 4.2 and step 5 as written. Old instances keep reading Azure until traffic moves, and cannot write.
prisma migrate deploy with no pending migration takes only an advisory lock, which a read-only server allows. Do not deploy a new migration during the window. Saves fail with a 500 and scheduled jobs, pushes and webhooks retry; a handler that sends an email or text before writing its record can send it twice.
Never do
- Never run the copy with
MODE=replacewhile api-events is serving. The job refuses a target with other sessions or recent writes, but it cannot see writes still landing on Azure, and those would be lost. - Never merge to
$ENV_BRANCHduring the window. Every push to it deploys api-events and appliesinfra-gcp/environments/$ENV, and either one undoes the drain. - Never print a password, a token or a URL that holds one. Secrets move through pipes only.
- Never add the Cloud SQL
DatabaseUrlversion before the drain has finished (--scaling=0applied). The service readsDatabaseUrlatlatestuntil the follow-up pin, so any instance that starts after the add connects to Cloud SQL. - Never copy
<env>-CloudSqlDatabaseUrlinto<env>-DatabaseUrl. Itssslmode=requiredoes not meet R3.
Variables
Helpers:
1. Before the window
-
Rehearse the copy in the default
MODE=freshon an empty target, or withMODE=replacewhile nothing uses Cloud SQL, and record its timings above. The job’s own guards are in its README. -
Check the service: every
status.trafficentry islatestRevision: trueat 100 percent with notag, andDATABASE_URLreads$ENV-DatabaseUrlatlatest, which is the Azure version. Record that version number asAZURE_URL_VERSION: it is the version rollback returns to. -
Record the baseline:
azure_connections, and the dead-letter depth of every*-dlqsubscription (pubsub.googleapis.com/subscription/num_undelivered_messages). -
Tell everyone who merges that
$ENV_BRANCHis frozen for the window. Then wait until no apply or deploy from an earlier merge is still queued or running, because one that starts after the drain restores automatic scaling during the copy. Both lists must print nothing:Run both again right before step 2. The Azure root (terraform-apply.yml) applies on the same pushes and does not touch the service. -
Prod only: the Go API (
prod-cus-api-app) holds its own AzureDATABASE_URL. It stops at the drain and does not come back (see Legacy Go API).
2. Drain
az webapp stop --subscription soundcheck -g prod-cus-backend-rg -n prod-cus-api-app.
--scaling=0 disables the service: requests get Service unavailable and in-flight requests finish. Repeat azure_connections until the count falls to the server’s own sessions (the azuresu, Azure agent and pg_cron sessions) and stays there. On qa it fell from 17 to a floor of 7 to 8 over about 6 minutes. The metric lags by one to two minutes. Also check that no other service reads $ENV-DatabaseUrl and that the service’s run.googleapis.com/container/instance_count is 0.
Scheduled run-* subscriptions that push to $API_HOST keep failing while the service is at zero. With a 10-second to 600-second backoff and 10 delivery attempts, a message reaches its dead-letter topic only after about 40 minutes, so a window under 30 minutes needs no push hold. If the window will run longer, save and clear the push configs as in the dev runbook (step 8.2 in this page’s history) and restore them after step 4.
3. Copy
restore done, tables: <n>, differing: 0 and copy finished. Row counts are exact here, because nothing writes to Azure during the freeze. A failed restore rolls back on Cloud SQL and leaves the previous copy; Azure is untouched, so the service returns on the Azure version (Rollback, first paragraph; step 3 in full if the Cloud SQL version was already added during the drain).
4. Switch
-
Add the Cloud SQL
DatabaseUrlversion. The CA path is thedatabase_server_ca_pathoutput ofmodules/events. -
Bring api-events back on that version, in one revision:
This is a hand change. Step 6 records it in Terraform. Until then, an apply sets
DATABASE_URLback tolatest, which is now the same Cloud SQL version.
5. Verify
-
The new revision is ready, serves 100 percent, and its boot log shows migrations up to date and no
SECURITY WARNINGfrom pg-connection-string: -
curl -fsS https://$API_HOST/health, andnpm run api -- call v1EventsList --env $ENVwith a tester token inSOUNDCHECK_TOKEN. -
Cloud SQL has the app’s sessions (
cloudsql.googleapis.com/database/postgresql/num_backends) and Azure stays at its own sessions (azure_connections). -
Pushes to
$API_HOST/__encore/pubsub/push/return 200 again, and the dead-letter depths match the step 1 baseline apart from jobs that were already failing.
6. Close the window
- Pin the version in Terraform:
database_url_secret_version = "<CLOUDSQL_URL_VERSION>"inlocal.database_cutoverofinfra-gcp/environments/$ENV/main.tf. It reaches$ENV_BRANCHwith the next promotion, and the apply then changes nothing. - Refuse writes on Azure:
azure_write_refusal = trueininfra/environments/$ENV/main.tf(default_transaction_read_only = on, dynamic, no restart). Same promotion. - Lift the merge freeze.
- Disable the copy job, so nobody dumps a stale Azure database over live data:
enabled = falsein thedatabase_copymodule (itsMODE=freshdefault already refuses a target with tables).
Rollback
A copy that fails before step 4.1 needs only theservices update and update-traffic commands of step 3 below, with ROLLBACK_URL_VERSION=$AZURE_URL_VERSION: Cloud SQL took no writes and latest is still the Azure version. After the read-only variant, also run step 2 first, because Azure refuses writes from the start of that window. Once step 4.1 has added the Cloud SQL version, latest points at Cloud SQL and the service reads latest until step 6.1, so the next apply would move it back to Cloud SQL without the writes Azure took in between. From then on, always run step 3 in full, which makes an Azure copy the newest version, even when the service never served on Cloud SQL.
Once the Cloud SQL revision has served, scheduled jobs write within seconds, so any rollback loses those writes. Roll back only when the approver accepts that loss, and record it. Then:
-
Drain the Cloud SQL revision and wait for in-flight work to finish, so nothing commits on Cloud SQL after the decision:
Wait until
run.googleapis.com/container/instance_countfor the service reads 0, then record the Cloud SQL write counts (cloudsql.googleapis.com/database/postgresql/tuples_processed_count,operation_typeinsert,updateanddelete) from step 4 to now. That is the data rollback abandons. -
If step 6.2 has applied, or the window used the read-only variant, lift the Azure write refusal before anything connects. The Terraform path is a promotion, so make the change by hand and record it in the same task:
The parameter is dynamic;
showmust printoff. Then setazure_write_refusal = falseininfra/environments/$ENV/main.tf, or the next Azure apply turns the refusal back on. -
Add an Azure version and bring the service back on it:
The new version holds the Azure URL and is the newest, so
latestis Azure again and a later apply cannot move the service back to Cloud SQL. Do not disable the Cloud SQL version instead:latestkeeps pointing at the newest version even when it is disabled, and new instances then fail to start. If step 6.1 has already pinned the Cloud SQL version in Terraform, change that pin toROLLBACK_URL_VERSIONin the same change as step 2’s refusal flip. -
Prod:
az webapp start --subscription soundcheck -g prod-cus-backend-rg -n prod-cus-api-app. Check thatazure_connectionsrises, that a write succeeds (the step 5 API checks), and that pushes return 200.
Legacy Go API (prod)
The Go API (prod-cus-api-app, App Service in prod-cus-core-vnet) holds its own DATABASE_URL and had 3 open sessions on the prod database on 2026-10-20. Cloud SQL has a private IP only, so the Go API cannot reach it. It stops at the drain and starts again only for a rollback to Azure before the first Cloud SQL write (decision 22). qa has no Go API.
Prod gates
Prod does not start until each of these is closed or carries an approved exception:- The scheduled go for the window.
- A qa run of this runbook end to end.
- Decision 34: the Prisma schema engine that runs
migrate deploydoes not readsslrootcertand treatsverify-caasprefer, so that path encrypts without verifying the certificate. - Decisions 12 and 42: the shared Azure admin password rotated, so rollback does not depend on an exposed credential.
- R2: the Azure firewall narrowed (
database_firewall_rules, starting withAllowAll_2025-2-14_15-15-43). teams-backup: a second public Flexible Server inprod-cus-database-rg, not in Terraform, purpose unknown.