Skip to content

Commit 7457a09

Browse files
authored
fix(driver-sql): give the bounded connection attempt an accurate error message (#3769) (#3791)
#3781 bounded a connection attempt at 10s via pool.createTimeoutMillis, which stopped the 30s hang but kept knex's own wording: "Timeout acquiring a connection. The pool is probably full". The pool is not full — the server never completed the handshake — so that message sends an operator to tune pool.max while the network is what is broken. SqlDriver now also sets the dialect's own connect timeout, which fails with a message that names what happened: pg / postgres / postgresql / cockroachdb → connectionTimeoutMillis → "timeout expired" mysql / mysql2 → connectTimeout → "connect ETIMEDOUT" Carrying the timeout requires `connection` to be an object, so a URL string moves into the client's URL slot (connectionString for pg, uri for mysql2). Measured against a black-holing listener: both forms still reach the URL's own host/port and still honour ?sslmode=require. SQLite is untouched — a file open has no handshake to time out. A function-valued connection is left alone. The two bounds are deliberately unequal. They race and knex wins a tie, so equal values let the pool timeout fire first and the accurate message is never seen — caught by the black-hole test, which now asserts the wording rather than merely that something threw. The dialect timeout is the effective bound at 10s; the pool timeout is a strictly looser backstop, 10s → 15s. driver.config keeps the shape the author passed — the rewrite applies only to what knex receives. serve.ts's startup banner and createDatabase() both depend on that; a test pins it. createDatabase()'s own admin connection now gets the same bound. Also documents the two defaults in drivers.mdx, which had claimed the constructor takes a Knex.Config "verbatim".
1 parent fb90784 commit 7457a09

4 files changed

Lines changed: 215 additions & 20 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
fix(driver-sql): give the bounded connection attempt an accurate error message (#3769)
6+
7+
#3781 bounded a connection attempt at 10s via `pool.createTimeoutMillis`, which
8+
stopped the 30s hang but kept knex's own wording: `Timeout acquiring a
9+
connection. The pool is probably full`. The pool is not full — the server never
10+
completed the handshake — so that message sends an operator to tune `pool.max`
11+
while the network is what is broken. This is the same defect class the boot
12+
guard in #3741 was about: an error that reads nothing like its cause.
13+
14+
`SqlDriver` now also sets the **dialect's own** connect timeout, which fails with
15+
a message that names what happened:
16+
17+
| client | key | message |
18+
|---|---|---|
19+
| `pg` / `postgres` / `postgresql` / `cockroachdb` | `connectionTimeoutMillis` | `timeout expired` |
20+
| `mysql` / `mysql2` | `connectTimeout` | `connect ETIMEDOUT` |
21+
22+
Carrying the timeout requires `connection` to be an object, so a URL string is
23+
moved into the dialect's URL slot (`connectionString` for pg, `uri` for mysql2).
24+
Verified against a black-holing listener that both forms still reach the URL's
25+
own host/port and still honour `?sslmode=require`. SQLite is untouched — opening
26+
a file has no handshake to time out.
27+
28+
**The two bounds are deliberately unequal.** They race and knex wins a tie, so
29+
equal values would let the pool timeout fire first and the accurate message would
30+
never be seen. The dialect timeout is the effective bound at **10s**; the pool
31+
timeout is a strictly looser backstop, raised from 10s to **15s**, reached only
32+
by a dialect with no connect-timeout knob or one that ignores the one we set.
33+
34+
`driver.config` keeps the shape the author passed — the rewrite applies only to
35+
what knex receives. Two existing readers depend on that: `serve.ts`'s startup
36+
banner and `createDatabase()`, which parses the URL to swap in the maintenance
37+
database. A test pins it.
38+
39+
`createDatabase()`'s own admin connection now gets the same bound; it is opened
40+
during boot against the very server we already suspect is unreachable, so it must
41+
not be the one place that still waits 30s.
42+
43+
**Migration.** None for a healthy datasource. A deployment that deliberately
44+
needs longer than 10s to establish a connection (a slow cross-region replica)
45+
sets `connection.connectionTimeoutMillis` (pg) or `connection.connectTimeout`
46+
(mysql2) explicitly, and it is left alone.

content/docs/data-modeling/drivers.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ even earlier — that is where `MongoDBDriver` puts its
127127
pnpm add @objectstack/driver-sql pg
128128
```
129129
130-
`SqlDriver`'s constructor accepts a [Knex `Knex.Config`](https://knexjs.org/guide/#configuration-options) object verbatim.
130+
`SqlDriver`'s constructor accepts a [Knex `Knex.Config`](https://knexjs.org/guide/#configuration-options)
131+
object, passing it through unchanged except for two connect-timeout defaults
132+
described [below](#connect-timeouts).
131133
132134
```typescript
133135
import { SqlDriver } from '@objectstack/driver-sql';
@@ -152,6 +154,29 @@ Or via env var alone (driver inferred from `postgres://` scheme):
152154
export OS_DATABASE_URL=postgres://admin:secret@db.example.com:5432/myapp
153155
```
154156

157+
### Connect timeouts
158+
159+
A database endpoint that accepts the TCP connection but never completes the
160+
handshakean overloaded instance, a half-open firewall, a load balancer
161+
mid-failovermakes every query *wait* rather than fail. Left to Knex's own
162+
defaults that wait is 30 seconds per query on the request path, and with a small
163+
`pool.max` a handful of them saturate the pool. So `SqlDriver` supplies two
164+
defaults ([#3769](https://github.com/objectstack-ai/objectstack/issues/3769)):
165+
166+
| Setting | Default | Purpose |
167+
| :--- | :--- | :--- |
168+
| `connection.connectionTimeoutMillis` (pg) / `connection.connectTimeout` (mysql2) | `10_000` | The effective bound. Fails with the driver's own wording — `timeout expired` / `connect ETIMEDOUT` — which names the network. |
169+
| `pool.createTimeoutMillis` | `15_000` | Backstop, only reached by a client that has no connect-timeout option or ignores it. Deliberately looser: the two race, and Knex wins a tie, so an equal value would mask the accurate message with Knex's misleading "the pool is probably full". |
170+
171+
Set either explicitly and it is left untoucheddo that when a datasource
172+
legitimately takes longer to connect (a distant cross-region replica). SQLite
173+
gets neither: opening a file has no handshake to time out.
174+
175+
Carrying a connect timeout requires `connection` to be an object, so a URL string
176+
is moved into the client's URL slot (`connectionString` for pg, `uri` for
177+
mysql2) before reaching Knex. This affects only what Knex receivesthe config
178+
you passed is preserved as-is on the driver.
179+
155180
## MongoDB
156181

157182
Configuration properties for the MongoDB driver.

packages/plugins/driver-sql/src/sql-driver-connect-bound.test.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ afterEach(async () => {
3232
describe('SqlDriver — connection-attempt bound (framework#3769)', () => {
3333
it('applies a default createTimeoutMillis when the host sets no pool config', () => {
3434
const d = make({ client: 'pg', connection: 'postgres://u:p@127.0.0.1:1/d' });
35-
expect((d as any).knex.client.config.pool.createTimeoutMillis).toBe(10_000);
35+
expect((d as any).knex.client.config.pool.createTimeoutMillis).toBe(15_000);
3636
});
3737

3838
it('applies it alongside a host pool config without clobbering min/max', () => {
@@ -42,7 +42,7 @@ describe('SqlDriver — connection-attempt bound (framework#3769)', () => {
4242
pool: { min: 0, max: 5 },
4343
});
4444
const pool = (d as any).knex.client.config.pool;
45-
expect(pool).toMatchObject({ min: 0, max: 5, createTimeoutMillis: 10_000 });
45+
expect(pool).toMatchObject({ min: 0, max: 5, createTimeoutMillis: 15_000 });
4646
});
4747

4848
it("leaves a host's explicit createTimeoutMillis alone", () => {
@@ -54,23 +54,97 @@ describe('SqlDriver — connection-attempt bound (framework#3769)', () => {
5454
expect((d as any).knex.client.config.pool.createTimeoutMillis).toBe(45_000);
5555
});
5656

57+
// The pool bound stops the wait but knex blames "the pool is probably full".
58+
// Each network dialect also gets its OWN connect timeout so the message names
59+
// the network instead — `timeout expired` (pg) / `connect ETIMEDOUT` (mysql2).
60+
describe('per-dialect connect timeout (accurate diagnosis)', () => {
61+
it('moves a pg URL into connectionString and rides the timeout alongside it', () => {
62+
const d = make({ client: 'pg', connection: 'postgres://u:p@host:5432/d' });
63+
expect((d as any).knex.client.config.connection).toEqual({
64+
connectionString: 'postgres://u:p@host:5432/d',
65+
connectionTimeoutMillis: 10_000,
66+
});
67+
});
68+
69+
it('moves a mysql2 URL into uri with the mysql2 spelling of the timeout', () => {
70+
const d = make({ client: 'mysql2', connection: 'mysql://u:p@host:3306/d' });
71+
expect((d as any).knex.client.config.connection).toEqual({
72+
uri: 'mysql://u:p@host:3306/d',
73+
connectTimeout: 10_000,
74+
});
75+
});
76+
77+
it('adds the timeout to an object connection without disturbing its fields', () => {
78+
const d = make({
79+
client: 'pg',
80+
connection: { host: 'db.example.com', port: 5432, user: 'admin', database: 'app', ssl: true },
81+
});
82+
expect((d as any).knex.client.config.connection).toEqual({
83+
host: 'db.example.com', port: 5432, user: 'admin', database: 'app', ssl: true,
84+
connectionTimeoutMillis: 10_000,
85+
});
86+
});
87+
88+
it("leaves a host's explicit connect timeout alone", () => {
89+
const d = make({
90+
client: 'pg',
91+
connection: { host: 'db.example.com', connectionTimeoutMillis: 60_000 },
92+
});
93+
expect((d as any).knex.client.config.connection.connectionTimeoutMillis).toBe(60_000);
94+
});
95+
96+
it('injects nothing for sqlite — a file open has no handshake to time out', () => {
97+
const d = make({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
98+
expect((d as any).knex.client.config.connection).toEqual({ filename: ':memory:' });
99+
});
100+
101+
it('leaves a function-valued connection alone — the host builds each one itself', () => {
102+
const provider = () => ({ host: 'x' });
103+
const d = make({ client: 'pg', connection: provider });
104+
expect((d as any).knex.client.config.connection).toBe(provider);
105+
});
106+
});
107+
108+
// `driver.config` is load-bearing for two readers that predate this change:
109+
// serve.ts's startup banner (`describeRegisteredDriver` reads conn.host /
110+
// conn.filename) and `createDatabase()` (parses the URL to swap in the
111+
// maintenance database). Both would break on the rewritten shape, so the
112+
// rewrite must apply to what knex receives — never to what the author gave us.
113+
it('keeps driver.config in the shape the author passed', () => {
114+
const d = make({ client: 'pg', connection: 'postgres://u:p@host:5432/d', pool: { min: 0, max: 5 } });
115+
expect((d as any).config.connection).toBe('postgres://u:p@host:5432/d');
116+
expect((d as any).config.pool).toEqual({ min: 0, max: 5 });
117+
});
118+
57119
it('actually bounds a query against a black-holing endpoint', async () => {
58120
const bh = blackHole();
59121
const port = await bh.listen();
60122
try {
61-
// 700ms so the assertion is decisive: unbounded is 30s.
123+
// The default is 10s; unbounded would be knex/tarn's 30s.
62124
const d = make({
63125
client: 'pg',
64126
connection: `postgres://u:p@127.0.0.1:${port}/d`,
65-
pool: { min: 0, max: 2, createTimeoutMillis: 700 },
127+
pool: { min: 0, max: 2 },
66128
});
67129
const started = Date.now();
68-
await expect((d as any).knex.raw('SELECT 1')).rejects.toThrow();
69-
expect(Date.now() - started).toBeLessThan(5_000);
130+
const err = await (d as any).knex.raw('SELECT 1').then(
131+
() => { throw new Error('query resolved but should have failed'); },
132+
(e: Error) => e,
133+
);
134+
const elapsed = Date.now() - started;
135+
136+
// The pg-level timeout fires first (10s), so the message names the
137+
// connection attempt rather than blaming pool sizing. Guarding against
138+
// the regression is the point: knex's own wording would send an operator
139+
// to tune `pool.max` while the network is what is broken.
140+
expect(err.message).toContain('timeout expired');
141+
expect(err.message).not.toContain('pool is probably full');
142+
expect(elapsed).toBeGreaterThan(8_000);
143+
expect(elapsed).toBeLessThan(20_000);
70144
} finally {
71145
bh.close();
72146
}
73-
}, 20_000);
147+
}, 40_000);
74148

