From a0fb331e0bea2514e08287bb0fbbd25cedc7abc3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:44:31 -0600 Subject: [PATCH] =?UTF-8?q?fix(health):=20add=20/health/ready=20to=20srv-q?= =?UTF-8?q?a=20=E2=80=94=20missing=20route=20404-crashed=20QA,=20aborting?= =?UTF-8?q?=20MTA=20deploy=20(#2975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2975 batch set srv-qa's CF startup health check to http /health/ready (mta.yaml:199, parity with tutorials-srv) but never added that route to srv-qa/server.js — it only has /healthz and /health/db. So CF's probe got a 404, srv-qa crash-looped (3 retries), and the ENTIRE MTA deploy aborted even though tutorials-srv itself started fine. (Observed on the 2026-11-02 DEV deploy.) srv-qa has no scheduler, so its readiness is DB-only: ready ⇔ a dialect-safe HANA ping (SELECT 1 FROM DUMMY — a bare SELECT 1 is a HANA syntax error) succeeds. Also made /health/db dialect-safe (it hardcoded FROM DUMMY, which breaks on local SQLite). Unit tests cover both dialects + the down path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srv-qa/server.js | 22 +++++++- test/srv-qa/health-ready.test.js | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 test/srv-qa/health-ready.test.js diff --git a/srv-qa/server.js b/srv-qa/server.js index 6f404f524..be231004a 100644 --- a/srv-qa/server.js +++ b/srv-qa/server.js @@ -13,9 +13,29 @@ cds.on('bootstrap', (app) => { app.disable('x-powered-by'); app.get('/healthz', (_req, res) => res.json({ status: 'ok', channel: 'qa' })); + // #2975 readiness gate: the MTA sets srv-qa's CF startup health check to + // http /health/ready (parity with tutorials-srv). srv-qa has NO scheduler, + // so readiness here is DB-only: ready ⇔ the HANA connection answers a ping. + // Dialect-safe: HANA rejects a bare `SELECT 1` (needs FROM DUMMY); SQLite + // rejects FROM DUMMY. Without this route CF's probe 404s and srv-qa + // crash-loops, aborting the whole MTA deploy (observed 2026-11-02). + app.get('/health/ready', async (_req, res) => { + let dbOk = false; + if (cds.db) { + const ping = cds.db.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'; + try { await cds.db.run(ping); dbOk = true; } + catch (_err) { dbOk = false; } + } + res.status(dbOk ? 200 : 503).json({ + status: dbOk ? 'ready' : 'not-ready', + channel: 'qa', + db: dbOk ? 'connected' : 'error', + timestamp: new Date().toISOString(), + }); + }); app.get('/health/db', async (_req, res) => { try { - await cds.db?.run('SELECT 1 FROM DUMMY'); + await cds.db?.run(cds.db?.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'); res.json({ status: 'ok', db: 'connected' }); } catch (err) { console.error('[health/db][qa]', err.message); diff --git a/test/srv-qa/health-ready.test.js b/test/srv-qa/health-ready.test.js new file mode 100644 index 000000000..52e2675d3 --- /dev/null +++ b/test/srv-qa/health-ready.test.js @@ -0,0 +1,90 @@ +// test/srv-qa/health-ready.test.js +// +// #2975: srv-qa /health/ready readiness gate. The MTA sets srv-qa's CF startup +// health check to http /health/ready; srv-qa has NO scheduler, so readiness is +// DB-only (ready ⇔ a dialect-safe DB ping succeeds). Without the route CF 404s +// and srv-qa crash-loops, aborting the MTA deploy (observed 2026-11-02). +// +// We don't boot cds here; we mount the structurally-identical handler against a +// fake cds.db, exercising it over real HTTP on an ephemeral port — same pattern +// as test/srv-qa/server.test.js. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import express from 'express'; + +// Mirror of the /health/ready handler in srv-qa/server.js, parameterised on a +// fake db so we can assert the dialect-safe ping + ready/not-ready mapping. +function buildApp(fakeDb) { + const app = express(); + app.get('/health/ready', async (_req, res) => { + let dbOk = false; + if (fakeDb) { + const ping = fakeDb.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'; + try { await fakeDb.run(ping); dbOk = true; } + catch { dbOk = false; } + } + res.status(dbOk ? 200 : 503).json({ + status: dbOk ? 'ready' : 'not-ready', + channel: 'qa', + db: dbOk ? 'connected' : 'error', + timestamp: new Date().toISOString(), + }); + }); + return app; +} + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function get(server, path) { + const { port } = server.address(); + const res = await fetch(`http://127.0.0.1:${port}${path}`); + return { status: res.status, body: await res.json() }; +} + +describe('srv-qa /health/ready (#2975)', () => { + it('200 ready when HANA ping (SELECT 1 FROM DUMMY) succeeds', async () => { + let sql; + const db = { kind: 'hana', run: async (q) => { sql = q; return [{}]; } }; + const server = await listen(buildApp(db)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(200); + expect(r.body.status).toBe('ready'); + expect(r.body.db).toBe('connected'); + expect(sql).toBe('SELECT 1 FROM DUMMY'); // dialect-safe on HANA + } finally { server.close(); } + }); + + it('uses bare SELECT 1 on sqlite', async () => { + let sql; + const db = { kind: 'sqlite', run: async (q) => { sql = q; return [{}]; } }; + const server = await listen(buildApp(db)); + try { + await get(server, '/health/ready'); + expect(sql).toBe('SELECT 1'); + } finally { server.close(); } + }); + + it('503 not-ready when the DB ping throws', async () => { + const db = { kind: 'hana', run: async () => { throw new Error('db down'); } }; + const server = await listen(buildApp(db)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(503); + expect(r.body.status).toBe('not-ready'); + expect(r.body.db).toBe('error'); + } finally { server.close(); } + }); + + it('503 not-ready when cds.db is absent', async () => { + const server = await listen(buildApp(null)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(503); + expect(r.body.db).toBe('error'); + } finally { server.close(); } + }); +});