Skip to content
Draft
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
13 changes: 8 additions & 5 deletions docs/architecture/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ 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
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
lands between pings, rather than re-paying the IAM-token + TLS connect.
pooled Postgres connections. The pool uses a **5-second** idle timeout
(`BENCH_DB_IDLE_TIMEOUT_MS`, default `5000`, in
[`web/lib/db.ts`](../../web/lib/db.ts)). Its
[`attachDatabasePool`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#attachdatabasepool)
hook keeps the invocation alive while pg closes idle clients before suspension.
Connections can serve concurrent requests, but expire between cron pings.
Existing deployments that override the previous 5-minute default must lower the override
to benefit from prompt cleanup. `0` is rejected because it disables idle cleanup.
- **The GitHub `web-keep-warm` workflow** (see
[deploy-and-infra.md](deploy-and-infra.md)) pings the public read
surface on its own schedule and doubles as a lightweight uptime check
Expand Down
3 changes: 2 additions & 1 deletion docs/runbooks/deploy-secrets-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ team's secret store. **Never paste a value into this document.**
| `BENCH_DB_SSL` | TLS verification mode (`verify-full` for RDS production, `disable` for local dev only) | Existing monorepo Vercel project env |
| `BENCH_DB_CA` | RDS CA bundle PEM content or mode; required for `verify-full` — Node does not include Amazon RDS roots in its trust store | Existing monorepo Vercel project env or [Amazon RDS CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) |
| `BENCH_DB_POOL_MAX` | Max connections in the pg pool (default `8`; omit to use default) | Existing monorepo Vercel project env |
| `BENCH_DB_IDLE_TIMEOUT_MS` | Pool idle-connection timeout in ms (default `300000` = 5 min; omit to use default) | Existing monorepo Vercel project env |
| `BENCH_DB_IDLE_TIMEOUT_MS` | Pool idle-connection timeout in ms (default `5000`; positive integer; omit to use default) | Existing monorepo Vercel project env |
| `BENCH_DB_CONNECTION_TIMEOUT_MS` | New-connection and pool-slot timeout in ms (default `5000`; positive integer; omit to use default) | Existing monorepo Vercel project env |
| `BENCH_REVALIDATE_TOKEN` | Bearer token for `POST /api/revalidate` — must match the value the monorepo's emitter caller (`post-ingest.py`) sends | Existing monorepo Vercel project env; coordinate with the monorepo caller (see note below) |

> **`BENCH_REVALIDATE_TOKEN` coordination:** this token authenticates the monorepo's `post-ingest.py`
Expand Down
2 changes: 2 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Connection config is read by `lib/db.ts`:
| `BENCH_DB_SSL` | no (`verify-full`) | `verify-full` validates the certificate chain and hostname; `disable` is for local non-TLS containers only. Any other value fails loudly. |
| `BENCH_DB_CA` | prod | PEM contents of the Amazon RDS CA bundle; Node's trust store does not include the RDS roots, so `verify-full` against RDS fails without it. |
| `BENCH_DB_POOL_MAX` | no (8) | Max pool connections per serverless instance; the per-render summary fan-out (`SUMMARY_CONCURRENCY`) is sized to this default. |
| `BENCH_DB_IDLE_TIMEOUT_MS` | no (5000) | Idle connection timeout in ms. Must be a positive integer within Node's timer range. |
| `BENCH_DB_CONNECTION_TIMEOUT_MS` | no (5000) | Timeout in ms for a new connection or a free pool slot. Must be a positive integer within Node's timer range. |
| `BENCH_DB_STATEMENT_TIMEOUT_MS` | no (30000) | PostgreSQL server-side timeout for each web statement. `0` disables the timeout. |

## CDN caching
Expand Down
90 changes: 60 additions & 30 deletions web/lib/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
requireEnv,
resetPool,
resolveIdleTimeoutMillis,
resolveConnectionTimeoutMillis,
resolveStatementTimeoutMillis,
resolveSsl,
sql,
Expand Down Expand Up @@ -39,12 +40,15 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres)
beforeAll(async () => {
// The pool roundtrip needs no schema; the BENCH_DB_PASSWORD fixture path
// set by the harness means IAM token generation is bypassed.
vi.stubEnv('BENCH_DB_POOL_MAX', '1');
vi.stubEnv('BENCH_DB_CONNECTION_TIMEOUT_MS', '1000');
container = await startBenchContainer({ applySchema: false });
});

