From b581f54f0b8823aa3a60e961dc279d1b985003c5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:41:34 -0700 Subject: [PATCH] fix(selfhost): gate gittensory's depends_on on postgres/pgbouncer health gittensory's depends_on only gated on redis and qdrant, never postgres or pgbouncer, even though --profile postgres/--profile pgbouncer are documented first-class deployment modes. On a cold Postgres volume, compose started gittensory in parallel with postgres initdb instead of waiting for the healthcheck; the app's own waitForPostgres() fallback (~30s hard cap) commonly lost that race on a slow disk/first pull, crash-looping the app via restart: unless-stopped before Postgres was reachable. Add postgres/pgbouncer to depends_on with condition: service_healthy, required: false (mirroring the existing qdrant entry), and give pgbouncer a pg_isready healthcheck so there's an actual readiness signal to gate on -- it had none, so nothing could depend on it even conceptually. required: false keeps the default (no-profile) SQLite path unaffected. --- docker-compose.yml | 20 ++++++++ test/unit/selfhost-compose-db-health.test.ts | 52 ++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 test/unit/selfhost-compose-db-health.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index b3ebd56f22..d9ae96b769 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,17 @@ services: qdrant: condition: service_healthy required: false + # Same pattern for --profile postgres / --profile pgbouncer: without this, compose starts gittensory + # in parallel with a cold Postgres initdb instead of waiting for it, and the app's own waitForPostgres() + # fallback (~30s hard cap) commonly loses that race on a slow disk/first pull, crash-looping the app via + # restart: unless-stopped before Postgres is reachable. required:false keeps the default SQLite path + # (no profile active) unaffected. + postgres: + condition: service_healthy + required: false + pgbouncer: + condition: service_healthy + required: false healthcheck: # Probe /ready (not /health): /health is a liveness stub that is 200 even when the DB is down, # whereas /ready returns 503 until the DB answers AND migrations are applied — so dependents that @@ -161,6 +172,15 @@ services: MAX_CLIENT_CONN: "200" DEFAULT_POOL_SIZE: "20" AUTH_TYPE: md5 + # The image ships the full postgresql-client toolchain (Alpine-based), so the same probe postgres itself + # uses works here: pg_isready only checks that the server accepts the startup packet, not that auth + # succeeds, so it reports "accepting connections" even before the upstream Postgres is reachable through + # the pooler — exactly the pooler-layer liveness signal gittensory's depends_on needs. + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"] + interval: 10s + timeout: 5s + retries: 5 # Postgres internals exporter. Starts with the Postgres profiles so Prometheus can scrape it when # observability is enabled, without starting against a missing DB on SQLite-only installs. diff --git a/test/unit/selfhost-compose-db-health.test.ts b/test/unit/selfhost-compose-db-health.test.ts new file mode 100644 index 0000000000..8fccaac4ab --- /dev/null +++ b/test/unit/selfhost-compose-db-health.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; +import { describe, expect, it } from "vitest"; + +function readYaml(path: string): Record { + const value = parse(readFileSync(path, "utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${path} must be a YAML object`); + } + return value as Record; +} + +// Pure structural checks only (no `docker` CLI invocation): the self-hosted runner container this actually +// runs on does not have Docker-in-Docker access, so a test that shells out to `docker compose config` +// would be unreliable/environment-dependent here (same constraint as docker-compose-override-example.test.ts). +describe("docker-compose.yml — postgres/pgbouncer startup ordering (#2500, #2503)", () => { + it("gates the core app on a healthy postgres and pgbouncer without requiring either profile", () => { + const compose = readYaml("docker-compose.yml"); + const services = (compose.services as Record>) ?? {}; + const app = services.gittensory ?? {}; + const dependsOn = app.depends_on as Record; + + // redis stays required (always-on service); postgres/pgbouncer/qdrant are all required:false since only + // a subset of profiles start them — a default (no-profile) SQLite deployment must not wait on any of them. + expect(dependsOn.redis).toEqual({ condition: "service_healthy" }); + expect(dependsOn.postgres).toEqual({ condition: "service_healthy", required: false }); + expect(dependsOn.pgbouncer).toEqual({ condition: "service_healthy", required: false }); + expect(dependsOn.qdrant).toEqual({ condition: "service_healthy", required: false }); + }); + + it("gives pgbouncer a healthcheck so gittensory's depends_on has a real readiness signal to gate on", () => { + const compose = readYaml("docker-compose.yml"); + const services = (compose.services as Record>) ?? {}; + const pgbouncer = services.pgbouncer ?? {}; + const healthcheck = pgbouncer.healthcheck as { test?: unknown[]; interval?: string; retries?: number }; + + // pg_isready only confirms the server accepts the startup packet, not that auth succeeds against the + // real upstream postgres -- correct for a pooler-layer liveness probe, mirroring the postgres service's + // own healthcheck shape (also pg_isready-based). + expect(healthcheck.test).toEqual(["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"]); + expect(healthcheck.retries).toBeGreaterThan(0); + }); + + it("still starts pgbouncer only after a healthy postgres (unaffected by the new gittensory dependency)", () => { + const compose = readYaml("docker-compose.yml"); + const services = (compose.services as Record>) ?? {}; + const pgbouncer = services.pgbouncer ?? {}; + const dependsOn = pgbouncer.depends_on as Record; + + expect(dependsOn.postgres).toEqual({ condition: "service_healthy" }); + }); +});