Skip to content

P1: Add readiness probe distinct from liveness (#169) - #187

Merged
dkijania merged 2 commits into
mainfrom
feat/readiness-probe
Aug 24, 2026
Merged

P1: Add readiness probe distinct from liveness (#169)#187
dkijania merged 2 commits into
mainfrom
feat/readiness-probe

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

Part of the production-readiness epic (#163). Closes #169.

/healthcheck is Yoga's built-in liveness check — it only confirms the process is serving HTTP, not that the database is reachable. An orchestrator therefore keeps routing traffic to an instance whose Postgres is down.

Changes

  • New /readiness endpoint (src/server/readiness.ts) that pings the DB and returns 200 when it answers, 503 otherwise.
  • ping() added to the database adapter — runs SELECT 1, resolves false instead of throwing.
  • The readiness plugin is prepended to the plugin list so probes short-circuit before any other request hook (e.g. rate limiting) can interfere.

Probe usage

Endpoint Probe Checks
/healthcheck liveness process is up (no DB)
/readiness readiness DB reachable (SELECT 1)

A node with an unreachable DB reports not-ready (stops getting traffic) while staying live (not needlessly restarted).

Testing

  • npm run build — clean
  • npm run test:unit — all pass (200/503 end-to-end through Yoga; confirms normal GraphQL requests are not intercepted)
  • npm run lint — clean
  • npx prettier --debug-check . — exit 0

🤖 Generated with Claude Code

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Verdict: NOT MERGEABLE (blocking) 🚫

One blocker. Everything else in this PR checks out — the shape is right (distinct endpoints, DB-free liveness, clean body, real test), the problem is that the readiness ping shares the main query pool and has no upper bound, which under load turns "slow" into "every replica NotReady at once".

What I checked

  • Liveness stays DB-free. healthCheckEndpoint: '/healthcheck' (src/server/server.ts:21) resolves to Yoga's built-in useHealthCheck, which returns new Response(null, {status: 200, headers: {'x-yoga-id': …}}) with no DB touch (node_modules/graphql-yoga/cjs/plugins/use-health-check.js:6-16). Path unchanged, so .github/workflows/smoke-load-test.yaml:80 still passes. No HEALTHCHECK in the repo Dockerfile and no manifests in-tree today, so nothing in-repo breaks. P2: Reference deployment manifests — k8s + prod Compose (#179) #196 already targets /healthcheck (liveness) + /readiness (readiness) — paths match this PR exactly, no coordination needed there.
  • { __typename } is unaffected, which is what mina-explorer-api actually probes with (/Users/sanabriarusso/github/mina-explorer-api/app/observability.py:307 READINESS_QUERY = "{ __typename }", POSTed to the root archive endpoint). The plugin only fires on url.pathname !== READINESS_PATH return (src/server/readiness.ts:21), and your third test asserts exactly this. mina-explorer (browser SPA) doesn't hit either probe path. No GraphQL error text is touched, so HARD CONSTRAINT Add Actions resolver support #1 is untouched.
  • No information disclosure. The body is {"status":"ready"|"not ready"} and nothing else; ping() swallows the pg error entirely (archive-node-adapter.ts new catch { return false }), so no host/port/connstring/driver text reaches an unauthenticated caller. Good — keep it that way (my patch below logs the error server-side only).
  • Plugin ordering claim is correct. whatwg-node runs onRequest hooks in registration order and endResponse calls stopEarly() (node_modules/@whatwg-node/server/cjs/createServerAdapter.js:55-70). Yoga puts its internals first and then splices ...options.plugins (node_modules/graphql-yoga/cjs/server.js:145-215), so [useReadiness(...), ...plugins] does land ahead of P0: Add per-IP request rate limiting (#166) #185's useRateLimit() (which P0: Add per-IP request rate limiting (#166) #185 pushes first in buildPlugins()). Cross-PR note for P0: Add per-IP request rate limiting (#166) #185: its exemption list only names /healthcheck (src/server/rate-limit.ts HEALTHCHECK_PATH); /readiness is protected purely by this ordering, not by an exemption. If either PR is rebased and the prepend is lost, kubelet gets 429 on /readiness and pods leave rotation. Worth an explicit /readiness entry in P0: Add per-IP request rate limiting (#166) #185's exempt set as belt-and-braces, plus a test asserting the order. CORS (P0: Secure CORS default instead of '*' (#167) #184) is not a factor: useCORS.onRequest only short-circuits OPTIONS (node_modules/@whatwg-node/server/cjs/plugins/useCors.js:98-110), and its onResponse still decorates the readiness response.
  • Status codes are right (200/503, not 500) and GET works — the hook is method-agnostic.

Blocker — the readiness ping runs on the shared pool with no timeout, so DB slowness reads as DB down on every replica simultaneously

What breaks. All replicas go NotReady together during a load spike in which Postgres is perfectly healthy, just busy. The Service loses every endpoint, the ingress has no backends, and mina-explorer / mina-explorer-api get connection failures with no errors[] body at all — not the tolerated non-2xx-with-errors path (mina-explorer src/services/api/client.ts re-parses the body; there is no body here). It is also self-amplifying: a pod that goes unready sheds its traffic onto its peers, which pushes them over the same edge.

Why. Two facts compose:

  1. ping() uses the same this.client pool as every GraphQL query (src/db/archive-node-adapter/archive-node-adapter.ts:98, this.client\SELECT 1`). postgres.js defaults are max: 10, connect_timeout: 30, and **no statement/query timeout at all** (node_modules/postgres/src/index.js:449-462); the adapter passes no options (archive-node-adapter.ts:43, postgres(connectionString)`).
  2. When all pooled connections are busy, postgres.js does not open more — it pipelines onto a busy connection: busy.length ? go(busy.shift(), query) : queries.push(query) (node_modules/postgres/src/index.js:330-336). Postgres executes a connection's queries serially, so SELECT 1 sits head-of-line-blocked behind whatever that connection is already running.

What is already running is the worst case in the brief: the explorer's analytics query pulls ANALYTICS_BLOCK_LIMIT = 2000 blocks in one shot (mina-explorer src/services/api/analytics.ts:14), and mina-explorer-api pages blocks at limit 500 continuously from its indexer. Ten of those in flight and SELECT 1 waits for one to finish — with no statement_timeout, that is unbounded. Neither useReadiness nor ping() imposes a deadline (src/server/readiness.ts:23, plain await db.ping()), so the HTTP response simply never arrives; kubelet gives up at #196's timeoutSeconds: 5 and, after the default failureThreshold: 3 × periodSeconds: 10, the pod is pulled from the Service. Every replica shares the same Postgres, so they cross that line at the same moment.

Secondary, same root cause: during a genuine network blackhole to Postgres (dropped packets rather than refused connections), each probe leaves a pending query alive for the full 30s connect_timeout while kubelet has long since walked away — pending handlers pile up at one per periodSeconds.

Fix. Two small changes: give pings their own single connection so they can never queue behind analytics traffic, and bound the wait in the plugin so the server answers 503 itself instead of hanging. Splitting it this way also makes the timeout unit-testable without a database.

src/db/archive-node-adapter/archive-node-adapter.ts:

+/**
+ * Connect deadline for the readiness pinger, in seconds. Kept short so a probe
+ * fails fast rather than hanging for the driver's 30s default.
+ */
+const PING_CONNECT_TIMEOUT_S = 2;
+
 export class ArchiveNodeAdapter implements DatabaseAdapter {
   private client: postgres.Sql;
+  /**
+   * Dedicated single-connection client for readiness pings. Deliberately kept
+   * off the main pool: postgres.js pipelines onto busy connections, so a ping
+   * issued on `client` can sit behind a 2000-block analytics query and make a
+   * merely-busy database look unreachable.
+   */
+  private pingClient: postgres.Sql;
   private eventsService: IEventsService;
@@
     this.client = postgres(connectionString);
+    this.pingClient = postgres(connectionString, {
+      max: 1,
+      idle_timeout: 60,
+      connect_timeout: PING_CONNECT_TIMEOUT_S,
+    });
     this.eventsService = new EventsService(this.client);
@@
   async ping(): Promise<boolean> {
     try {
-      await this.client`SELECT 1`;
+      await this.pingClient`SELECT 1`;
       return true;
-    } catch {
+    } catch (error) {
+      // Server-side log only — the probe response body never carries this.
+      console.warn(
+        `[readiness] database ping failed: ${
+          error instanceof Error ? error.message : String(error)
+        }`
+      );
       return false;
     }
   }
@@
   async close() {
-    return this.client.end();
+    await Promise.all([this.client.end(), this.pingClient.end()]);
   }

src/server/readiness.ts:

 const READINESS_PATH = '/readiness';
 
+/**
+ * Upper bound on a single readiness ping. Must stay comfortably under the
+ * kubelet's `timeoutSeconds` so the server answers 503 itself rather than
+ * leaving the probe to time out with no response at all.
+ */
+const DEFAULT_PING_TIMEOUT_MS = 2000;
+
+function pingTimeoutFromEnv(value: string | undefined): number {
+  const parsed = Number(value);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PING_TIMEOUT_MS;
+}
+
+/** Resolves, never rejects: a probe endpoint must not be able to return 500. */
+async function pingWithin(
+  db: Pick<DatabaseAdapter, 'ping'>,
+  timeoutMs: number
+): Promise<boolean> {
+  let timer: NodeJS.Timeout | undefined;
+  const timedOut = new Promise<boolean>((resolve) => {
+    timer = setTimeout(() => {
+      console.warn(`[readiness] database ping exceeded ${timeoutMs}ms`);
+      resolve(false);
+    }, timeoutMs);
+  });
+  const answered = Promise.resolve()
+    .then(() => db.ping())
+    .catch(() => false);
+  try {
+    return await Promise.race([answered, timedOut]);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
-function useReadiness(db: Pick<DatabaseAdapter, 'ping'>): Plugin {
+function useReadiness(
+  db: Pick<DatabaseAdapter, 'ping'>,
+  timeoutMs: number = pingTimeoutFromEnv(process.env.READINESS_PING_TIMEOUT_MS)
+): Plugin {
   return {
     async onRequest({ url, endResponse, fetchAPI }) {
       if (url.pathname !== READINESS_PATH) return;
 
-      const ready = await db.ping();
+      const ready = await pingWithin(db, timeoutMs);
       endResponse(
         new fetchAPI.Response(
           JSON.stringify({ status: ready ? 'ready' : 'not ready' }),
           {
             status: ready ? 200 : 503,
-            headers: { 'content-type': 'application/json' },
+            headers: {
+              'content-type': 'application/json',
+              'cache-control': 'no-store',
+            },
           }
         )
       );
     },
   };
 }

src/envionment.d.ts — add alongside the other optional vars:

       CORS_ORIGIN?: string;
+      READINESS_PING_TIMEOUT_MS?: string;

docs/getting-started.md — add to the env table (next to CORS_ORIGIN), and note the probe defaults you are recommending:

 | `CORS_ORIGIN` | `*` | CORS allowed origin |
+| `READINESS_PING_TIMEOUT_MS` | `2000` | Upper bound on the `/readiness` database ping. Exceeding it returns 503 rather than leaving the probe to hang. Keep it below the orchestrator's probe `timeoutSeconds`. |
 Use `/healthcheck` as the Kubernetes **liveness** probe and `/readiness` as the **readiness** probe: a node whose database is unreachable reports not-ready (so it stops receiving traffic) while staying live (so it isn't needlessly restarted).
+
+Suggested probe settings — readiness must tolerate a brief blip, because every replica shares one Postgres and would otherwise leave rotation simultaneously:
+
+```yaml
+livenessProbe:   # process only; never let this depend on the DB
+  httpGet: { path: /healthcheck, port: 8080 }
+  initialDelaySeconds: 10
+  periodSeconds: 15
+  timeoutSeconds: 5
+  failureThreshold: 3
+readinessProbe:
+  httpGet: { path: /readiness, port: 8080 }
+  initialDelaySeconds: 5
+  periodSeconds: 10
+  timeoutSeconds: 3          # > READINESS_PING_TIMEOUT_MS
+  failureThreshold: 3        # ~30s of continuous failure before leaving rotation
+  successThreshold: 1
+```

Regression test — append to tests/unit/readiness.test.ts (the first one fails today: an unbounded ping currently hangs the request instead of answering 503; the second guards the no-500 / no-leak property):

  test('reports not-ready when the ping exceeds its timeout', async () => {
    const yoga = createYoga({
      schema,
      graphqlEndpoint: '/',
      // A ping that never settles — the shape of a SELECT 1 stuck behind a
      // long-running analytics query on the same connection.
      plugins: [useReadiness({ ping: () => new Promise<boolean>(() => {}) }, 20)],
    });
    const response = await yoga.fetch(`http://localhost${READINESS_PATH}`);
    assert.strictEqual(response.status, 503);
    assert.deepStrictEqual(await response.json(), { status: 'not ready' });
  });

  test('reports not-ready, not 500, when the ping rejects, and leaks nothing', async () => {
    const yoga = serverWith(async () => {
      throw new Error('connect ECONNREFUSED 10.0.0.5:5432');
    });
    const response = await yoga.fetch(`http://localhost${READINESS_PATH}`);
    assert.strictEqual(response.status, 503);
    const body = await response.text();
    assert.deepStrictEqual(JSON.parse(body), { status: 'not ready' });
    assert.ok(!body.includes('ECONNREFUSED'));
    assert.ok(!body.includes('5432'));
  });

Cost of the extra client is one additional Postgres connection per replica, and close() now ends both.

Non-blocking nits

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

dkijania and others added 2 commits August 24, 2026 19:04
`/healthcheck` is Yoga's built-in liveness check — it only confirms the process
is serving HTTP, not that the database is reachable. An orchestrator therefore
keeps routing traffic to an instance whose Postgres is down.

Add a `/readiness` endpoint that pings the database (`SELECT 1`) and returns 200
when it answers, 503 otherwise. A lightweight `ping()` is added to the database
adapter (resolves false instead of throwing). The readiness plugin is prepended
to the plugin list so probes short-circuit before any other request hook (e.g.
rate limiting) can interfere.

Use `/healthcheck` for the Kubernetes liveness probe and `/readiness` for the
readiness probe: a node with an unreachable DB reports not-ready (stops getting
traffic) while staying live (not needlessly restarted). Docs updated; unit tests
cover 200/503 and confirm normal GraphQL requests pass through untouched.

Closes #169.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6

@SanabriaRusso SanabriaRusso 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.

Approving — the round-1 blocker is fixed, and fixed structurally rather than papered over.

The thing I most wanted to check was whether this was a real isolation fix or just a Promise.race wrapped around the shared pool. It's the real fix: src/db/archive-node-adapter/archive-node-adapter.ts:56-60 constructs a separate postgres.js instancepostgres(connectionString, { max: 1, idle_timeout: 60, connect_timeout: 2 }) — and ping() at :115 queries this.pingClient, not this.client. That means a 2000-block analytics query saturating this.client cannot delay the ping, and a hung ping cannot burn a shared-pool connection. That self-inflicted-pressure loop was the whole of the round-1 blocker.

Everything else on the unblock list landed:

  • pingWithin() bound with clearTimeout in finally, resolves-never-rejects (src/server/readiness.ts:29-49).
  • READINESS_PING_TIMEOUT_MS in src/envionment.d.ts:8, both .env.example.*, parsed at readiness.ts:22-27 with a 2000 ms default and a > 0 guard.
  • Docs: docs/getting-started.md:181 env table, plus a liveness-vs-readiness explanation and a probe-YAML snippet.
  • Both regression tests: tests/unit/readiness.test.ts:32 (timeout → 503, 20 ms budget against a never-resolving ping) and :50 (rejection → 503, asserting the body leaks neither ECONNREFUSED nor 5432).
  • Liveness untouched: src/server/server.ts:21 still healthCheckEndpoint: '/healthcheck', and the plugin early-returns for any path but /readiness.
  • close() now ends both clients (archive-node-adapter.ts:128), so #188 won't leak the ping connection.

Timings check out: with #196's readiness timeoutSeconds: 3 / periodSeconds: 10 / failureThreshold: 6, a 2000 ms ping budget fits with margin and gives 60 s tolerance. Round 1's "30 s tolerance vs 30 s statement_timeout" concern is now moot in both directions — the ping is bounded at 2 s on a dedicated connection, so it is structurally independent of #182's (now 15 s) statement_timeout.

Downstream: no impact on either consumer. /readiness is a new path neither calls. mina-explorer-api's { __typename } probe is explicitly protected by the test at tests/unit/readiness.test.ts:69, which asserts HTTP 200 while the DB reports not-ready. Nice.

Non-blocking notes:

  • Single-flight would be cheap hardening. With the DB wedged, pingWithin returns false at 2 s but the underlying SELECT 1 stays queued on the max:1 client, so each subsequent probe queues another. Behaviour stays correct (every probe times out → NotReady, which is the truth) and it's ~6 queued no-ops/minute, so this is not a production risk — but a small guard makes it strictly bounded:
    let inFlight: Promise<boolean> | undefined;
    const answered = inFlight ?? (inFlight = Promise.resolve()
      .then(() => db.ping()).catch(() => false)
      .finally(() => { inFlight = undefined; }));
  • connect_timeout: 2 (seconds) exactly equals the default READINESS_PING_TIMEOUT_MS of 2000, so the driver's own connect error never wins the race. Both paths produce 503, so it's cosmetic — but connect_timeout: 1 would let the more informative driver error reach the log.
  • ping() logs via console.warn rather than the structured logger #190 introduces, and fires once per failed probe per replica (6/min during an outage). Worth reconciling when #190 lands.
  • Doc/manifest drift: docs/getting-started.md suggests readiness failureThreshold: 3 while #196's manifest uses 6. Both are fine against a 2 s ping; just pick one so operators aren't handed two numbers.
  • Merge ordering with #182 (both edit archive-node-adapter.ts:44): resolve to this.client = postgres(connectionString, buildPostgresOptions()); and leave pingClient on its own literal options — passing buildPostgresOptions() to the ping client would give it max: 10 and undo the isolation this PR exists to provide. Optionally give the ping client connection: { statement_timeout: '2000' } so a wedged SELECT 1 is cancelled server-side too. After both land each replica holds PG_MAX_CONNECTIONS + 1 connections, so the PG_MAX_CONNECTIONS line in docs/getting-started.md:177 deserves a one-word tweak.

Worth calling out as a bonus: registering useReadiness ahead of the user plugin list (server.ts:26) structurally exempts /readiness from #185's rate limiter, closing one of #185's round-1 to-dos for free.

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

Labels

P1 Strongly recommended before GA production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P1: Add readiness probe (DB ping) distinct from liveness

2 participants