afterAll(async () => {
await resetPool();
await container.stop();
vi.unstubAllEnvs();
});

it('connects via the password fixture and roundtrips a SELECT', async () => {
Expand All @@ -55,6 +59,19 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres)
expect(rows).toEqual([{ one: 1, greeting: 'hi' }]);
});

it('times out waiting for a pool slot and recovers after release', async () => {
const pool = getPool();
const client = await pool.connect();
try {
await expect(pool.query('SELECT 1')).rejects.toThrow(
'timeout exceeded when trying to connect',
);
} finally {
client.release();
}
expect(await sql`SELECT 1 AS one`).toEqual([{ one: 1 }]);
}, 5000);

it('binds interpolated values as parameters rather than concatenating them', async () => {
const hostile = '1); DROP TABLE x; --';
const rows = await sql<{ v: string }>`SELECT ${hostile}::text AS v`;
Expand All @@ -73,7 +90,8 @@ describe('db IAM auth path (mocked rds-signer)', () => {
region: 'us-east-1',
ssl: false,
poolMax: 4,
idleTimeoutMillis: 300000,
idleTimeoutMillis: 5000,
connectionTimeoutMillis: 5000,
statementTimeoutMillis: 30000,
staticPassword: undefined,
};
Expand Down Expand Up @@ -154,40 +172,33 @@ describe('resolveSsl', () => {
});
});

describe('resolveIdleTimeoutMillis', () => {
describe.each([
['BENCH_DB_IDLE_TIMEOUT_MS', resolveIdleTimeoutMillis],
['BENCH_DB_CONNECTION_TIMEOUT_MS', resolveConnectionTimeoutMillis],
] as const)('%s', (name, resolve) => {
afterEach(() => {
delete process.env.BENCH_DB_IDLE_TIMEOUT_MS;
});

it('defaults to 300000 ms (5 min) when unset', () => {
delete process.env.BENCH_DB_IDLE_TIMEOUT_MS;
expect(resolveIdleTimeoutMillis()).toBe(300000);
});

it('honors a numeric override', () => {
process.env.BENCH_DB_IDLE_TIMEOUT_MS = '60000';
expect(resolveIdleTimeoutMillis()).toBe(60000);
});

it('throws (fails loudly) on a non-numeric value rather than silently using NaN', () => {
process.env.BENCH_DB_IDLE_TIMEOUT_MS = 'soon';
expect(() => resolveIdleTimeoutMillis()).toThrow(/BENCH_DB_IDLE_TIMEOUT_MS/);
vi.unstubAllEnvs();
});

it('throws on a negative value', () => {
process.env.BENCH_DB_IDLE_TIMEOUT_MS = '-1';
expect(() => resolveIdleTimeoutMillis()).toThrow(/BENCH_DB_IDLE_TIMEOUT_MS/);
it('defaults to 5000 ms when unset or blank', () => {
for (const value of [undefined, '', ' ']) {
vi.stubEnv(name, value);
expect(resolve()).toBe(5000);
}
});

it('falls back to the default when set but empty', () => {
process.env.BENCH_DB_IDLE_TIMEOUT_MS = '';
expect(resolveIdleTimeoutMillis()).toBe(300000);
it('honors a positive integer override', () => {
vi.stubEnv(name, '12000');
expect(resolve()).toBe(12000);
});

it('accepts 0 as the never-timeout sentinel', () => {
process.env.BENCH_DB_IDLE_TIMEOUT_MS = '0';
expect(resolveIdleTimeoutMillis()).toBe(0);
});
it.each(['soon', '-1', '0', '1.5', 'Infinity', '2147483648'])(
'rejects %s instead of disabling the timeout or overflowing its timer',
(value) => {
vi.stubEnv(name, value);
expect(resolve).toThrow(name);
},
);
});

describe('resolveStatementTimeoutMillis', () => {
Expand Down Expand Up @@ -217,14 +228,15 @@ describe('resolveStatementTimeoutMillis', () => {
});
});

describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', () => {
describe('singleton pool configuration', () => {
const ENV_KEYS = [
'BENCH_DB_HOST',
'BENCH_DB_NAME',
'BENCH_DB_USER',
'BENCH_DB_PASSWORD',
'BENCH_DB_SSL',
'BENCH_DB_IDLE_TIMEOUT_MS',
'BENCH_DB_CONNECTION_TIMEOUT_MS',
'BENCH_DB_STATEMENT_TIMEOUT_MS',
'BENCH_DB_PORT',
'BENCH_DB_REGION',
Expand Down Expand Up @@ -256,15 +268,33 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)',
it('uses the resolved timeouts as pool options', () => {
configurePoolEnvironment();
process.env.BENCH_DB_IDLE_TIMEOUT_MS = '123456';
process.env.BENCH_DB_CONNECTION_TIMEOUT_MS = '3456';
process.env.BENCH_DB_STATEMENT_TIMEOUT_MS = '23456';

// `pg`'s Pool exposes the resolved construction options at runtime but the
// types do not surface `options`, so read it through a narrow cast.
const pool = getPool() as unknown as {
options: { idleTimeoutMillis?: number; statement_timeout?: number };
options: {
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
statement_timeout?: number;
};
};
expect(pool.options.idleTimeoutMillis).toBe(123456);
expect(pool.options.statement_timeout).toBe(23456);
expect(pool.options.connectionTimeoutMillis).toBe(3456);
});

it('attaches one lifecycle listener per singleton pool', async () => {
configurePoolEnvironment();
const pool = getPool();
expect(getPool()).toBe(pool);
expect(pool.listenerCount('release')).toBe(1);

await resetPool();
const replacement = getPool();
expect(replacement).not.toBe(pool);
expect(replacement.listenerCount('release')).toBe(1);
});

it('handles idle-client errors on the shared pool', () => {
Expand Down
48 changes: 27 additions & 21 deletions web/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { Pool, type PoolConfig, type QueryResultRow } from 'pg';
import { Signer } from '@aws-sdk/rds-signer';
import { attachDatabasePool } from '@vercel/functions';

/**
* Resolved Postgres connection settings for the benchmarks read service.
Expand Down Expand Up @@ -31,6 +32,8 @@ export interface DbConfig {
poolMax: number;
/** Idle-connection timeout (ms) for the pg pool; see `resolveIdleTimeoutMillis`. */
idleTimeoutMillis: number;
/** Timeout (ms) for establishing a connection or waiting for a pool slot. */
connectionTimeoutMillis: number;
/** Per-statement server timeout (ms); see `resolveStatementTimeoutMillis`. */
statementTimeoutMillis: number;
/** When defined, IAM token generation is bypassed in favor of this password. */
Expand Down Expand Up @@ -78,38 +81,38 @@ export function resolveSsl(): PoolConfig['ssl'] {
return { rejectUnauthorized: true, ...(ca ? { ca } : {}) };
}

/** Default pg pool idle-connection timeout: 5 minutes, see `resolveIdleTimeoutMillis`. */
const DEFAULT_IDLE_TIMEOUT_MS = 300_000;
const DEFAULT_POOL_TIMEOUT_MS = 5_000;
const DEFAULT_STATEMENT_TIMEOUT_MS = 30_000;

/**
* Resolves the pool's idle-connection timeout in milliseconds from
* `BENCH_DB_IDLE_TIMEOUT_MS`. An unset OR empty/whitespace-only value uses the
* default `DEFAULT_IDLE_TIMEOUT_MS` (5 minutes) so a pooled connection survives
* the keep-warm cron's two-minute ping gap instead of pg's 10s default, which
* would otherwise drop the connection between pings and make the next request
* re-pay the RDS IAM-token + TLS connect even on a warm function instance. `0`
* is accepted and means pg never times out an idle client. A non-empty,
* non-numeric, or negative value fails loudly rather than silently becoming
* `NaN`. Exported for unit testing the parsing and default.
*/
export function resolveIdleTimeoutMillis(): number {
const raw = process.env.BENCH_DB_IDLE_TIMEOUT_MS;
// Treat unset OR empty/whitespace-only as "use the default". This is an
// optional tuning knob, so an accidentally-cleared value falls back to the
// safe default rather than silently becoming `Number('')` === 0 (no timeout).
/** Read a positive timeout that fits Node's signed 32-bit timer range. */
function resolvePoolTimeoutMillis(name: string): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') {
return DEFAULT_IDLE_TIMEOUT_MS;
return DEFAULT_POOL_TIMEOUT_MS;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
if (!Number.isInteger(value) || value <= 0 || value > 2_147_483_647) {
throw new Error(
`Invalid \`BENCH_DB_IDLE_TIMEOUT_MS\` \`${raw}\`; expected a non-negative number of milliseconds.`,
`Invalid \`${name}\` \`${raw}\`; expected an integer from 1 to 2147483647 milliseconds.`,
);
}
return value;
}

/**
* Resolve the idle timeout from `BENCH_DB_IDLE_TIMEOUT_MS` (default 5 seconds).
* Vercel keeps the invocation alive until pg's idle timer can close clients.
* A short, nonzero timeout allows cleanup before the function suspends.
*/
export function resolveIdleTimeoutMillis(): number {
return resolvePoolTimeoutMillis('BENCH_DB_IDLE_TIMEOUT_MS');
}

/** Resolve the connection and pool acquisition timeout (default 5 seconds). */
export function resolveConnectionTimeoutMillis(): number {
return resolvePoolTimeoutMillis('BENCH_DB_CONNECTION_TIMEOUT_MS');
}

/**
* Resolves the PostgreSQL statement timeout in milliseconds. The timeout runs
* on the server, so PostgreSQL cancels abandoned work when a request times out
Expand Down Expand Up @@ -140,6 +143,7 @@ function readConfig(): DbConfig {
ssl: resolveSsl(),
poolMax: Number(process.env.BENCH_DB_POOL_MAX ?? '8'),
idleTimeoutMillis: resolveIdleTimeoutMillis(),
connectionTimeoutMillis: resolveConnectionTimeoutMillis(),
statementTimeoutMillis: resolveStatementTimeoutMillis(),
staticPassword: staticPassword === '' ? undefined : staticPassword,
};
Expand Down Expand Up @@ -182,8 +186,10 @@ function createPool(config: DbConfig = readConfig()): Pool {
ssl: config.ssl,
max: config.poolMax,
idleTimeoutMillis: config.idleTimeoutMillis,
connectionTimeoutMillis: config.connectionTimeoutMillis,
statement_timeout: config.statementTimeoutMillis,
});
attachDatabasePool(pool);
// pg emits idle-client connection failures on the pool. Without this
// listener, EventEmitter turns a recoverable disconnect into an uncaught
// exception. The pool removes the failed client and replaces it on demand.
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@aws-sdk/rds-signer": "^3.1063.0",
"@vercel/functions": "^3.9.5",
"chart.js": "^4.5.1",
"chartjs-plugin-zoom": "^2.2.0",
"next": "15.5.19",
Expand Down
Loading
Loading