Skip to content

feat: schema v1 and migrations before every rollout - #27

Merged
devinder-dev merged 5 commits into
mainfrom
feat/schema-migrations
Nov 1, 2026
Merged

devinder-dev merged 5 commits into
mainfrom
feat/schema-migrations

Conversation

@devinder-dev

Copy link
Copy Markdown
Owner

Replaces #25, which GitHub closed when its base branch infra/postgres was deleted after
#22 merged (it couldn't be reopened after the rebase). Same branch, same commits, now on main.
Abdikhani's review of #25 and its answers are below.

What

Step 2.4 of Phase 2: every deploy runs the commit's database migrations first, before any pod
changes.

Part Files What
2.4a api/db/migrations/…_init.sql, api/package.json + bun.lock, api/Dockerfile Migration 001 (schema v1, with deleted_at for the soft delete and named indexes; down raises an error on purpose). dbmate 2.37.0, pinned, inside the API image, run as bun run --bun db:migrate (dbmate … migrate)
2.4a .github/workflows/pipeline.yml api-image builds the image, smoke-tests the migrations inside it (uid 1000, read-only root, 128 MiB, against a throwaway Postgres made by our first-start script), and only then pushes, on main
2.4b k8s/migrate/job.yaml The migrate Job: generateName, backoffLimit: 0, deadline 150 s, pod grace 10 s, TTL 1 h, Pod Security restricted
2.4b infra/server/relay-deploy.sh run_migrations: create the Job, then ask for its verdict every 2 s, at most 90 times (≥ 180 s). Complete → continue; Failed / no verdict / can't read → print why, return 1, and the existing rollback takes over. Commits without k8s/migrate/ skip this, so a rollback to Phase 1 still works
2.4b k8s/platform/deployer.yaml The Role gets jobs (get, list, watch, create: no update, no delete) and pods/log (get)
2.4b infra/server/test-relay-deploy.sh Cases 11–17, a fake sleep, and a 30 s watchdog so a hanging script fails the test instead of CI
2.4b pipeline.yml Deploy timeout 35 → 40 min (the migrate step adds up to 3 min, twice on a rollback)
2.4c docs/decisions/0006-migrations-expand-contract.md The rule: forward-only, expand first, contract only what the last good release no longer uses. With the rename example in four releases
docs k8s/README.md, docs/CONTEXT.md A row for k8s/migrate/; deleted_at and the migrations row in CONTEXT

Tested before the PR

  • Locally against Postgres 18.6:
    • the first db:migrate creates the 6 tables, owned by relay, and a second run does nothing;
    • dbmate rollback is refused and the history stays;
    • a file broken halfway leaves nothing behind;
    • with two new files and the second broken, the first stays applied.
  • The CI smoke test, run locally with the real image: exit 0.
  • test-relay-deploy.sh: 49 passed, 0 failed. A Job that stays Pending creates exactly 1 Job,
    reads its status 90 times, then gives up with "may still be running".
  • Server dry runs: the Job is valid (the server invents its name), and only the Role's two new
    rules change. shellcheck, actionlint and bun audit are clean.