75149
it('does not disturb a working sqlite connection', async () => {
76150
const d = make({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -598,21 +598,68 @@ export class SqlDriver implements IDataDriver {
598598
* knows better (a deliberately slow cross-region replica) sets its own
599599
* `pool.createTimeoutMillis` and this leaves it alone.
600600
*
601-
* NOTE this bounds the wait but not the diagnosis: knex reports it as
602-
* "Timeout acquiring a connection. The pool is probably full", which points
603-
* at pool sizing rather than the network. Making the message accurate needs a
604-
* dialect-specific connect timeout (pg's `connectionTimeoutMillis`), which
605-
* changes the shape of `connection` — tracked in #3769.
601+
* Applied in two layers, because the outer one bounds the wait but misstates
602+
* the cause. Knex reports its own timeout as "Timeout acquiring a connection.
603+
* The pool is probably full" — which sends an operator to tune `pool.max`
604+
* while the actual problem is the network. So each network dialect ALSO gets
605+
* its own connect timeout, which fails with a message that names what really
606+
* happened (`timeout expired` from pg, `connect ETIMEDOUT` from mysql2).
607+
*
608+
* **The two bounds must not be equal.** They race, and knex wins a tie — set
609+
* to the same value, the pool timeout fires first and the accurate message is
610+
* never seen (caught by the black-hole test, which asserts the wording). So
611+
* the dialect timeout is the effective bound at 10s and the pool timeout is a
612+
* strictly looser backstop at 15s, reached only by a dialect that has no
613+
* connect-timeout knob (SQLite) or ignores the one we set.
614+
*/
615+
private static readonly DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
616+
private static readonly DEFAULT_CREATE_TIMEOUT_MS = 15_000;
617+
618+
/**
619+
* Per-dialect connect timeout: how the driver spells "how long may
620+
* establishing ONE connection take", and where a URL goes once `connection`
621+
* has to become an object to carry it.
622+
*
623+
* SQLite (`better-sqlite3` / `sqlite3`) is deliberately absent — it opens a
624+
* file, so there is no handshake to time out and nothing to inject.
606625
*/
607-
private static readonly DEFAULT_CREATE_TIMEOUT_MS = 10_000;
626+
private static readonly DIALECT_CONNECT_TIMEOUT: Record<string, { key: string; urlKey: string }> = {
627+
pg: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' },
628+
postgres: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' },
629+
postgresql: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' },
630+
cockroachdb: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' },
631+
mysql: { key: 'connectTimeout', urlKey: 'uri' },
632+
mysql2: { key: 'connectTimeout', urlKey: 'uri' },
633+
};
608634

609635
private static withConnectBound(knexConfig: Record<string, any>): Record<string, any> {
610636
const pool = knexConfig.pool as Record<string, any> | undefined;
611-
if (pool?.createTimeoutMillis !== undefined) return knexConfig; // host chose its own
612-
return {
613-
...knexConfig,
614-
pool: { ...(pool ?? {}), createTimeoutMillis: SqlDriver.DEFAULT_CREATE_TIMEOUT_MS },
615-
};
637+
const bounded: Record<string, any> =
638+
pool?.createTimeoutMillis === undefined
639+
? {
640+
...knexConfig,
641+
pool: { ...(pool ?? {}), createTimeoutMillis: SqlDriver.DEFAULT_CREATE_TIMEOUT_MS },
642+
}
643+
: { ...knexConfig }; // host chose its own bound — respect it
644+
645+
const dialect = SqlDriver.DIALECT_CONNECT_TIMEOUT[String(knexConfig.client ?? '')];
646+
if (!dialect) return bounded; // sqlite / unknown client — nothing to inject
647+
648+
const conn = knexConfig.connection;
649+
if (typeof conn === 'string') {
650+
// The URL must move into the dialect's own URL slot so the timeout can
651+
// ride alongside it. Verified for both dialects: the connection attempt
652+
// still goes to the URL's host/port, `?sslmode=` is still honoured.
653+
bounded.connection = {
654+
[dialect.urlKey]: conn,
655+
[dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS,
656+
};
657+
} else if (conn && typeof conn === 'object' && (conn as any)[dialect.key] === undefined) {
658+
bounded.connection = { ...(conn as object), [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS };
659+
}
660+
// A function-valued `connection` (knex's per-acquire provider) is left
661+
// alone: the host is building each connection itself and owns its timeouts.
662+
return bounded;
616663
}
617664

618665
/**
@@ -4123,7 +4170,10 @@ export class SqlDriver implements IDataDriver {
41234170
return; // Unsupported dialect for auto-creation
41244171
}
41254172

4126-
const adminKnex = knex(adminConfig);
4173+
// Same connect bound as the main pool (#3769) — this admin connection is
4174+
// opened during boot against the very server we already suspect might be
4175+
// unreachable, so it must not be the one place that waits 30s.
4176+
const adminKnex = knex(SqlDriver.withConnectBound(adminConfig));
41274177
try {
41284178
if (this.isPostgres) {
41294179
await adminKnex.raw(`CREATE DATABASE "${dbName}"`);

0 commit comments

Comments
 (0)