Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example.compose
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion src/db/archive-node-adapter/archive-node-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
82 changes: 82 additions & 0 deletions src/db/archive-node-adapter/postgres-options.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

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<Record<string, never>> {
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,
},
};
}
4 changes: 4 additions & 0 deletions src/envionment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/postgres-options.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});
Loading