Changed after the review of #25

  • No updated_at columns (on endpoints and deliveries). Nothing kept them current, so
    without a trigger they would quietly have meant created_at whenever an UPDATE forgot them.
    Nothing reads them, and they aren't in docs/api.md. If something needs one later, a migration
    adds the column and its trigger together, which is an additive change and safe under ADR 0006.
    Editing migration 001 itself is fine here: it has never run in production, only in throwaway
    databases. ADR 0006's "never edit a merged migration" is about migrations that have.

  • next_attempt_at written down: never NULL in the table (while a row is leased, it holds
    the lease's expiry), but null in the API unless the status is pending. That mapping lives in
    one place, the deliveries repository's read query in Phase 3
    (CASE WHEN status = 'pending' THEN next_attempt_at END). The comment is in the migration, and
    it's an input to the Phase 3 plan.

  • lock_timeout tested with dbmate 2.37.0, against a real lock:

    • one session held a lock on a table;
    • a migration with our header tried to ALTER it;
    • it gave up after 5 s with 55P03 canceling statement due to lock timeout;
    • the column wasn't added, and the migration wasn't recorded as applied, so the next deploy
      simply retries.

    dbmate runs each migration in a transaction unless it's marked transaction:false. And even
    without that, Postgres runs several statements sent together as one implicit transaction, so
    SET LOCAL can't silently do nothing. The result is noted in the migration's header.

  • One source for the Postgres image: api/scripts/postgres-image.sh prints the
    digest-pinned image from k8s/data/postgres.yaml. The smoke test uses it instead of a
    hardcoded digest, and so will test-db.sh in feat: connect the API to Postgres, with Postgres in the tests (step 2.6) #26. A manifest without a digest makes the step
    fail with a clear message (tested). shellcheck now also checks api/scripts/.

  • Checked again:

    • on a fresh Postgres (production's image, the first-start script), migration 001 applies as
      relay, and a second run finds nothing to do;
    • 6 tables, all owned by relay, 0 updated_at columns.

After merging (an admin, in this order; output posted here)

The merge itself deploys with the old installed script, which doesn't migrate yet. That's
harmless: nothing in k8s/app/ changes. Then:

  1. Apply k8s/platform/deployer.yaml first, and check as the deployer: create jobs and
    get pods/log → yes; delete jobs, patch jobs, get secrets → no. (The other way round,
    the new script couldn't create its Job, and the rollback would fail the same way.)
  2. Install the new relay-deploy (install -o root -g root -m 755) and diff it against git.
  3. Deploy the same commit by hand (sudo -u deploy env SSH_ORIGINAL_COMMAND=<sha> relay-deploy).
    This runs migration 001: migrating with job.batch/migrate-…, then Applying: …_init.sql.
  4. Check schema_migrations and the tables as relay. One hour later, the old Jobs are gone.
  5. Negative tests: a hand-made Job with an image that doesn't exist ends as Failed
    (DeadlineExceeded), and the legacy rollback test (Phase 1's commit a78934c…, full SHA)
    still rolls back with 0 failed requests.

For the reviewer

  • Is the expand/contract rule clear enough that you could apply it to one of your own changes?
  • pods/log lets the deployer read every pod's log in relay. Acceptable, given that nothing
    logs a secret?

@AbdikhaniAbdirashid AbdikhaniAbdirashid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three points answered, and one of them better than I asked for. Approving.

The one I want to single out

I wrote that SET LOCAL lock_timeout was the one line in the migration that couldn't be tested, because an empty database has no locks to wait for. You went and found a way to test it anyway: a session holding a lock, a migration with our header trying to ALTER through it, 55P03 canceling statement due to lock timeout after 5 s, and then the two checks that matter — the column was not added, and the migration was not recorded as applied, so the next deploy just retries.

That is the positive control on the one line I had written off as unverifiable. It also settles the dbmate question properly rather than by reading the README: the behaviour was observed on 2.37.0, and the note about several statements sent together forming an implicit transaction means the header can't silently be a no-op even if that default ever changes.

The other two

updated_at dropped. Option 3, which was my preference too. And you caught a nuance I had not thought about: editing migration 001 is fine because it has never run in production, and ADR 0006's "never edit a merged migration" is about migrations that have. That distinction is worth having in the ADR itself at some point — it's the difference between a rule and a superstition.

next_attempt_at. Written down, and placed: CASE WHEN status = 'pending' THEN next_attempt_at END in the deliveries repository's read query, as an input to the Phase 3 plan. One place, named, and the comment is in the migration where someone reading the column will see it. That is exactly what I was asking for — the column and the API field are allowed to differ, as long as the mapping isn't folk knowledge.

The digest, checked

api/scripts/postgres-image.sh is the tidier of the two options. I ran its logic against the manifest now that #22 is merged:

$ sed -n 's/^ *image: *\(postgres:[^ ]*\).*/\1/p' k8s/data/postgres.yaml | head -1
postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4

Matches the script's own regex, so the guard would not fire on a correct manifest — which is the half of a validation that is easy to forget to check. And the failure path having a clear message rather than an empty variable is what stops it from degrading into docker run "" a year from now.

Also confirmed: zero updated_at column definitions left in migration 001.

Unchanged from my first review

Everything I agreed with still stands, so I won't repeat it — the migrate:down exception, text + CHECK over enums, deriving the attempt number instead of storing it, the no-delete-on-jobs Role, and commits without k8s/migrate/ skipping the step so a rollback to Phase 1 still works.

Approving. Please post the output of step 3 of the apply order, the by-hand deploy that runs migration 001 — and if the lock-timeout test is cheap to re-run on the server after that, I'd rather see it there once than trust that it behaves the same way against the real database.

@devinder-dev
devinder-dev merged commit 0b4746e into main Nov 1, 2026
4 checks passed
@devinder-dev
devinder-dev deleted the feat/schema-migrations branch November 1, 2026 19:52
@devinder-dev

Copy link
Copy Markdown
Owner Author

Live on 1 Nov, in the order from the description. The output you asked for:

1. The deployer Role first, checked as the deployer (kubectl auth can-i --as=system:serviceaccount:relay:deployer):

  • yes: create, get and watch jobs, get pods/log, patch deployments (namespace relay);
  • no: delete, patch and update jobs, get secrets, create pods/exec,
    delete deployments, and create jobs in kube-system or default.

2. The new relay-deploy: owned by root, mode 755, and byte-identical to
infra/server/relay-deploy.sh at 0b4746e (same SHA-256).

3. The deploy by hand of 0b4746e, which ran migration 001:

migrate-bj789   complete, finished 19:57:47Z, ttlSecondsAfterFinished 3600
labels: app.kubernetes.io/name=relay-migrate, relay.myrelay.tech/commit=0b4746e…
$ dbmate --wait --wait-timeout 60s --no-dump-schema --migration-lock --migration-lock-timeout 30s migrate
Applying: 20261031140445_init.sql
Applied: 20261031140445_init.sql in 21.140999ms

The Job's pod ran as uid 1000, non-root, with a read-only root filesystem.

4. The database (read-only queries):

  • schema_migrations: 20261031140445;
  • 6 tables, all owned by relay;
  • 0 updated_at columns.

From outside, /version answers 0b4746e… and /health/ready answers 200.

5. The negative tests (a Job with an image that doesn't exist; the legacy rollback) come
next, and I'll post them here too.

Next: #26 rebased onto main. The rules need your approval once more after that push (the
content doesn't change).

@devinder-dev

Copy link
Copy Markdown
Owner Author

Step 5, the negative tests, both passed (1 Nov, UTC):

1. A migration that can't start doesn't hang the deploy. We created a Job from
k8s/migrate/job.yaml with an image tag that doesn't exist (0000…0001):

created job.batch/migrate-hls7c at 20:01:59
after 20 s, its pod: ImagePullBackOff
verdict at 20:04:31: Failed=DeadlineExceeded   (the 150 s activeDeadlineSeconds)
what relay-deploy reads: FailureTarget Failed   → its "Failed" case → stop and roll back

2. The new relay-deploy still deploys and rolls back a release from before migrations.
We deployed Phase 1's a78934c4f1075565faa333a4890d9ddff8f55d2f (#15, no k8s/migrate/ and
no image) by hand, under load: ab -k -c 20 for 7 minutes, plus a /version check every 0.2 s.

  • No migrate Job for a78934c: commits without k8s/migrate/ skip it.
  • The new pod sat in ImagePullBackOff and never got traffic. The 2 old pods kept serving
    (maxUnavailable: 0).
  • After the 180 s rollout timeout, relay-deploy redeployed 0b4746e. Its migrate Job
    (migrate-kgz2p) completed with nothing to apply, so running a migration again does no harm.
  • Afterwards, the same 2 pods as before, 0 restarts, 2/2 on 0b4746e.
ab:        123,904 requests in 420 s, Failed requests: 0, no non-2xx answers
/version:  943 checks, all {"sha":"0b4746e…"} with 200

So #27's go-live list is complete. Both tests leave nothing behind: the Jobs are removed after an
hour (ttlSecondsAfterFinished: 3600).

devinder-dev added a commit that referenced this pull request Nov 2, 2026
….6) (#26)

## What

Step 2.6 of Phase 2: the API connects to Postgres, and the tests run
against a real one.

**Stacked on #27** (base `feat/schema-migrations`; #27 replaces #25):
the integration tests
need migration 001. Before #27 merges, this PR's base is switched to
`main`; then it gets one rebase.

## Why it works this way

- **The API waits for the database before it listens** (at most 60 s, by
the clock). Readiness
never checks the database: one database blip would take every pod out at
once (the point from
the review of #15). A pod with a wrong `DATABASE_URL` never listens, so
its startupProbe fails,
  the rollout fails and `relay-deploy` rolls back.
- **Waiting can't fix a wrong login**, so `28P01` (wrong password),
`28000` (login not allowed)
and `3D000` (no such database) stop it at once. Everything else
(refused, timeout, `57P03`
"starting up") is retried every 2 s. Each try is limited to the time
that is left (at most
5 s). A try that runs over is abandoned, not cancelled: postgres.js
3.4.9's `cancel()` drops
the promise of its own cancel request, so a failed cancel would go
uncaught.
- **A database connection error answers `503 service_unavailable` with
`Retry-After: 5`**, in
our usual error shape. Every other database error stays a 500, including
`57014` (a query
stopped by `statement_timeout`): slow SQL is our bug, not an outage, and
must not look like one.
- **postgres.js 3.4.9**, pinned (4.0.0 came out on 29 Oct). Pool 5 per
pod, idle 30 s,
`connect_timeout` 5 s, `statement_timeout` 5 s, `application_name`
`relay-api`.
- **Signal handlers are set up before the wait**, not after `listen`:
bun is process 1 in the
container, and Linux ignores SIGTERM for process 1 unless it has a
handler. Shutdown is
  `app.close()`, then `sql.end({ timeout: 5 })`.

## Changes

- **API:** `config.ts` (`DATABASE_URL` required, `DB_POOL_MAX` 1–20,
default 5), `db.ts` (new),
`server.ts`, `error-handler.ts`, the comment in `routes/health.ts`; unit
tests with a fake
  database and a fake clock.
- **Tests against a real Postgres:** `api/scripts/test-db.sh` with
`db:up`, `test:db`,
`db:down` and `test:unit`. A throwaway container on `127.0.0.1:55432`,
with production's image
(the digest is read from `k8s/data/postgres.yaml`) and the same
first-start script, so the
tests log in as `relay`. `db:down` removes only the container the script
made (it carries a
label). It takes production's image from `api/scripts/postgres-image.sh`
(#27), the same
script the CI smoke test uses. `api/test/integration/`: the connection
tests and the schema tests.
- **CI:** `api-test` runs `db:up`, then `test:db`. shellcheck also
checks `api/scripts/`.
- **Kubernetes:** `k8s/app/deployment.yaml`: `DATABASE_URL` from Secret
`relay-db` (key `url`);
a startupProbe (45 × 2 s = 90 s, more than the API's own 60 s wait);
liveness without the
15 s delay; `terminationGracePeriodSeconds: 30` written out, with what
has to fit inside it.
- **Docs:** `docs/api.md` (readiness never checks the database; the new
503),
  `docs/CONTEXT.md`, the error table in ADR 0003.

## One change from the reviewed plan

CI starts the test Postgres with `docker run`, the same way as the
existing image smoke test,
instead of a `services:` container. Services start **before** checkout,
so they can't mount
`k8s/data/initdb/`, and the first-start script would not run the way
production runs it.

## How it was checked

- **59 tests pass against the real Postgres, logged in as `relay`.**
They include:
  - `relay` isn't a superuser;
  - our settings arrive on every connection;
- a wrong password stops at the first answer with `28P01` (no retry is
logged);
  - `pg_sleep(6)` is cancelled at 5 s with `57014`;
- a connection ended with `pg_terminate_backend` comes back (a pool of
exactly one connection);
- one valid row per table, two NULL idempotency keys don't collide, the
limits themselves
(20 event types, a 255-character key) are accepted, and deleting an
owner cascades;
- 14 rule-breaking rows, each refused with the right SQLSTATE (`23514`,
`23505`, `23503`,
    `23502`).
- **Negative checks:**
- without `DATABASE_URL`, the integration tests fail (exit 1) and never
skip;
- without `DATABASE_URL`, the API stops at once (exit 1, `→ at
DATABASE_URL`).
- **On the cluster**, a server-side dry run of the Deployment is
accepted with no Pod Security
warnings. The same file with a misspelled probe field is refused
(`--validate=strict`).
- **Other checks:**
  - `tsc --strict` is clean, and so is shellcheck;
  - Snyk Code: 0 findings in our code;
  - `bun audit`: 0 vulnerabilities.

## Changed after the review

- **No cancel on a timed-out try** (above). A unit test checks that a
late error after the
  timeout is still handled.
- **503 codes listed by name:**
- added `57P02` (Postgres crashed), `EPIPE`, `EHOSTUNREACH` and
`ENETUNREACH`;
  - "every `08…`" was replaced by the five connection codes by name;
- `08P01` (protocol violation, a driver bug) and `08007` ("did the
commit happen? unknown")
    now stay a 500: "try again" could save a write twice.
- **The clock:** `waitForDatabase` uses `performance.now()`, which never
jumps when the system
  clock is set.
- **`db:up` always starts fresh.** It removes its own old container
first, so an older image,
  first-start script or port binding can't be reused.
- **No stopwatch checks in the integration tests.** They would be flaky
on a slow runner; the
  SQLSTATE and "no retry was logged" prove the same things.
- **The `terminationGracePeriodSeconds` comment is corrected:** the 5 s
preStop runs inside the
  30 s, before SIGTERM.

- **From the review of #22:** the comment on `RELAY_DB_PASSWORD` in
`k8s/data/postgres.yaml`
now says it stays in the pod's env on purpose, because runbook 6b reads
it again. It's only a
  comment, so Postgres doesn't need to be applied again.

## Before it can go live

It needs #22 applied (done on 1 Nov: the Secret `relay-db` and Postgres)
and #27's migration 001. Without the
Secret, the new pods can't start: the rollout fails and rolls back
(safe, but red).

The order:
1. #22 merged and applied (done);
2. #27 merged (its deploy runs migration 001);
3. this PR rebased and merged.

**Not in this PR:**
- `docs/CONTEXT.md`'s "Where we are": updated at the end of Phase 2.
- The API doesn't read any table yet: Phase 3 gives the routes the pool
with the first repository.
AbdikhaniAbdirashid pushed a commit that referenced this pull request Nov 3, 2026
## What

Closes Phase 2 in `docs/CONTEXT.md`'s "Where we are": what is live, and
the tests that proved it.

## Phase 2, as merged and applied

- **#21:** guards on namespace `relay` (Pod Security `restricted`,
ClusterIP-only Services).
- **#22:** Postgres 18.6 on a disk that is kept on delete, applied by
hand, with the `relay` login.
- **#27** (replaces #25): migration 001, and the migrate Job before
every rollout (ADR 0006).
- **#26:** the API waits for the database before it listens; Postgres in
the tests.

## The live evidence, posted on the PRs

- **#22:** a login from another pod as `relay`, a wrong password
refused, 5432 closed from outside,
and the deletion drill: the data survived deleting the pod, the
StatefulSet and the claim.
- **#27:** the Role checked both ways, the installed script identical to
git, and migration 001
  applied by its Job. Two negative tests:
  - a Job that can't start fails within 150 s;
- a rollback to Phase 1's release under load: 123,904 requests, 0
failed.
- **#26 (live 2 Nov):** both pods log `✅ database answered` before they
listen; `/health/ready`
  answers 200 from outside.

Docs only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants