diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index ef11dc8..c1bdda0 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -76,9 +76,9 @@ Caches handle *repeat* reads; they do nothing for the first read after an idle gap, which is the dominant cost on this site. Two crons keep the hot path warm: - **The warmer — a Vercel-native cron, `*/2 * * * *` on `/api/health`** - ([`web/vercel.json`](../../web/vercel.json)). `/api/health` fans out a - `COUNT(*)` per table, so each ping warms the function instance *and* several - pooled Postgres connections. Paired with it, the `pg` pool's idle timeout is + ([`web/vercel.json`](../../web/vercel.json)). `/api/health` runs one + `SELECT 1`, so each ping checks connectivity and warms one pooled Postgres connection + without scanning benchmark tables. Paired with it, the `pg` pool's idle timeout is raised to **5 minutes** (`BENCH_DB_IDLE_TIMEOUT_MS`, default `300000`, in [`web/lib/db.ts`](../../web/lib/db.ts)) — comfortably longer than the 2-minute ping gap, so a connection minted by one ping survives to serve a visitor who diff --git a/docs/architecture/read-path.md b/docs/architecture/read-path.md index 487c7fa..b884314 100644 --- a/docs/architecture/read-path.md +++ b/docs/architecture/read-path.md @@ -87,7 +87,7 @@ constant-time. | `GET /api/groups` | all groups + their chart links (structure only) | | `GET /api/group/{slug}` | one group with every chart's payload inlined | | `GET /api/chart/{slug}` | one chart's payload | -| `GET /api/health` | liveness: build SHA, schema version, per-table row counts, latest commit timestamp (never cached) | +| `GET /api/health` | database liveness via `SELECT 1`, build SHA, and schema version (200/503, never cached) | The `?n=` query parameter selects the commit window: `?n=all` is uncapped; numeric values are floored to 1 and clamped to `MAX_NUMERIC_COMMIT_WINDOW = 1000` diff --git a/web/app/api/health/route.test.ts b/web/app/api/health/route.test.ts new file mode 100644 index 0000000..57d126b --- /dev/null +++ b/web/app/api/health/route.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sql } from '@/lib/db'; +import { GET } from './route'; + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(sql).mockReset(); +}); + +describe('GET /api/health', () => { + it('returns deployment identity after exactly one connectivity query', async () => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'abc123'); + vi.mocked(sql).mockResolvedValue([]); + + const response = await GET(); + + expect(sql).toHaveBeenCalledExactlyOnceWith(['SELECT 1']); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ + status: 'ok', + schema_version: 3, + build_sha: 'abc123', + }); + }); + + it('returns an uncached 503 without exposing the database error', async () => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', undefined); + vi.mocked(sql).mockRejectedValue(new Error('private database detail')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await GET(); + + expect(response.status).toBe(503); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ + status: 'error', + schema_version: 3, + build_sha: 'unknown', + }); + }); +}); diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts index 1686153..1985542 100644 --- a/web/app/api/health/route.ts +++ b/web/app/api/health/route.ts @@ -8,11 +8,11 @@ import { collectHealth } from '@/lib/health'; // A liveness probe must reflect the live database, never a cached snapshot. export const dynamic = 'force-dynamic'; -/** - * `GET /api/health` returns the v3-compatible snake_case `HealthResponse` - * (status, per-table `row_counts`, DB host, build SHA, `schema_version`); see - * [`collectHealth`] for the wire shape. - */ +/** Return an uncached database liveness check with deployment identity. */ export async function GET() { - return NextResponse.json(await collectHealth()); + const health = await collectHealth(); + return NextResponse.json(health, { + status: health.status === 'ok' ? 200 : 503, + headers: { 'Cache-Control': 'no-store' }, + }); } diff --git a/web/lib/families.test.ts b/web/lib/families.test.ts index 99af833..d8de988 100644 --- a/web/lib/families.test.ts +++ b/web/lib/families.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { describe, expect, it } from 'vitest'; -import { FAMILIES, familyForChartKind, familyForGroupKind, HEALTH_TABLES } from './families'; +import { FAMILIES, familyForChartKind, familyForGroupKind } from './families'; describe('FAMILIES registry', () => { it('lists the five fact tables in family.rs declaration order', () => { @@ -46,16 +46,3 @@ describe('family lookups', () => { } }); }); - -describe('HEALTH_TABLES', () => { - it('is commits plus every family table, in sorted (BTreeMap) order', () => { - expect(HEALTH_TABLES).toEqual([ - 'commits', - 'compression_sizes', - 'compression_times', - 'query_measurements', - 'random_access_times', - 'vector_search_runs', - ]); - }); -}); diff --git a/web/lib/families.ts b/web/lib/families.ts index 9a8dfe2..18540c5 100644 --- a/web/lib/families.ts +++ b/web/lib/families.ts @@ -7,9 +7,8 @@ * Each of the five fact tables is a [`Family`] that ties together the Postgres * table name and the chart and group slug prefixes. The read endpoints * dispatch through this registry rather than hand-listing the families, so the - * slug prefixes (consumed by [`./slug`]) and the table-name set (consumed by - * `/health`'s row counts; the read queries name their tables in static SQL) - * have a single source of truth, exactly as the Rust `family.rs` "spine" does. + * slug prefixes consumed by [`./slug`] have a single source of truth. The read + * queries name their tables in static SQL. * * The order of [`FAMILIES`] mirrors `family.rs`'s `FAMILIES` constant and the * DDL apply order in `migrations/001_initial_schema.sql`. @@ -113,16 +112,6 @@ export function familyForGroupKind(kind: GroupKind): Family { return family; } -/** - * The Postgres tables surfaced by `/health`, in `BTreeMap` (sorted) order to - * match the Rust `HealthResponse.row_counts` wire shape: the `commits` dim - * table plus every [`FAMILIES`] table name, sorted lexicographically. - */ -export const HEALTH_TABLES: readonly string[] = [ - 'commits', - ...FAMILIES.map((f) => f.tableName), -].sort(); - /** * Byte-order string comparison matching Rust `String::cmp` (and so `BTreeMap` * key order); the ASCII series, format, and ranking names compared by the read diff --git a/web/lib/health.test.ts b/web/lib/health.test.ts index 9c339fd..285f42e 100644 --- a/web/lib/health.test.ts +++ b/web/lib/health.test.ts @@ -1,92 +1,31 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { assembleHealth, buildRowCounts, collectHealth, type HealthResponse } from './health'; -import { getPool, resetPool } from './db'; -import { HEALTH_TABLES } from './families'; +import { collectHealth } from './health'; +import { resetPool } from './db'; import { dockerAvailable, startBenchContainer } from './test-harness'; -describe('buildRowCounts', () => { - it('emits keys in HEALTH_TABLES (sorted BTreeMap) order', () => { - // Insertion order here is deliberately scrambled to prove the output order - // comes from HEALTH_TABLES, not from the input map. - const counts = new Map([ - ['vector_search_runs', 1], - ['commits', 2], - ['compression_times', 3], - ['compression_sizes', 4], - ['query_measurements', 5], - ['random_access_times', 6], - ]); - expect(Object.keys(buildRowCounts(counts))).toEqual([...HEALTH_TABLES]); - }); - - it('throws loud when a table count is missing', () => { - // Only `commits` is supplied; iteration is in sorted HEALTH_TABLES order, so - // the first missing table reported is `compression_sizes`. - expect(() => buildRowCounts(new Map([['commits', 1]]))).toThrow(/compression_sizes/); - }); -}); - -describe('assembleHealth', () => { - it('builds the snake_case HealthResponse with status ok and schema_version 3', () => { - const health: HealthResponse = assembleHealth({ - rowCounts: { commits: 3 }, - latestCommitTimestamp: '2024-01-15T10:30:45Z', - dbPath: 'bench.example.rds.amazonaws.com', - buildSha: 'abc123', - }); - expect(health).toEqual({ - status: 'ok', - db_path: 'bench.example.rds.amazonaws.com', - schema_version: 3, - build_sha: 'abc123', - latest_commit_timestamp: '2024-01-15T10:30:45Z', - row_counts: { commits: 3 }, - }); - }); -}); - describe.skipIf(!dockerAvailable())('collectHealth (testcontainers Postgres)', () => { let container: StartedPostgreSqlContainer; beforeAll(async () => { - container = await startBenchContainer(); - delete process.env.VERCEL_GIT_COMMIT_SHA; + container = await startBenchContainer({ applySchema: false }); + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', undefined); }); afterAll(async () => { + vi.unstubAllEnvs(); await resetPool(); await container.stop(); }); - it('reports zero counts and a null timestamp against the empty schema', async () => { - const health = await collectHealth(); - expect(health.status).toBe('ok'); - expect(health.schema_version).toBe(3); - expect(health.db_path).toBe(container.getHost()); - expect(health.build_sha).toBe('unknown'); - expect(health.latest_commit_timestamp).toBeNull(); - expect(health.row_counts).toEqual({ - commits: 0, - compression_sizes: 0, - compression_times: 0, - query_measurements: 0, - random_access_times: 0, - vector_search_runs: 0, + it('checks connectivity without requiring benchmark tables', async () => { + expect(await collectHealth()).toEqual({ + status: 'ok', + schema_version: 3, + build_sha: 'unknown', }); }); - - it('reflects real row counts and the latest commit timestamp', async () => { - await getPool().query( - `INSERT INTO commits (commit_sha, timestamp, tree_sha, url) - VALUES ('abc', '2024-01-15T10:30:45Z', 'tree', 'https://example/abc')`, - ); - const health = await collectHealth(); - expect(health.row_counts.commits).toBe(1); - expect(health.row_counts.query_measurements).toBe(0); - expect(health.latest_commit_timestamp).toBe('2024-01-15T10:30:45Z'); - }); }); diff --git a/web/lib/health.ts b/web/lib/health.ts index 00e907d..39f72ba 100644 --- a/web/lib/health.ts +++ b/web/lib/health.ts @@ -1,96 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -/** - * `/health` liveness probe plus a per-table row-count rollup, the TypeScript - * port of `server/src/api/mod.rs::collect_health`. - * - * The wire shape preserves the Rust `HealthResponse`: snake_case field names, - * a `row_counts` object keyed by table name in sorted (`BTreeMap`) order. Two - * fields are adapted for the stateless Vercel deployment: `db_path` reports the - * Postgres host (there is no local DuckDB file), and `build_sha` reports the - * Vercel deployment commit SHA rather than a compile-time `env!`. - */ - -import { getPool, sql } from './db'; -import { HEALTH_TABLES } from './families'; +import { sql } from './db'; import { SCHEMA_VERSION } from './schema-version'; -/** Body of `GET /health`. Field names match the Rust `HealthResponse` exactly. */ +/** Database liveness and deployment identity returned by `GET /api/health`. */ export interface HealthResponse { - status: string; - db_path: string; + status: 'ok' | 'error'; schema_version: number; build_sha: string; - latest_commit_timestamp: string | null; - row_counts: Record; } -/** - * Project per-table counts into the `row_counts` object, emitting keys in - * [`HEALTH_TABLES`] order (sorted, matching the Rust `BTreeMap`). Throws if a - * table's count is missing so a query gap fails loud rather than dropping a key. - */ -export function buildRowCounts(counts: ReadonlyMap): Record { - const rowCounts: Record = {}; - for (const table of HEALTH_TABLES) { - const n = counts.get(table); - if (n === undefined) { - throw new Error(`missing row count for table \`${table}\``); - } - rowCounts[table] = n; +/** Check database connectivity without scanning benchmark tables. */ +export async function collectHealth(): Promise { + let status: HealthResponse['status'] = 'ok'; + try { + await sql`SELECT 1`; + } catch (error) { + console.error('bench: database health check failed', error); + status = 'error'; } - return rowCounts; -} - -/** Assemble the `HealthResponse` from its parts. Pure, for unit testing. */ -export function assembleHealth(args: { - rowCounts: Record; - latestCommitTimestamp: string | null; - dbPath: string; - buildSha: string; -}): HealthResponse { return { - status: 'ok', - db_path: args.dbPath, + status, schema_version: SCHEMA_VERSION, - build_sha: args.buildSha, - latest_commit_timestamp: args.latestCommitTimestamp, - row_counts: args.rowCounts, + build_sha: process.env.VERCEL_GIT_COMMIT_SHA ?? 'unknown', }; } - -async function countTable(table: string): Promise { - // `table` comes from `HEALTH_TABLES` (the `commits` dim table plus the closed - // `FAMILIES` set), a compile-time constant set, never user input, so it is - // safe in the identifier position. Re-assert membership defensively before - // interpolating, mirroring the Rust `count_rows` "closed enum of literals". - if (!HEALTH_TABLES.includes(table)) { - throw new Error(`refusing to count unknown table \`${table}\``); - } - const result = await getPool().query<{ n: number }>(`SELECT COUNT(*)::int AS n FROM ${table}`); - return result.rows[0].n; -} - -/** Run the health queries against the pool and assemble the response. */ -export async function collectHealth(): Promise { - const entries = await Promise.all( - HEALTH_TABLES.map(async (table) => [table, await countTable(table)] as const), - ); - // Render the latest commit timestamp as a UTC RFC-3339 string. This drops - // sub-second precision, which is safe because git commit timestamps are - // whole-second; it diverges from the Rust server's DuckDB `CAST(... AS VARCHAR)` - // format, but `/health` is a smoke-test field, not a wire-compat contract. - const latest = await sql<{ ts: string | null }>` - SELECT to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS ts - FROM commits - ORDER BY timestamp DESC - LIMIT 1 - `; - return assembleHealth({ - rowCounts: buildRowCounts(new Map(entries)), - latestCommitTimestamp: latest.length > 0 ? latest[0].ts : null, - dbPath: process.env.BENCH_DB_HOST ?? 'unknown', - buildSha: process.env.VERCEL_GIT_COMMIT_SHA ?? 'unknown', - }); -} diff --git a/web/lib/queries.ts b/web/lib/queries.ts index 34b5d71..dd42fee 100644 --- a/web/lib/queries.ts +++ b/web/lib/queries.ts @@ -27,10 +27,7 @@ * - `commits.timestamp` is rendered with the same `YYYY-MM-DD HH24:MI:SS+00` * text the DuckDB `CAST(timestamp AS VARCHAR)` produced, so the wire-compat * `commits[].timestamp` field stays byte-identical for the (always - * whole-second, UTC) git commit timestamps. This differs from `/health`'s - * `latest_commit_timestamp` (a non-contract smoke-test field that uses an - * ISO `T...Z` rendering); the chart timestamp is consumed by `chart-init.js` - * and is preserved exactly. + * whole-second, UTC) git commit timestamps. */ import { getPool } from './db';