Skip to content

Commit c4e8bbc

Browse files
claude[bot]claude
andauthored
feat(datasource): size the SQL pool from OS_DATABASE_POOL_MAX (#14776)
`buildSqlPool` gives every postgres/mysql datasource that declares no `pool` an explicit `{min:0,max:5}`. The primary datasource — the one behind `OS_DATABASE_URL` — is composed as a url and nothing else, so that `max: 5` was the per-replica ceiling on every self-hosted deployment with no operator knob for it. A driver-level env read would have been dead code behind this function's explicit object. Precedence: a declared `pool.max` > `OS_DATABASE_POOL_MAX` > today's `5`. With the env unset nothing changes, which is the upgrade path for every existing deployment and is pinned as such. A non-integer value refuses the boot naming the variable, the value and the sizing rule. Only the postgres/mysql arms call `buildSqlPool`, so the unsupported arms (`memory` / `sqlite` / `sqlite-wasm` / `turso`) structurally cannot see it. Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 392f410 commit c4e8bbc

4 files changed

Lines changed: 272 additions & 2 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
---
4+
5+
feat(datasource): size the SQL connection pool from `OS_DATABASE_POOL_MAX`
6+
7+
A multi-replica deployment had no supported way to raise the number of database
8+
connections each replica opens. The reporter measured a live 3-replica cluster
9+
whose authed data API plateaued at ~25 rps while Postgres held only ~9-21 of its
10+
200 connections: the ceiling was the client pool, and the operator had no knob
11+
for it. Adding replicas raised the ceiling; sizing the pool — the cheap half —
12+
was not expressible at all.
13+
14+
The pool for a `postgres` / `mysql` datasource is built by `buildSqlPool`, which
15+
gives every datasource that declares no `pool` block an explicit `{min: 0,
16+
max: 5}`. That includes the primary datasource: the one behind
17+
`OS_DATABASE_URL` is composed as a url and nothing else, so `max: 5` per replica
18+
was the effective ceiling on every self-hosted deployment, and it was reachable
19+
only by hand-authoring a `pool` block onto a datasource the operator does not
20+
write.
21+
22+
`buildSqlPool` now reads `OS_DATABASE_POOL_MAX`. Precedence is a declared
23+
`pool.max` first, then the env, then today's `5` — an operator knob does not
24+
override what an author wrote about their own datasource, and it is the only
25+
site that decides the unspecified case, so the ordering is expressed once.
26+
27+
**Nothing changes when the variable is unset**, which is the upgrade path for
28+
every existing deployment: the pool stays exactly `{min: 0, max: 5}`, pinned by
29+
a test whose job is to stay red if that ever drifts. A blank value is read as
30+
unset, so a declared-but-unfilled compose variable keeps today's behaviour too.
31+
32+
A value that is not a positive integer refuses the boot, naming the variable,
33+
the value it rejected and the sizing rule — rather than the lenient
34+
`Number(process.env.X ?? default)` shape, where a typo becomes `NaN` and the
35+
operator who was trying to raise the ceiling silently keeps the one they meant
36+
to leave. A pool ceiling is only ever measured in production.
37+
38+
Size it with `replicas × OS_DATABASE_POOL_MAX` below the database's
39+
`max_connections`, leaving headroom for migrations and admin connections.
40+
41+
Only `postgres` / `mysql` are affected — they are the two arms that build a
42+
pool. `memory` / `sqlite` / `sqlite-wasm` / `turso` receive no pool parameter
43+
and reject a declared one outright; the env is read inside a function those arms
44+
never call, so it cannot reach them. `OS_DATABASE_POOL_MIN` is deliberately not
45+
exposed: this path already runs `min: 0`.

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
4949
| `OS_DATABASE_AUTH_TOKEN` | string || Auth token for a libSQL/Turso connection (`--database-auth-token`). The vendor's own `TURSO_AUTH_TOKEN` is read as a fallback and is **not** renamed (see the third-party names note above). Ignored by every other driver — their credentials live in the URL. |
5050
| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mysql` \| `mongodb` \| `turso`. `mysql` is a supported deployment target that carries three dialect caveats — two of them integrity guarantees MySQL cannot enforce at the database. Read [MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) before choosing it. |
5151
| `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). |
52+
| `OS_DATABASE_POOL_MAX` | integer | `5` | Maximum pooled connections **each replica** opens to `postgres` / `mysql`. The pool, not the database, is usually what caps authed throughput on a multi-replica deployment: at the default of 5 a 3-replica cluster reaches at most ~15 connections, which can plateau the data API while the database still has most of its `max_connections` free. Size it so **`replicas × OS_DATABASE_POOL_MAX` stays below the database's `max_connections`**, leaving headroom for migrations and admin connections — e.g. 3 replicas × 40 = 120 against `max_connections=200`. A per-datasource `pool.max` in the datasource's own definition outranks it, and with the variable unset nothing changes. A value that is not a positive integer refuses the boot rather than falling back to the default. Ignored by `memory` / `sqlite` / `sqlite-wasm` / `turso`, which open no pool — declaring a `pool` block on those is an error, not a silent drop. |
5253
| `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. |
5354
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
5455
| `OS_STORAGE_ROOT` | path || **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. |
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14176 — the primary datasource's knex pool had no operator-facing size.
4+
//
5+
// What the reporter measured (a live 3-replica EE cluster, Postgres 16 with
6+
// `max_connections=200`, 2026-09-01) and what this file does NOT re-measure:
7+
// authed data throughput plateaued at ~25 rps while Postgres held ~9-21 of its
8+
// 200 connections, i.e. the ceiling was the client pool, not the server. Those
9+
// are the REPORTER'S numbers. No test here reproduces them — there is no
10+
// cluster and no live database in this suite, and a fabricated local rerun
11+
// would be evidence of nothing. What is pinnable here is the MECHANISM the
12+
// throughput number rests on: which pool size actually reaches knex.
13+
//
14+
// The card blamed `SqlDriver.withConnectBound` for setting no pool size. That
15+
// is falsified for this path and the pins below say why: the CLI composes the
16+
// primary datasource as `config: { url, ...autoMigrate }` with no `pool` block
17+
// (`packages/cli/src/utils/storage-driver.ts`, the `postgres` arm), so
18+
// `buildSqlPool` — not knex's own `{min:2,max:10}` default, and not the driver
19+
// — decides the size, and it decided `{min:0,max:5}` for every deployment.
20+
// An env read in the driver would have been dead code behind that explicit
21+
// object. Maintainer ruling 2026-09-02 (「同意」, option A): read one variable,
22+
// `OS_DATABASE_POOL_MAX`, in `buildSqlPool`, precedence declared `pool` > env >
23+
// `{min:0,max:5}`.
24+
//
25+
// ⚠️ The first pin below is the load-bearing one. An unset env is the upgrade
26+
// path for every existing deployment, so "unset means exactly what it meant
27+
// before" is the whole safety argument for this change; if it ever goes red,
28+
// the change is silently altering production connection counts on upgrade.
29+
30+
import { describe, it, expect, afterEach } from 'vitest';
31+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
32+
33+
const factory = () => createDefaultDatasourceDriverFactory({ dev: false });
34+
35+
const POOL_MAX_ENV = 'OS_DATABASE_POOL_MAX';
36+
37+
/** The knex config a constructed SqlDriver was built from (as the sibling suite reads it). */
38+
function knexConfigOf(driver: any): any {
39+
return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {};
40+
}
41+
42+
/**
43+
* Build a datasource the way the CLI composes the PRIMARY one — a url and
44+
* nothing else, in particular no `pool` block — and return its knex pool.
45+
* Never connects: `create` constructs the driver, and the pool is read off the
46+
* config it was built from.
47+
*/
48+
async function primaryPool(
49+
extra: Record<string, unknown> = {},
50+
): Promise<Record<string, unknown>> {
51+
const handle: any = await factory().create({
52+
driver: 'postgres',
53+
config: { url: 'postgres://app@db.internal:5432/app' },
54+
...extra,
55+
});
56+
try {
57+
return knexConfigOf(handle.driver ?? handle).pool;
58+
} finally {
59+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
60+
}
61+
}
62+
63+
function setEnv(value: string | undefined): void {
64+
if (value === undefined) delete process.env[POOL_MAX_ENV];
65+
else process.env[POOL_MAX_ENV] = value;
66+
}
67+
68+
afterEach(() => { delete process.env[POOL_MAX_ENV]; });
69+
70+
describe('OS_DATABASE_POOL_MAX — the primary datasource pool ceiling (#14176)', () => {
71+
// ⭐ THE DEFAULT-PRESERVATION PIN. Do not relax this one.
72+
it('with the env UNSET, the pool is byte-identical to the pre-change default', async () => {
73+
setEnv(undefined);
74+
expect(await primaryPool()).toEqual({ min: 0, max: 5 });
75+
});
76+
77+
it('reads a blank value as unset rather than as garbage', async () => {
78+
// `OS_DATABASE_POOL_MAX=` in a compose file is a declared-but-unfilled
79+
// variable asking for today's behaviour, not a misconfiguration.
80+
setEnv('');
81+
expect(await primaryPool()).toEqual({ min: 0, max: 5 });
82+
setEnv(' ');
83+
expect(await primaryPool()).toEqual({ min: 0, max: 5 });
84+
});
85+
86+
it('raises the ceiling the operator asked for, leaving the floor alone', async () => {
87+
setEnv('40');
88+
// The floor stays 0 — `OS_DATABASE_POOL_MIN` is deliberately NOT exposed
89+
// (ruling 2026-09-02: today's path runs `min: 0`; a later patch if needed).
90+
expect(await primaryPool()).toEqual({ min: 0, max: 40 });
91+
});
92+
93+
it("a datasource's own declared pool outranks the operator env", async () => {
94+
setEnv('40');
95+
const pool = await primaryPool({ pool: { min: 2, max: 9 } });
96+
expect(pool).toMatchObject({ min: 2, max: 9 });
97+
});
98+
99+
it('refuses a non-integer value loudly instead of silently keeping the default', async () => {
100+
// The failure this rejects is the lenient `Number(process.env.X ?? d)`
101+
// shape: a typo becomes NaN, knex gets an unsizable pool, and the operator
102+
// who was TRYING to raise the ceiling silently keeps the one they wanted
103+
// to leave. A pool ceiling is only ever measured in production.
104+
for (const bad of ['abc', '10.5', '-4', '0', '1e3', '0x10', '10 20']) {
105+
setEnv(bad);
106+
await expect(
107+
factory().create({ driver: 'postgres', config: { url: 'postgres://app@db.internal:5432/app' } }),
108+
).rejects.toThrow(/OS_DATABASE_POOL_MAX must be a positive integer/);
109+
}
110+
});
111+
112+
it('names the variable, the value it rejected and the sizing rule in the refusal', async () => {
113+
setEnv('lots');
114+
await expect(
115+
factory().create({ driver: 'postgres', config: { url: 'postgres://app@db.internal:5432/app' } }),
116+
).rejects.toThrow(/got "lots"[\s\S]*max_connections/);
117+
});
118+
119+
it('applies to the mysql arm on the same terms', async () => {
120+
setEnv('12');
121+
const handle: any = await factory().create({
122+
driver: 'mysql',
123+
config: { url: 'mysql://app@db.internal:3306/app' },
124+
});
125+
try {
126+
expect(knexConfigOf(handle.driver ?? handle).pool).toEqual({ min: 0, max: 12 });
127+
} finally {
128+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
129+
}
130+
});
131+
132+
// ⛔ The guard for the constraint this change must not breach. `sqlite` /
133+
// `sqlite-wasm` / `memory` / `turso` REJECT a declared `pool` outright under
134+
// three maintainer rulings (#5714 / #5931 / #7243), and knex's better-sqlite3
135+
// dialect pins `{min:1,max:1}` on purpose — two connections to `:memory:` are
136+
// two separate databases. The env is read inside `buildSqlPool`, which only
137+
// the `postgres` / `mysql` arms call, so those arms structurally cannot see
138+
// it. This pin is what makes "structurally" a measurement.
139+
it('does not leak a pool onto an arm that refuses to be pooled', async () => {
140+
setEnv('40');
141+
const handle: any = await factory().create({ driver: 'sqlite', config: { filename: ':memory:' } });
142+
try {
143+
const cfg = knexConfigOf(handle.driver ?? handle);
144+
expect(cfg.pool?.max).not.toBe(40);
145+
} finally {
146+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
147+
}
148+
});
149+
});

packages/services/service-datasource/src/default-datasource-driver-factory.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,60 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett
614614
};
615615
}
616616

617+
/**
618+
* The operator-facing ceiling for a factory-built SQL datasource's knex pool.
619+
*
620+
* Named per AGENTS.md Prime Directive #9 (`OS_{DOMAIN}_{NAME}`): the domain is
621+
* `DATABASE`, which is the family this joins (`OS_DATABASE_URL`,
622+
* `OS_DATABASE_DRIVER`, `OS_DATABASE_SQLITE_JOURNAL_MODE`). `OS_DB_*` is not a
623+
* prefix this repo uses.
624+
*/
625+
const POOL_MAX_ENV = 'OS_DATABASE_POOL_MAX';
626+
627+
/**
628+
* The pool a `postgres` / `mysql` datasource gets when it declares no `pool`
629+
* and the operator sets no env. ⛔ Do not move these: an unset env is the
630+
* upgrade path for every existing deployment, and changing either number here
631+
* changes how many connections every running cluster opens on upgrade.
632+
*/
633+
const DEFAULT_SQL_POOL_MIN = 0;
634+
const DEFAULT_SQL_POOL_MAX = 5;
635+
636+
function invalidPoolMaxMessage(raw: string): string {
637+
return (
638+
`${POOL_MAX_ENV} must be a positive integer, got ${JSON.stringify(raw)}. ` +
639+
'It caps the connections each replica opens to postgres / mysql; size it so ' +
640+
`replicas × ${POOL_MAX_ENV} stays below the database's max_connections, leaving ` +
641+
'headroom for migrations and admin connections. ' +
642+
`Unset ${POOL_MAX_ENV} to keep the default of ${DEFAULT_SQL_POOL_MAX}.`
643+
);
644+
}
645+
646+
/**
647+
* `OS_DATABASE_POOL_MAX`, or `undefined` when the operator set nothing.
648+
*
649+
* Strict on purpose, and loud rather than lenient: the repo's older numeric env
650+
* reads are `Number(process.env.X ?? default)`, which turns a typo into a
651+
* silent `NaN` and hands knex a pool it cannot size. A pool ceiling is measured
652+
* only in production, so a value that does not parse must refuse the boot
653+
* instead of quietly reverting to the default the operator was trying to raise.
654+
* Blank is read as unset, not as garbage — `OS_DATABASE_POOL_MAX=` in a compose
655+
* file is a declared-but-unfilled variable, and refusing that would break boots
656+
* that are asking for today's behaviour.
657+
*/
658+
function readPoolMaxEnv(): number | undefined {
659+
const raw = process.env[POOL_MAX_ENV];
660+
if (raw === undefined) return undefined;
661+
const trimmed = raw.trim();
662+
if (trimmed === '') return undefined;
663+
// Reject before Number(): it accepts '0x10', '1e3', ' 12 ' and '12.0', none of
664+
// which an operator meant to write as a connection count.
665+
if (!/^[0-9]+$/.test(trimmed)) throw new Error(invalidPoolMaxMessage(raw));
666+
const value = Number(trimmed);
667+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(invalidPoolMaxMessage(raw));
668+
return value;
669+
}
670+
617671
/**
618672
* Knex pool options for a SQL driver, from the datasource's own `pool` block.
619673
*
@@ -622,12 +676,33 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett
622676
* hardcoded `{ min: 0, max: 5 }` over the top, so an author who sized their pool
623677
* got the defaults and no indication. Those defaults are preserved for the
624678
* unspecified case, so nothing that did not set `pool` changes behaviour.
679+
*
680+
* ## Why the env is read HERE and not in the driver (#14176)
681+
*
682+
* The primary datasource — the one behind `OS_DATABASE_URL` — is composed by
683+
* the CLI as `config: { url, ...autoMigrate }` with **no `pool` block**, so the
684+
* `max: 5` fallback on this line is the number that reaches knex on every
685+
* self-hosted deployment. `SqlDriver` therefore never sees an "unspecified"
686+
* pool from this path: an env read in the driver would be dead code, because
687+
* this function's explicit `{min, max}` always wins. This is the only site that
688+
* decides the unspecified case, which is why it is the only site that needs to
689+
* know the env exists — the precedence below is expressed once, here.
690+
*
691+
* Precedence: a declared `pool.max` > `OS_DATABASE_POOL_MAX` > `5`. An operator
692+
* knob must not override what an author wrote about their own datasource, and
693+
* with the env unset nothing changes at all.
694+
*
695+
* ⛔ Reaches only `postgres` / `mysql` — the two arms that call this function.
696+
* `memory` / `sqlite` / `sqlite-wasm` / `turso` receive no pool parameter at all
697+
* and reject a declared one outright (`POOL_UNSUPPORTED_DRIVER_IDS`, rulings
698+
* #5714 / #5931 / #7243); the env cannot change that, because it is read inside
699+
* a function those arms never call.
625700
*/
626701
function buildSqlPool(spec: DatasourceConnectionSpec): Record<string, unknown> {
627702
const pool = (spec.pool ?? {}) as Record<string, unknown>;
628703
return {
629-
min: typeof pool.min === 'number' ? pool.min : 0,
630-
max: typeof pool.max === 'number' ? pool.max : 5,
704+
min: typeof pool.min === 'number' ? pool.min : DEFAULT_SQL_POOL_MIN,
705+
max: typeof pool.max === 'number' ? pool.max : (readPoolMaxEnv() ?? DEFAULT_SQL_POOL_MAX),
631706
...(typeof pool.idleTimeoutMillis === 'number' ? { idleTimeoutMillis: pool.idleTimeoutMillis } : {}),
632707
...(typeof pool.connectionTimeoutMillis === 'number'
633708
? { acquireTimeoutMillis: pool.connectionTimeoutMillis }

0 commit comments

Comments
 (0)