Repository navigation
feat: connect the API to Postgres, with Postgres in the tests (step 2.6) - #26
Conversation
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Read db.ts, server.ts, error-handler.ts, the integration tests, test-db.sh and the deployment changes, and checked the pin against npm. Approving. One forward-looking note at the end — it isn't a defect here, but it becomes one in step 3.2 if nobody writes it down.
Four things I checked rather than read past
The pin, against the registry:
postgres 3.4.9 published 2026-04-05
postgres 4.0.0 published 2026-10-29 ← three days old
"postgres": "3.4.9", exact, no caret. That is the same two-weeks-old rule I used to pick Node 24 over 26 in ADR 0005, applied to a different dependency on the same day. Worth saying out loud because it means the rule is actually a rule and not a line in a document.
pingDatabase abandons rather than cancels, and the reasoning holds. You say Promise.race still handles the abandoned query's late answer or error — it does, and it is worth being precise about why: race attaches then(resolve, reject) to every input promise, so the slow query already has a rejection handler on it even after the race has settled on the timeout. A late rejection is absorbed instead of becoming an unhandled rejection. If you had instead written await Promise.any or raced a wrapper that drops the reference, that would not be true.
Signal handlers before the wait. bun is process 1 in the container, and Linux ignores SIGTERM for process 1 unless there is a handler — so a pod stopped during the 60 s wait would sit there until SIGKILL. Setting them up first costs nothing and removes a 30 s stall nobody would ever reproduce locally, where the process isn't PID 1.
startupProbe 45 × 2 s = 90 s against the app's own 60 s deadline. The ordering is the right way round: the app gives up first, with ❌ database not reachable, stopping in the log, instead of being killed by the probe halfway through a retry. If the probe were the shorter of the two, every one of these failures would look identical in kubectl describe and tell you nothing about why.
The line I liked most
// 08007 "did the commit happen? unknown". 503 means "try again", which could save it twice
That is the sharpest distinction in the PR, and it is the kind of thing that is invisible until it has already happened twice in production. Same for keeping 57014 a 500: a query killed by statement_timeout is our slow SQL, and dressing it as an outage would mean we never fix it. The comment block listing what is deliberately not in the set is worth more than the set itself.
One note for step 3.2
isDatabaseUnavailable tests the shape of an error code, not its origin. Most of the set is unambiguous — the SQLSTATE codes and the CONNECTION_* ones can only come from postgres.js. But these are generic Node socket and DNS codes:
ECONNRESET ETIMEDOUT EPIPE EHOSTUNREACH ENETUNREACH ENOTFOUND EAI_AGAIN
Today that is fine: nothing else in the API does network I/O, so any of those can only have come from the pool.
Step 3.2 changes that. The server-side SSRF check has to resolve the customer's host name — that is the whole point of doing it server-side, since the form can only see the name. A customer endpoint whose DNS doesn't resolve raises ENOTFOUND or EAI_AGAIN, and if that reaches the error handler unconverted, the API answers
503 service_unavailable, Retry-After: 5
"The service is briefly unavailable, try again shortly"
— telling the customer our database is down when the truth is their URL doesn't resolve. The contract says that case is 422 invalid_endpoint_url. And it is the worst shape of wrong: the customer retries after 5 s, gets the same answer, and concludes Relay is broken.
Two ways out, and I prefer the first:
- The SSRF check catches its own DNS errors and converts them to
invalid_endpoint_urlbefore they ever leave the service. That is where the knowledge belongs anyway: only that code knows the lookup was for a customer's host and not for ours. - Make
isDatabaseUnavailablerequire the error to have come from a query, so the generic codes only count when the pool raised them.
Nothing to change in this PR — the set is correct for the code that exists. But a line in db.ts next to the generic codes, something like "generic Node codes: only unambiguous while the pool is the only thing doing network I/O — 3.2 resolves customer host names and must convert its own DNS errors", would put the warning where whoever writes 3.2 will actually be looking.
The test setup
test-db.sh reading the Postgres digest out of k8s/data/postgres.yaml is the right instinct — one place for the version, and the tests run against production's image rather than something close to it. Logging in as relay instead of as the superuser matters for the same reason: a test that runs as superuser can't catch a missing GRANT.
Which brings up the one inconsistency across the stack: #25's migration smoke test hardcodes that digest in pipeline.yml instead of reading it the way test-db.sh does. Bump the version in postgres.yaml and the smoke test quietly keeps testing the old image. It is one PR down the stack so I will leave it there rather than block this — I have added it as a note on #25.
Approving. Please post the integration-test output when you run it against the real cluster, and I would like to see ❌ database not reachable, stopping once with a deliberately wrong DATABASE_URL, since the rollback path depends on that pod failing to listen rather than starting unhealthy.
1279c80 to
22883d8
Compare
998e9f2 to
b3b59d4
Compare
b3b59d4 to
8a90d3d
Compare
> **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?
The base branch was changed.
c0c5032 to
81e7b63
Compare
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Re-approving after the rebase. My approval of 998e9f2 was dismissed by the force-push, so I
compared file contents between that commit and 81e7b63 rather than the patches — at review
time the branch sat on an older schema branch, which makes the patches useless for this.
37 files differ, 34 of them only because main caught up (#9's dashboard, #27's migrations).
Three are yours:
| File | Change |
|---|---|
.github/workflows/pipeline.yml |
the smoke test reads the digest through postgres-image.sh |
api/scripts/test-db.sh |
its own prod_image() is gone, same script instead |
k8s/data/postgres.yaml |
the RELAY_DB_PASSWORD comment from #22 |
No behaviour changed, and the one inconsistency I found is closed: there is now a single place
that knows which Postgres image we run.
Three things I ran rather than read:
postgres-image.shprints the pinned image, and with the digest taken out of the manifest it
exits 1 with its own message on stderr.set -eaborts the caller onPG_IMAGE=$(…). So the "on its own line" comment is exactly
right, and worth keeping: inline asdocker run … "$(…)"the shell would have carried on with
an empty image name.REPO_DIRdidn't become a dead variable whenprod_image()left —INITDB_DIRstill uses it.
Prerequisites, from outside: Secret relay-db has url and password, schema_migrations
holds 20261031140445, and postgres-0 plus both API pods are Running. The rollout should be
green.
Still open, and still not a blocker: the generic Node codes in db.ts have no line about step
3.2. I'd rather it sat next to ENOTFOUND than in this thread, where whoever writes the SSRF
check won't be looking — but it's a comment, so it can ride along with 3.2 instead of holding
this up. Nit: two blank lines left where prod_image() was.
## 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.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 testsneed migration 001. Before #27 merges, this PR's base is switched to
main; then it gets one rebase.Why it works this way
never checks the database: one database blip would take every pod out at once (the point from
the review of infra: add the API manifests (2 pods, probes, zero-downtime rollout) #15). A pod with a wrong
DATABASE_URLnever listens, so its startupProbe fails,the rollout fails and
relay-deployrolls back.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()dropsthe promise of its own cancel request, so a failed cancel would go uncaught.
503 service_unavailablewithRetry-After: 5, inour usual error shape. Every other database error stays a 500, including
57014(a querystopped by
statement_timeout): slow SQL is our bug, not an outage, and must not look like one.connect_timeout5 s,statement_timeout5 s,application_namerelay-api.listen: bun is process 1 in thecontainer, and Linux ignores SIGTERM for process 1 unless it has a handler. Shutdown is
app.close(), thensql.end({ timeout: 5 }).Changes
config.ts(DATABASE_URLrequired,DB_POOL_MAX1–20, default 5),db.ts(new),server.ts,error-handler.ts, the comment inroutes/health.ts; unit tests with a fakedatabase and a fake clock.
api/scripts/test-db.shwithdb:up,test:db,db:downandtest:unit. A throwaway container on127.0.0.1:55432, with production's image(the digest is read from
k8s/data/postgres.yaml) and the same first-start script, so thetests log in as
relay.db:downremoves only the container the script made (it carries alabel). It takes production's image from
api/scripts/postgres-image.sh(feat: schema v1 and migrations before every rollout #27), the samescript the CI smoke test uses.
api/test/integration/: the connection tests and the schema tests.api-testrunsdb:up, thentest:db. shellcheck also checksapi/scripts/.k8s/app/deployment.yaml:DATABASE_URLfrom Secretrelay-db(keyurl);a startupProbe (45 × 2 s = 90 s, more than the API's own 60 s wait); liveness without the
15 s delay;
terminationGracePeriodSeconds: 30written out, with what has to fit inside it.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 mountk8s/data/initdb/, and the first-start script would not run the way production runs it.How it was checked
relay. They include:relayisn't a superuser;28P01(no retry is logged);pg_sleep(6)is cancelled at 5 s with57014;pg_terminate_backendcomes back (a pool of exactly one connection);(20 event types, a 255-character key) are accepted, and deleting an owner cascades;
23514,23505,23503,23502).DATABASE_URL, the integration tests fail (exit 1) and never skip;DATABASE_URL, the API stops at once (exit 1,→ at DATABASE_URL).warnings. The same file with a misspelled probe field is refused (
--validate=strict).tsc --strictis clean, and so is shellcheck;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:
57P02(Postgres crashed),EPIPE,EHOSTUNREACHandENETUNREACH;08…" was replaced by the five connection codes by name;08P01(protocol violation, a driver bug) and08007("did the commit happen? unknown")now stay a 500: "try again" could save a write twice.
The clock:
waitForDatabaseusesperformance.now(), which never jumps when the systemclock is set.
db:upalways 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
terminationGracePeriodSecondscomment is corrected: the 5 s preStop runs inside the30 s, before SIGTERM.
From the review of infra: Postgres 18.6 on a disk that survives deletion #22: the comment on
RELAY_DB_PASSWORDink8s/data/postgres.yamlnow 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-dband Postgres) and #27's migration 001. Without theSecret, the new pods can't start: the rollout fails and rolls back (safe, but red).
The order:
Not in this PR:
docs/CONTEXT.md's "Where we are": updated at the end of Phase 2.