Repository navigation
feat: schema v1 and migrations before every rollout - #25
devinder-dev wants to merge 4 commits into
Conversation
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Read the migration against docs/api.md and the data model in docs/CONTEXT.md, field by field, plus the Job, the relay-deploy changes and ADR 0006. One thing I think is missing and two questions below; the rest I checked and agree with.
Checked against the contract, field by field
Every validation rule in the schema matches docs/api.md exactly, which is the part I expected to find drift in and did not:
| Contract | Schema |
|---|---|
name 1–100 characters |
CHECK (length(name) BETWEEN 1 AND 100) on owners and endpoints |
url at most 2048 |
CHECK (length(url) <= 2048) |
type matches ^[a-z0-9][a-z0-9_.-]{0,99}$ |
the same regex, character for character |
payload a JSON object |
CHECK (jsonb_typeof(payload) = 'object') |
event_types 1–20 |
CHECK (cardinality(event_types) BETWEEN 1 AND 20) |
idempotency_key ≤ 255, UNIQUE per owner |
both, and NULLs don't collide |
status pending/delivered/dead |
CHECK (status IN (…)) |
The 2048 one matters to me beyond tidiness: I enforced exactly that number in the endpoint form yesterday (#9), so the form, the contract and the column now say the same thing.
Also: CONTEXT.md is updated in the same PR, disabled_at → deleted_at. The data model and the schema can't disagree, which is the right way round.
Three choices I want to say out loud, because they are not obvious
attemptshas nonumbercolumn; it'srow_number()when read. The contract says the number runs 1, 2, 3 across the whole history including after replays — which a stored counter would have to be kept in step with, andattemptson the delivery is already a different number (the retry budget, reset by replay). Two counters with similar names is exactly how they end up disagreeing. Deriving it means it cannot.- text + CHECK instead of an enum. Adding an allowed value is a cheap migration; removing one from a Postgres enum is not. Given forward-only (ADR 0006), that asymmetry is the right way to be wrong.
migrate:downraising an exception rather than being empty. An empty section would delete the row inschema_migrationswhile leaving the tables, so the nextmigratewould try to create them again and fail on something unrelated. Raising rolls the whole rollback back. That is a trap I would have walked into.
One thing I think is missing: nothing maintains updated_at
endpoints.updated_at and deliveries.updated_at are NOT NULL DEFAULT now(), and there is no trigger. So unless every single UPDATE in Phase 3 and Phase 4 sets updated_at = now() by hand, the column quietly means created_at instead — and the one query that forgets is invisible, because a stale timestamp looks like a real one.
Three ways out, and I don't mind which:
- A
BEFORE UPDATEtrigger now, while the schema is new. One function, two triggers. - Keep it manual, and say so in ADR 0003 so the repositories are written that way from the start.
- Drop the columns until something needs them — they aren't in
docs/api.mdand no screen reads them.
I'd pick 1 or 3. Adding a trigger later is its own migration, and option 2 relies on nobody forgetting across two phases of writes.
Two questions
1. next_attempt_at is NOT NULL here, nullable in the contract.
schema: next_attempt_at timestamptz NOT NULL DEFAULT now()
docs/api.md: next_attempt_at: string | null // null unless status is "pending"
I can see why the column is NOT NULL — your comment says that while a row is leased it holds the lease's expiry, so it always has an operational meaning. But that makes the column and the API field two different things, and the read has to blank it whenever status != 'pending'. If one query forgets, the dashboard shows a "next attempt" on a delivered delivery, which is the kind of wrong that looks fine.
Is that mapping going to live in the repository layer, or in a view? Either is fine, I'd just like it written down somewhere before Phase 3, because the dashboard reads next_attempt_at === null as "nothing is coming".
2. SET LOCAL lock_timeout = '5s' is the one line here that can't be tested yet.
SET LOCAL only does anything inside a transaction, and your own comment points out that an empty database has no locks to wait for. So the template works by construction today and we won't find out whether it holds until a migration runs against a live database with traffic — which is the moment we most want it to.
Does dbmate wrap each migration in a transaction by default, and have you confirmed that for 2.37.0 rather than assumed it? If it doesn't, the SET LOCAL is silently a no-op and migration 002 could queue every API query behind a lock instead of giving up after 5 s. Worth one BEGIN-level check now, since every future migration will copy this header.
The deploy path
run_migrations reads well and the failure paths are the ones I'd want: Failed, no verdict, and can't-read all return 1 and hand over to the existing rollback. Two things I specifically liked:
- commits without
k8s/migrate/skip it, so a rollback to a Phase 1 commit still works. That's the kind of backwards compatibility that only shows up when you need it at 2 a.m. - the deployer Role gets
createon jobs but notupdateordelete, andpods/logread-only. Same shape as the no-delete-on-deployments rule: a deploy adds, it never removes.
49 cases passing in test-relay-deploy.sh, including a Job that stays Pending creating exactly one Job and giving up after 90 reads, is the part that makes me comfortable with this touching production's deploy script.
Order
Agreed on applying deployer.yaml before installing the new script — the other way round, the new script can't create its Job and the rollback fails the same way. Please post the output of step 3, the by-hand deploy that runs migration 001, when you do it.
Not approving yet, only because of updated_at: it is a two-line fix now and an extra migration later. Say which of the three you want and I'll re-review straight away — or push back if you think manual is fine and I'll approve on that.
|
One more thing, found while reviewing #26 rather than this PR, so it didn't make my first pass. The migration smoke test hardcodes the Postgres digest in postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4But #26's So after both PRs land, the same digest lives in two places with two different mechanisms. Bump Same fix as #26 already uses: read it out of the manifest. Something like - name: Read the Postgres digest from the manifest
run: echo "PG_IMAGE=$(grep -oP 'postgres:[0-9.]+@sha256:[0-9a-f]+' k8s/data/postgres.yaml | head -1)" >> "$GITHUB_ENV"and then use Worth folding into the |
> **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 #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?
….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.
## 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.
What
Step 2.4 of Phase 2: every deploy runs the commit's database migrations first, before any pod
changes.
api/db/migrations/…_init.sql,api/package.json+bun.lock,api/Dockerfiledeleted_atfor the soft delete and named indexes;downraises an error on purpose). dbmate 2.37.0, pinned, inside the API image, run asbun run --bun db:migrate(dbmate … migrate).github/workflows/pipeline.ymlapi-imagebuilds 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 maink8s/migrate/job.yamlgenerateName,backoffLimit: 0, deadline 150 s, pod grace 10 s, TTL 1 h, Pod Securityrestrictedinfra/server/relay-deploy.shrun_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 withoutk8s/migrate/skip this, so a rollback to Phase 1 still worksk8s/platform/deployer.yamljobs(get, list, watch, create: no update, no delete) andpods/log(get)infra/server/test-relay-deploy.shsleep, and a 30 s watchdog so a hanging script fails the test instead of CIpipeline.ymldocs/decisions/0006-migrations-expand-contract.mdk8s/README.md,docs/CONTEXT.mdk8s/migrate/;deleted_atand the migrations row in CONTEXTTested before the PR
db:migratecreates the 6 tables, owned byrelay, and a second run does nothing;dbmate rollbackis refused and the history stays;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".
rules change. shellcheck, actionlint and
bun auditare clean.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:k8s/platform/deployer.yamlfirst, and check as the deployer:create jobsandget 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.)
relay-deploy(install -o root -g root -m 755) anddiffit against git.sudo -u deploy env SSH_ORIGINAL_COMMAND=<sha> relay-deploy).This runs migration 001:
migrating with job.batch/migrate-…, thenApplying: …_init.sql.schema_migrationsand the tables asrelay. One hour later, the old Jobs are gone.Failed(
DeadlineExceeded), and the legacy rollback test (Phase 1's commita78934c…, full SHA)still rolls back with 0 failed requests.
For the reviewer
pods/loglets the deployer read every pod's log inrelay. Acceptable, given that nothinglogs a secret?