Skip to content

Commit 6146c3b

Browse files
committed
postgres-engine: scope search statement_timeout to the transaction
searchKeyword and searchVector run on a pooled postgres.js client (max: 10 by default). The original code bounded each search with await sql`SET statement_timeout = '8s'` try { await sql`<query>` } finally { await sql`SET statement_timeout = '0'` } but every tagged template is an independent round-trip that picks an arbitrary connection from the pool. The SET, the query, and the reset could all land on DIFFERENT connections. In practice the GUC sticks to whichever connection ran the SET and then gets returned to the pool — the next unrelated caller on that connection inherits the 8s timeout (clipping legitimate long queries) or the reset-to-0 (disabling the guard for whoever expected it). A crash in the middle leaves the state set permanently. Wrap each search in sql.begin(async sql => …). postgres.js reserves a single connection for the transaction body, so the SET LOCAL, the query, and the implicit COMMIT all run on the same connection. SET LOCAL scopes the GUC to the transaction — COMMIT or ROLLBACK restores the previous value automatically, regardless of the code path out. Error paths can no longer leak the GUC. No API change. Timeout value and semantics are identical (8s cap on search queries, no effect on embed --all / bulk import which runs outside these methods). Only one transaction per search — BEGIN + COMMIT round-trips are negligible next to a ranked FTS or pgvector query. Also closes the earlier audit finding R4-F002 which reported the same pattern on searchKeyword. This PR covers both searchKeyword and searchVector so the pool-leak class is fully closed. Tests (test/postgres-engine.test.ts, new file): - No bare SET statement_timeout remains after stripping comments. - searchKeyword and searchVector each wrap their query in sql.begin. - Both use SET LOCAL. - Neither explicitly clears the timeout with SET statement_timeout=0. Source-level guardrails keep the fast unit suite DB-free. Live Postgres coverage of the search path is in test/e2e/search-quality.test.ts, which continues to exercise these methods end-to-end against pgvector when DATABASE_URL is set.
1 parent b7e3005 commit 6146c3b

2 files changed

Lines changed: 132 additions & 16 deletions

File tree

src/core/postgres-engine.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,17 @@ export class PostgresEngine implements BrainEngine {
191191
const detailLow = opts?.detail === 'low';
192192

193193
// Search-only timeout: prevents DoS via expensive queries without
194-
// affecting long-running operations like embed --all or bulk import
195-
await sql`SET statement_timeout = '8s'`;
196-
try {
194+
// affecting long-running operations like embed --all or bulk import.
195+
// SET LOCAL inside sql.begin() scopes the GUC to the transaction so
196+
// it can never leak onto a pooled connection returned to other
197+
// callers. A bare `SET statement_timeout` goes to an arbitrary
198+
// connection from the pool, lives past this method, and either
199+
// clips an unrelated caller's long-running query (DoS) or — via
200+
// `SET statement_timeout = 0` — disables the guard for them.
201+
const rows = await sql.begin(async sql => {
202+
await sql`SET LOCAL statement_timeout = '8s'`;
197203
// CTE: rank pages by FTS score, then pick the best chunk per page in SQL
198-
const rows = await sql`
204+
return await sql`
199205
WITH ranked_pages AS (
200206
SELECT p.id, p.slug, p.title, p.type,
201207
ts_rank(p.search_vector, websearch_to_tsquery('english', ${query})) AS score
@@ -221,10 +227,8 @@ export class PostgresEngine implements BrainEngine {
221227
FROM best_chunks
222228
ORDER BY score DESC
223229
`;
224-
return rows.map(rowToSearchResult);
225-
} finally {
226-
await sql`SET statement_timeout = '0'`;
227-
}
230+
});
231+
return rows.map(rowToSearchResult);
228232
}
229233

230234
async searchVector(embedding: Float32Array, opts?: SearchOpts): Promise<SearchResult[]> {
@@ -241,10 +245,12 @@ export class PostgresEngine implements BrainEngine {
241245

242246
const vecStr = '[' + Array.from(embedding).join(',') + ']';
243247

244-
// Search-only timeout (see searchKeyword for rationale)
245-
await sql`SET statement_timeout = '8s'`;
246-
try {
247-
const rows = await sql`
248+
// Search-only timeout (see searchKeyword for rationale). SET LOCAL +
249+
// sql.begin ensures the GUC stays transaction-scoped on the pooled
250+
// connection.
251+
const rows = await sql.begin(async sql => {
252+
await sql`SET LOCAL statement_timeout = '8s'`;
253+
return await sql`
248254
SELECT
249255
p.slug, p.id as page_id, p.title, p.type,
250256
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
@@ -260,10 +266,8 @@ export class PostgresEngine implements BrainEngine {
260266
LIMIT ${limit}
261267
OFFSET ${offset}
262268
`;
263-
return rows.map(rowToSearchResult);
264-
} finally {
265-
await sql`SET statement_timeout = '0'`;
266-
}
269+
});
270+
return rows.map(rowToSearchResult);
267271
}
268272

269273
async getEmbeddingsByChunkIds(ids: number[]): Promise<Map<number, Float32Array>> {

test/postgres-engine.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* postgres-engine.ts source-level guardrails.
3+
*
4+
* Live Postgres coverage for search paths lives in test/e2e/search-quality.test.ts.
5+
* This file stays fast and DB-free: it inspects the source of
6+
* src/core/postgres-engine.ts to lock in decisions that protect the
7+
* shared connection pool from per-request GUC leaks.
8+
*
9+
* Regression: R6-F006 / R4-F002.
10+
* searchKeyword and searchVector used to call bare
11+
* await sql`SET statement_timeout = '8s'`
12+
* ...query...
13+
* finally { await sql`SET statement_timeout = '0'` }
14+
* against the shared pool. Each tagged template picks an arbitrary
15+
* connection, so the SET, the query, and the reset could all land on
16+
* DIFFERENT connections. Worst case: the 8s GUC sticks on some pooled
17+
* connection and clips the next caller's long-running query; or the
18+
* reset to 0 lands on a connection that other code expected to be
19+
* protected. The fix wraps each query in sql.begin() and uses
20+
* SET LOCAL so the GUC is transaction-scoped and auto-resets on
21+
* COMMIT/ROLLBACK, regardless of error path.
22+
*/
23+
24+
import { describe, test, expect } from 'bun:test';
25+
import { readFileSync } from 'fs';
26+
import { join } from 'path';
27+
28+
const SRC = readFileSync(
29+
join(import.meta.dir, '..', 'src', 'core', 'postgres-engine.ts'),
30+
'utf-8',
31+
);
32+
33+
describe('postgres-engine / search path timeout isolation', () => {
34+
test('no bare `SET statement_timeout` statement survives', () => {
35+
// Strip comments so the commentary mentioning the anti-pattern does
36+
// not trigger a false positive. Block-comment + line-comment strip.
37+
const stripped = SRC
38+
.replace(/\/\*[\s\S]*?\*\//g, '')
39+
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
40+
41+
// Match a tagged-template statement of the form
42+
// sql`SET statement_timeout = ...`
43+
// that is NOT preceded by LOCAL. This is the exact shape that bleeds
44+
// onto pooled connections; SET LOCAL is safe inside a transaction.
45+
const bare = stripped.match(
46+
/sql`\s*SET\s+(?!LOCAL\s)statement_timeout\b[^`]*`/gi,
47+
);
48+
expect(bare).toBeNull();
49+
});
50+
51+
test('searchKeyword wraps its query in sql.begin()', () => {
52+
const fn = extractMethod(SRC, 'searchKeyword');
53+
expect(fn).toMatch(/sql\.begin\s*\(\s*async\s+sql\s*=>/);
54+
});
55+
56+
test('searchVector wraps its query in sql.begin()', () => {
57+
const fn = extractMethod(SRC, 'searchVector');
58+
expect(fn).toMatch(/sql\.begin\s*\(\s*async\s+sql\s*=>/);
59+
});
60+
61+
test('both search methods use SET LOCAL for the timeout', () => {
62+
const keyword = extractMethod(SRC, 'searchKeyword');
63+
const vector = extractMethod(SRC, 'searchVector');
64+
expect(keyword).toMatch(/SET\s+LOCAL\s+statement_timeout/);
65+
expect(vector).toMatch(/SET\s+LOCAL\s+statement_timeout/);
66+
});
67+
68+
test('neither search method clears the timeout with `SET statement_timeout = 0`', () => {
69+
// The reset-to-zero pattern was the other half of the leak: if SET
70+
// LOCAL is in play, COMMIT handles the reset and an explicit
71+
// `SET statement_timeout = '0'` would itself leak the GUC change
72+
// onto the returned connection. Strip comments first so the
73+
// commentary in the method itself (which quotes the anti-pattern
74+
// to explain it) does not trigger a false positive.
75+
const keyword = stripComments(extractMethod(SRC, 'searchKeyword'));
76+
const vector = stripComments(extractMethod(SRC, 'searchVector'));
77+
expect(keyword).not.toMatch(/SET\s+statement_timeout\s*=\s*['"]?0/);
78+
expect(vector).not.toMatch(/SET\s+statement_timeout\s*=\s*['"]?0/);
79+
});
80+
});
81+
82+
function stripComments(s: string): string {
83+
return s
84+
.replace(/\/\*[\s\S]*?\*\//g, '')
85+
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
86+
}
87+
88+
// extractMethod grabs the body of a class method by brace-matching from
89+
// its opening line. Returns the method body up to the matching closing
90+
// brace. Good enough for the small number of methods in this file.
91+
function extractMethod(source: string, name: string): string {
92+
// Find "async <name>(" at method-definition indentation (2 spaces).
93+
const openRe = new RegExp(`^\\s+async\\s+${name}\\s*\\(`, 'm');
94+
const match = openRe.exec(source);
95+
if (!match) {
96+
throw new Error(`method ${name} not found in postgres-engine.ts`);
97+
}
98+
// Scan forward balancing braces.
99+
let i = source.indexOf('{', match.index);
100+
if (i < 0) throw new Error(`no opening brace for ${name}`);
101+
const start = i;
102+
let depth = 0;
103+
for (; i < source.length; i++) {
104+
const c = source[i];
105+
if (c === '{') depth++;
106+
else if (c === '}') {
107+
depth--;
108+
if (depth === 0) return source.slice(start, i + 1);
109+
}
110+
}
111+
throw new Error(`unbalanced braces in ${name}`);
112+
}

0 commit comments

Comments
 (0)