Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/architecture/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/read-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
47 changes: 47 additions & 0 deletions web/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
12 changes: 6 additions & 6 deletions web/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
}
15 changes: 1 addition & 14 deletions web/lib/families.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
]);
});
});
15 changes: 2 additions & 13 deletions web/lib/families.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
83 changes: 11 additions & 72 deletions web/lib/health.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>([
['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');
});
});
94 changes: 13 additions & 81 deletions web/lib/health.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

/**
* 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<string, number>): Record<string, number> {
const rowCounts: Record<string, number> = {};
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<HealthResponse> {
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<string, number>;
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<number> {
// `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<HealthResponse> {
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',
});
}
5 changes: 1 addition & 4 deletions web/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading