Skip to content

Commit 43bac51

Browse files
authored
perf(sql): read the confirmation gate's text in linear time (#2249)
* fix(sql): follow an EXPLAIN chain in a loop, not one call deeper per EXPLAIN The confirmation gate read the statement an EXPLAIN executes by calling itself on it, so a PostgreSQL text of EXPLAIN ANALYZE repeated ran V8 out of stack: the web editor, the VS Code extension's child and a Node server threw a RangeError from a 250,000-unit chain, short of the 1,000,000 units the statement analysis route accepts. The chain is now followed in a loop, which is the same tail call written out, so every verdict stays as it was. Bun's engine eliminates the tail call and never showed it, so the extension's query/target suite pins the depth under Node. * perf(sql): read the code point before a raw-string prefix off its code units Every reader asks whether an `r` on Spark continues a word, and the answer split the two code units before it into an array of code points to take the last one. On 1,000,000 units of `r` words that was most of the confirmation gate's 1.5 s. The code point is now read off the two code units: a surrogate pair whole, any other unit alone, as the array gave it. * perf(sql): keep the leading reader's trivia grammar once per grammar record `readLeadingKeyword` reads a leading `#` as a comment on every dialect, and it copied the caller's grammar record to say so on every call. The confirmation gate reads the leading keyword of every statement of a script, so a script of 100,000 short statements made 100,000 copies. The copy is now kept per record, which is a constant, so each dialect is copied once. * perf(sql): stop the confirmation gate reading the rest of a text again per nested or repeated form Four checks read the rest of the text again for every copy of what they looked for, so their cost grew with the square of the text, minutes at the 1,000,000 units the statement analysis route accepts: - a CTE body that opens with WITH had its list read again from that WITH, crossing every body nested inside it once per level (65 s for 250,000 units of nested bodies on PostgreSQL); - a Spark computed call looked for the end of its run, crossing every call nested inside it again (68 s for 250,000 units); - a Snowflake CREATE STAGE looked for URL to the end of the text after every occurrence (52 s for 250,000 units); - a FOR UPDATE lock clause in a block matched its pattern against the whole text before every UPDATE (40 s for 1,000,000 units). Where a parenthesised run closes is now remembered for every index a walk stood on (ParenthesisedRuns), which is exact because a walk's next step depends only on its index, and the nested CTE lists are read in place to share it. A conditional form is decided at the first occurrence its words follow, and the lock clause pattern reads the white space before the UPDATE and the four characters before that. Every verdict stays as it was: the existing gate tests, the analysis parity tests, and a probe comparing the old gate with this one over 400,000 generated texts and every connection type agree. * perf(sql): read a text's code words once for every form the confirmation gate asks about The gate walked the text once for every confirmation form and shared word it looked for, and read the words after every occurrence once per form that starts with that word: about 100 walks of a Snowflake block and more than 200 of a block of CREATE words. One walk now indexes the code words, prefixes and quoted names those forms are found by (indexCodeWords), word sequences that share their first word are compared as one tree, a quoted call's names as one set, and a grammar's forms are compiled once per record. A statement that is the whole text, as one under the caret is, is not read a second time. Measured on 1,000,000 units under Bun 1.4.4: 166,666 Snowflake BEGIN words 1065 ms to 40 ms, Snowflake CREATE words 2.8 s to 94 ms, 500,000 Spark words 649 ms to 69 ms, 100,000 Spark statements 645 ms to 114 ms. Every verdict stays as it was: the gate tests, the analysis parity tests and the old-against-new probe over 400,000 generated texts and every connection type agree. * docs: restate the statement analysis route's cost now the confirmation gate is linear The route's bound comment, the known limit in docs/SECURITY.md and backlog U202 named 1.2 s for 1,000,000 units on Snowflake, which the gate no longer costs. They now state the 2026-11-05 measurements: about 45 ms for a PostgreSQL script and up to about 0.22 s for the slowest texts found, of which the gate is at most about 0.2 s, so one user can still ask for about 26 s of CPU in every minute. U202 stays open, narrowed to that residual: its done-when asks for a charge that grows with the text or a gate near the splitter's 14 ms, and neither is met. S12 records a sibling found while measuring: the row limiter's CTE body reader is quadratic in nested bodies and recursive per level. * test(sql): admit the parenthesised-run reader to the splitter's browser closure The CTE list reader the splitter reaches through operative-keyword.ts now imports parenthesised.ts, which reaches only spans.ts and words.ts, so the closure the browser pays for gains one pure reader. * test(vscode-extension): give the long EXPLAIN chain test room to finish on a slow runner Two million-unit texts under c8 take about 1.2 s locally and went past mocha's 2 s default on a CI runner. The gate's growth is pinned by tests/unit/sql/dangerous-query.test.ts, so this test only needs time to finish, not a tighter bound.
1 parent 5b67914 commit 43bac51

16 files changed

Lines changed: 1009 additions & 248 deletions

‎docs/BACKLOG.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ None of it is a GitHub issue.
2727

2828
**Sections**
2929

30-
- [SQL statement reading](#sql-statement-reading) — S2-S11 · 8
30+
- [SQL statement reading](#sql-statement-reading) — S2-S12 · 9
3131
- [Drivers and connections](#drivers-and-connections) — D1-D457, U17 · 336
3232
- [Value interpolation](#value-interpolation) — V1
3333
- [Row editing](#row-editing) — R1–R3 · 3
@@ -170,6 +170,15 @@ Found 2026-11-02 while reviewing the shared statement guard.
170170

171171
**Done when:** the reader reads through the leading parentheses of a query expression to the keyword inside, the guard answers a parenthesised `SELECT` and a parenthesised compound query as a read and still refuses one whose body carries a side-effect word, the limiter's reading of the same text is decided in that change, and unit tests pin each shape in the guard's and the limiter's test files.
172172

173+
### S12. The row limiter's CTE body reader costs the square of nested bodies and a call per level
174+
175+
`readCteBodyWrite` in `src/lib/sql/operative-keyword.ts`, which `src/lib/db/utils/query-limiter.ts` asks of every statement that leads with `WITH`, reads the CTE list of each `WITH` nested in a body from scratch, so every level crosses each body inside it again, and it goes one call deeper per level through `readBodyWrite` and `readListWrite`.
176+
Measured on 2026-11-05 on PostgreSQL's grammar with `WITH a AS (` nested and closed: under Bun 1.4.4 it took 22 ms at 10,000 UTF-16 code units, 249 ms at 40,000 and 1.8 s at 100,000, and under Node 24 a nest of 5,000 levels (105,000 units) ended in `RangeError: Maximum call stack size exceeded`.
177+
The confirmation gate reads nested lists through `readOperativeKeywordAt` with a `CteListRuns`, which remembers where each run closes, since 2026-11-05; this reader shares nothing between its lists.
178+
Found 2026-11-05 while measuring the confirmation gate's cost on the statement analysis route.
179+
180+
**Done when:** `readCteBodyWrite` hands one `CteListRuns` to every list it reads and walks nested bodies without a call per level, and tests pin its growth between two sizes and a nest of 60,000 levels under Node, as the gate's tests do.
181+
173182
---
174183

175184
## Drivers and connections
@@ -6998,9 +7007,11 @@ Found 2026-11-03 while designing the statement analysis for #2204.
69987007
### U202. One signed-in user can keep the event loop busy with statement analyses
69997008

70007009
`POST /api/db/statement/analyze` (`src/app/api/db/statement/analyze/route.ts`) runs `analyzeStatement` on the event loop for a text of up to 1,000,000 UTF-16 code units and charges one `query` slot whatever the length.
7001-
Measured on 2026-11-04 with the whole text selected: under Node 24 through the route, a PostgreSQL script of that length took about 165 ms, 166,666 words `BEGIN ` on Snowflake about 1.2 s and 500,000 words `x` on Spark about 0.85 s; under Bun 1.4.4 the Snowflake text took 1.6 s, `isDangerousQuery` alone 1.08 s, and the statement splitter 14 ms.
7002-
At the bucket's default of 120 a minute, one user can therefore ask for more than a minute of CPU in every minute, and every other request of the process waits behind it; docs/SECURITY.md states it as a known limit.
7003-
The 1,000,000-unit bound and the single slot are #2204's decisions, so either change is a change of that contract.
7010+
The confirmation gate, `isDangerousQuery`, reads that length in linear time since 2026-11-05, where nested CTE bodies, nested Spark computed calls, Snowflake `CREATE STAGE` and `FOR UPDATE` lock clauses in a block had cost it the square of the text (65 s for 250,000 units of nested CTE bodies on PostgreSQL).
7011+
Measured on 2026-11-05 on `analyzeStatement` under Bun 1.4.4 with the whole text selected, a PostgreSQL script of that length takes about 45 ms and the slowest texts found about 0.22 s, a SQL Server script of 100,000 short statements and CTE bodies nested 83,000 deep; the gate alone takes up to about 0.2 s, against about 14 ms for the splitter.
7012+
At the bucket's default of 120 a minute, one user can therefore still ask for about 26 s of CPU in every minute, and every other request of the process waits behind each analysis; docs/SECURITY.md states it as a known limit.
7013+
Going further in the gate means rewriting the basic lexer (`readSqlSpan` and `readSqlWord` are about half of the cost) or merging the gate's walks of the text.
7014+
The 1,000,000-unit bound and the single slot are #2204's decisions, so a charge that grows with the text is a change of that contract.
70047015
Found 2026-11-04 by the review of #2204's route.
70057016

70067017
**Done when:** a text near the bound costs its caller in proportion to its length, by a charge that grows with the text or a gate whose worst case is near the splitter's, and the known limit in docs/SECURITY.md is removed or restated with the new measurement.

‎docs/SECURITY.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -863,8 +863,9 @@ These are real, current, and not oversights. Each is a decision with a reason.
863863
[`.env.example`](../.env.example) under Forwarded Headers.
864864
- **One signed-in user can keep the server's event loop busy with statement analyses.**
865865
`POST /api/db/statement/analyze` reads a text of up to 1,000,000 UTF-16 code units on the event loop, and charges one slot of the `query` budget for it whatever its length.
866-
Measured on 2026-11-04 under Node 24, a text of that length took about 165 ms on PostgreSQL and up to about 1.2 s on Snowflake, almost all of it in the confirmation gate, so the default 120 slots a minute buy one user more CPU time than the minute holds, and every other request of the process waits behind it.
867-
The bound is the LibreDB Mobile editor's own; a charge that grows with the text, or a faster gate, is a backlog entry (U202).
866+
Measured on 2026-11-05 on `analyzeStatement` under Bun 1.4.4 with the whole text selected, a text of that length took about 45 ms for a PostgreSQL script and up to about 0.22 s for the slowest texts found, a SQL Server script of 100,000 short statements and CTE bodies nested 83,000 deep; the confirmation gate's share is linear in the text and at most about 0.2 s of it (backlog U202).
867+
So the default 120 slots a minute buy one user up to about 26 s of CPU in every minute, and every other request of the process waits behind each analysis.
868+
The bound is the LibreDB Mobile editor's own, and the charge stays one slot whatever the length.
868869
- **The CapRover auto-connect variant runs a companion with the Docker socket, which is root on the host.**
869870
It is an operator decision, made by choosing the separate `libredb-studio-autoconnect` template; the plain CapRover template, the chart and every other channel never mount the socket.
870871
The exporter, `docker/discover.mjs`, sends GET requests to two Docker Engine API paths only, but that is a property of its code and not of the socket: whoever controls the companion's image tag or process controls the host.

‎src/app/api/db/statement/analyze/route.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ export const dynamic = "force-dynamic";
1313
/**
1414
* The longest text this route analyses, in UTF-16 code units, the unit of its offsets (#2204, decision 1).
1515
*
16-
* The analysis is CPU on the event loop, and every reader in it is linear in the text. Measured on 2026-11-04 with the
17-
* whole text selected, through this route under Node 24 and on `analyzeStatement` under Bun 1.4.4: a 1,000,000-unit
18-
* PostgreSQL script took about 165 ms, and the slowest text found, 166,666 words `BEGIN ` on Snowflake, about 1.2 s
19-
* under Node and 1.6 s under Bun, almost all of it in the confirmation gate; Spark's slowest took about 0.85 s. The
20-
* proxy truncates a body at 10 MB rather than refusing it, so without a bound of its own one request could hold every
21-
* other request of the process for ten times as long. It is the LibreDB Mobile editor's own cap, so that client never
22-
* meets it; the web editor and the VS Code extension analyse their text locally and never send it here.
16+
* The analysis is CPU on the event loop, and every reader in it is linear in the text. Measured on 2026-11-05 on
17+
* `analyzeStatement` under Bun 1.4.4 with the whole text selected: a 1,000,000-unit PostgreSQL script took about
18+
* 45 ms, 166,666 words `BEGIN ` on Snowflake about 48 ms, and the slowest texts found about 0.22 s, a SQL Server script
19+
* of 100,000 short statements and CTE bodies nested 83,000 deep. The proxy truncates a body at 10 MB rather than
20+
* refusing it, so without a bound of its own one request could hold every other request of the process for ten times
21+
* as long. It is the LibreDB Mobile editor's own cap, so that client never meets it; the web editor and the VS Code
22+
* extension analyse their text locally and never send it here.
2323
*
24-
* The bound does not stop one signed-in user from filling the event loop: at one slot per request, the `query`
25-
* bucket's default 120 a minute buy more CPU time than the minute holds. That is a known limit in docs/SECURITY.md,
26-
* and a charge that grows with the text, or a faster gate, is `docs/BACKLOG.md` U202.
24+
* The bound does not stop one signed-in user from holding much of the event loop: at one slot per request, the `query`
25+
* bucket's default 120 a minute buy up to about 26 s of CPU time in every minute. That is a known limit in
26+
* docs/SECURITY.md.
2727
*/
2828
const MAX_TEXT_LENGTH = 1_000_000;
2929

0 commit comments

Comments
 (0)