Skip to content

fix(worker): survive a database restart; clean exit when the database is down at start - #42

Open
Aspirant200715 wants to merge 6 commits into
mergit-io:mainfrom
Aspirant200715:fix/worker-db-resilience
Open

Aspirant200715 wants to merge 6 commits into
mergit-io:mainfrom
Aspirant200715:fix/worker-db-resilience

Conversation

@Aspirant200715

Copy link
Copy Markdown
Collaborator

Follow-up to the #38 review notes.

What changed

  • WorkerIdLock.still_held() reconnects and takes the lock again after the database drops the session. A watchdog checks every 10s. If another process took the id meanwhile, the worker stops after its current job (exit 2).
  • A database that is down at start-up exits 1 with one error line instead of a traceback; Docker restarts it.
  • deploy/worker-vm/README.md: two new troubleshooting rows.

What I verified

  • Full control-plane suite: 565 passed (-m 'not gvisor'). ruff and pyright clean.
  • 5 new tests against a real Postgres container. Breaking each fix makes a test fail.

Not verified

  • A real 'docker restart cp_postgres' against a running worker VM. Tests use pg_terminate_backend, which drops the session the same way.

Aspirant200715 and others added 2 commits October 31, 2026 23:40
…i host, /healthz

- deploy/worker-vm: compose file, runbook, env template and preflight for the agent worker on its own VM
- worker: refuses a second process with the same WORKER_ID, can require every agent key,
  sweeps abandoned workspaces, warns when named after its hostname
- deploy/worker-vm/db-role.sql: a mergit_worker role that can touch only jobs, pr_requests, plans and tasks
- api: GET /healthz (database check), compose healthcheck, update.sh waits for it
- Caddy: api.mergit.site serves browser routes and /healthz; /v0/internal only from WORKER_VM_IP
- cp_postgres published on CP_DB_BIND (loopback by default)
…e compose

The postgres:16 image's pg_hba ends with `host all all all scram-sha-256`, so
once CP_DB_BIND publishes cp_postgres on the private network the worker VM can
log in as the superuser with the password committed in compose.controlplane.yaml,
which makes the restricted role pointless. cp_postgres now uses
deploy/postgres/pg_hba.conf: any role from the compose network (samenet), and
from anywhere else only mergit_worker on mergit_cp. In-container socket access
(backup.sh, wallet-interest.sh, create-db-role.sh) is unchanged.
…ipt, fail-fast db connects

Follow-up to Viscous106's review of mergit-io#38.

- pg_hba.conf matched the compose network with samenet, which under some Docker
  network settings (docker-proxy's own bridge gateway, iptables: false) is the
  address a published port sees a connection arrive from: the superuser,
  password and all, would then be reachable from outside. The compose network
  now pins a subnet (networks.default.ipam) and pg_hba.conf matches that exact
  CIDR instead.
- api's 8000:8000 published to every interface, bypassing the Caddy lockdown
  for a run without compose.server.yaml. Bound to 127.0.0.1.
- deploy/worker-vm/README.md ran create-db-role.sh before the stack that
  creates the control plane's tables, which create-db-role.sh itself says it
  needs. Swapped the order.
- The three places a worker or the api reconnects to Postgres (WorkerIdLock,
  PgJobStore, PgPrStore) had no connect_timeout, so a database that is down or
  unreachable hung the call forever instead of letting start-up or a
  reconnect fail and get retried.

Two new tests pin the subnet and the api binding so they cannot drift back to
samenet or an open port silently.

Checked locally: full control-plane suite (328 passed), ruff and pyright
clean, and `docker compose config` resolves correctly for
compose.testnet+controlplane, and with compose.server.yaml layered on top
(api's port is fully unpublished there, as before).
@OfficialAbhinavSingh

Copy link
Copy Markdown
Member

this branch carries #38's commits plus its own 5540bb4 (retake the lock after a database restart). checked that commit on top of #38's latest fix push (ddd1c04) locally: one real conflict in startup.py where both touched WorkerIdLock.acquire, resolved by moving connect_timeout=10 into the shared _take() helper that acquire and still_held both go through now. full suite green after that (328 passed), ruff and pyright clean. not pushed here since #38 itself needs a real rebase first (see my comment there on the Caddyfile/app.py conflicts against current main) — this branch will need the same once #38 is sorted.

on 5540bb4 itself: it covers a database restart dropping the session (reconnect + retake, watchdog every 10s), which is good and well tested. it does not cover the power-loss case from Viscous106's review of #38 (a VM that dies without sending a FIN, so Postgres doesn't notice for ~2h without tuned keepalives) — that's a separate, still-open item.

@Viscous106 Viscous106 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I compared the current #42 against #38. #42 is stacked on an older snapshot of #38, not independent: both share a base, and 19 of the 25 files are byte-identical. #42 adds the lock reconnect (still_held), the watch_lock thread, exit 1 when the DB is down at start, exit 2 on lock loss, restart tests in test_worker_role.py and two README rows. I read the diffs only and ran nothing.

Merging this as-is would regress #38's newer fixes, which #42 lacks:

  • the pinned subnet 172.30.9.0/24 and exact-CIDR pg_hba.conf (#42 is back to samenet)
  • 127.0.0.1:8000 for the API port, with its test
  • the README order fix
  • connect_timeout=10 in WorkerIdLock.acquire, pr_store.py and jobs.py (#42 doesn't touch the last two)

Every shared file is add/add, so whichever merges second will conflict. Suggested order: land #38 first (after its own fixes and a rebase on main), then rebase this down to its delta only (startup.py, __main__.py, test_worker_role.py and the two README rows).

Should fix in this PR

  1. No connect_timeout in _take() (startup.py, ~line 130 of the new file). On a blackholed DB the connect hangs, so the "clean exit when the database is down at start" in the title never fires. The same applies to the watcher's reconnect.
  2. Lock still not released after a power loss. There are no TCP keepalives anywhere, and still_held's SELECT 1 can block forever on a half-open socket, so the watchdog never notices the loss. Set keepalives_idle, keepalives_interval and keepalives_count on the lock connection, or tcp_keepalives_* on the server, and give SELECT 1 a timeout.
  3. Watcher races close(). __main__.py finally does stop.set(); built.close(); lock.close() while the watcher may be inside still_held(). It can reassign _conn after _drop(), leaking the connection and the lock (daemon thread, no join). Join the watcher before closing.
  4. Test safety: test_worker_role.py runs DROP ROLE mergit_worker, which is cluster-wide. If TEST_DATABASE_URL points at a dev or prod cluster, it destroys the real role.
  5. Lock-loss timing: exit 2 only happens after the current job finishes, so that job's result is published after another process owns the id. Fine given the 40-minute grace period, but please add a comment.

Everything else from my #38 review (/healthz rate limiting, cleartext DB link, update.sh exit code, CONNECT not revoked from PUBLIC) applies here too.

Not verified: tests, compose, CI, Docker source-IP behaviour, and the current origin/main.

This branch has not been deployed

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

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants