From dfcad110514e67b25b800ab84fee7de17bb98412 Mon Sep 17 00:00:00 2001 From: dkijania Date: Sun, 28 Jun 2026 11:36:29 +0200 Subject: [PATCH] feat(db): configure Postgres pool limits and statement_timeout The archive-node Postgres client was created with `postgres(connectionString)` and no pool sizing or timeouts. With the API exposed publicly this is a denial- of-service risk: one expensive query can hold a connection open indefinitely, exhausting the pool and cascading into an outage. Add a small, unit-testable `postgres-options` module that builds the client options from conservative, env-tunable defaults: - PG_MAX_CONNECTIONS (max pooled connections, default 10) - PG_IDLE_TIMEOUT (seconds, default 30) - PG_CONNECT_TIMEOUT (seconds, default 30) - PG_STATEMENT_TIMEOUT(ms server-side query cap, default 30000; 0 disables) Malformed values fall back to defaults rather than throwing, so a stray typo can never silently disable a safety limit. Docs, env example, and env type declarations updated; unit tests cover parsing, fallbacks, and the options shape. Also anchor the `db/` and `data/` .gitignore rules to the repo root (`/db/`, `/data/`). The unanchored `db/` matched `src/db/` anywhere in the tree, which silently ignored the new module file. Closes #165. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6 --- .env.example.compose | 5 ++ .gitignore | 6 +- docs/getting-started.md | 4 + .../archive-node-adapter.ts | 3 +- .../archive-node-adapter/postgres-options.ts | 82 +++++++++++++++++++ src/envionment.d.ts | 4 + tests/unit/postgres-options.test.ts | 69 ++++++++++++++++ 7 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 src/db/archive-node-adapter/postgres-options.ts create mode 100644 tests/unit/postgres-options.test.ts diff --git a/.env.example.compose b/.env.example.compose index 12bb81d..eea738e 100644 --- a/.env.example.compose +++ b/.env.example.compose @@ -18,6 +18,11 @@ PGPASSWORD=password PGDATABASE=archive PGURI=postgres://postgres:5432/archive PG_CONN=postgres://${PGUSER}:${PGPASSWORD}@postgres:5432/${PGDATABASE} +# Connection-pool and query-timeout limits (optional; conservative defaults shown) +PG_MAX_CONNECTIONS=10 +PG_IDLE_TIMEOUT=30 +PG_CONNECT_TIMEOUT=30 +PG_STATEMENT_TIMEOUT=30000 PG_DUMP="archive.sql" PGDATA=/var/lib/postgresql/data PGPORT=5432 # Local port for postgres - it will always be 5432 inside the container diff --git a/.gitignore b/.gitignore index 4830ff7..99ff0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,9 @@ coverage/**/* benchmark/*.json benchmark/*.csv -# docker-compose postgres volumes -db/ -data/ +# docker-compose postgres volumes (root-anchored so they don't match src/db/ etc.) +/db/ +/data/ # memsearch .memsearch/ diff --git a/docs/getting-started.md b/docs/getting-started.md index 67a52be..11e8ac7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -175,6 +175,10 @@ The server reads config from environment variables. `PG_CONN` is the only requir | Variable | Default | Description | | --- | --- | --- | | `PG_CONN` | *(required)* | Postgres connection string for the archive-node DB | +| `PG_MAX_CONNECTIONS` | `10` | Max pooled Postgres connections (per host) | +| `PG_IDLE_TIMEOUT` | `30` | Seconds an idle connection is kept before closing | +| `PG_CONNECT_TIMEOUT` | `30` | Seconds to wait for a new connection before failing | +| `PG_STATEMENT_TIMEOUT` | `30000` | Server-side query timeout in ms; a longer query is cancelled. `0` disables | | `PORT` | `8080` | Port the GraphQL server listens on | | `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` | | `CORS_ORIGIN` | `*` | CORS allowed origin | diff --git a/src/db/archive-node-adapter/archive-node-adapter.ts b/src/db/archive-node-adapter/archive-node-adapter.ts index c6a21d8..b31cb52 100644 --- a/src/db/archive-node-adapter/archive-node-adapter.ts +++ b/src/db/archive-node-adapter/archive-node-adapter.ts @@ -13,6 +13,7 @@ import type { BlockSortByInput, } from '../../resolvers-types.js'; import { getTables, USED_TABLES } from '../../db/sql/events-actions/queries.js'; +import { buildPostgresOptions } from './postgres-options.js'; import { EventsService } from '../../services/events-service/events-service.js'; import { IEventsService } from '../../services/events-service/events-service.interface.js'; import { ActionsService } from '../../services/actions-service/actions-service.js'; @@ -40,7 +41,7 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { throw new Error( 'Missing Postgres Connection String. Please provide a valid connection string in the environment variables or in your configuration file to connect to the Postgres database.' ); - this.client = postgres(connectionString); + this.client = postgres(connectionString, buildPostgresOptions()); this.eventsService = new EventsService(this.client); this.actionsService = new ActionsService(this.client); this.networkService = new NetworkService(this.client); diff --git a/src/db/archive-node-adapter/postgres-options.ts b/src/db/archive-node-adapter/postgres-options.ts new file mode 100644 index 0000000..f5ca6c1 --- /dev/null +++ b/src/db/archive-node-adapter/postgres-options.ts @@ -0,0 +1,82 @@ +import type postgres from 'postgres'; + +export { buildPostgresOptions, resolvePoolConfig, POOL_DEFAULTS }; +export type { PostgresPoolConfig }; + +/** + * Connection-pool and timeout configuration for the archive-node Postgres + * client. Without these limits a single expensive query can hold a connection + * open indefinitely, exhausting the pool and cascading into an outage. The + * defaults are deliberately conservative so the server is safe to expose + * publicly out of the box, and every value is tunable via the environment. + */ +interface PostgresPoolConfig { + /** Maximum number of pooled connections (per host). */ + max: number; + /** Seconds a connection may sit idle before it is closed. */ + idleTimeout: number; + /** Seconds to wait for a new connection before giving up. */ + connectTimeout: number; + /** + * Server-side `statement_timeout` in milliseconds. A query running longer + * than this is cancelled by Postgres. `0` disables the timeout. + */ + statementTimeout: number; +} + +const POOL_DEFAULTS: PostgresPoolConfig = { + max: 10, + idleTimeout: 30, + connectTimeout: 30, + statementTimeout: 30_000, +}; + +/** + * Parse a non-negative integer from an env value, falling back to `fallback` + * when the value is missing or malformed. We fall back rather than throw so a + * stray typo never silently disables a safety limit (e.g. `max` becoming 0). + */ +function intFromEnv(value: string | undefined, fallback: number): number { + if (value === undefined || value.trim() === '') return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) return fallback; + return parsed; +} + +type EnvSource = Record; + +function resolvePoolConfig(env: EnvSource = process.env): PostgresPoolConfig { + return { + // `max` must be at least 1 — a pool of 0 connections can never serve a query. + max: Math.max(1, intFromEnv(env.PG_MAX_CONNECTIONS, POOL_DEFAULTS.max)), + idleTimeout: intFromEnv(env.PG_IDLE_TIMEOUT, POOL_DEFAULTS.idleTimeout), + connectTimeout: intFromEnv( + env.PG_CONNECT_TIMEOUT, + POOL_DEFAULTS.connectTimeout + ), + statementTimeout: intFromEnv( + env.PG_STATEMENT_TIMEOUT, + POOL_DEFAULTS.statementTimeout + ), + }; +} + +/** + * Build the options object passed to `postgres()`. `statement_timeout` is sent + * as a startup connection parameter so it applies to every query on every + * connection in the pool. + */ +function buildPostgresOptions( + env: EnvSource = process.env +): postgres.Options> { + const config = resolvePoolConfig(env); + return { + max: config.max, + idle_timeout: config.idleTimeout, + connect_timeout: config.connectTimeout, + connection: { + // Sent as a startup connection parameter, so it applies to every query. + statement_timeout: config.statementTimeout, + }, + }; +} diff --git a/src/envionment.d.ts b/src/envionment.d.ts index 95cf7a6..41e74e3 100644 --- a/src/envionment.d.ts +++ b/src/envionment.d.ts @@ -4,6 +4,10 @@ declare global { LOG_LEVEL: string; PORT?: string; PG_CONN: string; + PG_MAX_CONNECTIONS?: string; + PG_IDLE_TIMEOUT?: string; + PG_CONNECT_TIMEOUT?: string; + PG_STATEMENT_TIMEOUT?: string; CORS_ORIGIN?: string; ENABLE_LOGGING?: bool; ENABLE_INTROSPECTION?: bool; diff --git a/tests/unit/postgres-options.test.ts b/tests/unit/postgres-options.test.ts new file mode 100644 index 0000000..6590e9c --- /dev/null +++ b/tests/unit/postgres-options.test.ts @@ -0,0 +1,69 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { + buildPostgresOptions, + resolvePoolConfig, + POOL_DEFAULTS, +} from '../../src/db/archive-node-adapter/postgres-options.js'; + +describe('Postgres pool options', () => { + describe('resolvePoolConfig', () => { + test('uses conservative defaults when no env vars are set', () => { + assert.deepStrictEqual(resolvePoolConfig({}), POOL_DEFAULTS); + }); + + test('reads valid overrides from the environment', () => { + const config = resolvePoolConfig({ + PG_MAX_CONNECTIONS: '25', + PG_IDLE_TIMEOUT: '60', + PG_CONNECT_TIMEOUT: '5', + PG_STATEMENT_TIMEOUT: '15000', + }); + assert.deepStrictEqual(config, { + max: 25, + idleTimeout: 60, + connectTimeout: 5, + statementTimeout: 15000, + }); + }); + + test('falls back to defaults on malformed values', () => { + const config = resolvePoolConfig({ + PG_MAX_CONNECTIONS: 'abc', + PG_IDLE_TIMEOUT: '-1', + PG_CONNECT_TIMEOUT: '1.5', + PG_STATEMENT_TIMEOUT: '', + }); + assert.deepStrictEqual(config, POOL_DEFAULTS); + }); + + test('clamps max to at least 1 so the pool can never be empty', () => { + assert.strictEqual(resolvePoolConfig({ PG_MAX_CONNECTIONS: '0' }).max, 1); + }); + + test('allows statement timeout of 0 to disable the limit', () => { + assert.strictEqual( + resolvePoolConfig({ PG_STATEMENT_TIMEOUT: '0' }).statementTimeout, + 0 + ); + }); + }); + + describe('buildPostgresOptions', () => { + test('maps config onto the postgres() options shape', () => { + const options = buildPostgresOptions({ + PG_MAX_CONNECTIONS: '12', + PG_IDLE_TIMEOUT: '45', + PG_CONNECT_TIMEOUT: '7', + PG_STATEMENT_TIMEOUT: '20000', + }); + assert.strictEqual(options.max, 12); + assert.strictEqual(options.idle_timeout, 45); + assert.strictEqual(options.connect_timeout, 7); + // statement_timeout is sent as a startup connection parameter. + assert.deepStrictEqual(options.connection, { + statement_timeout: 20000, + }); + }); + }); +});