Skip to content

fix(sqlite): count work performed inside a single engine step (TM-SQL-014) - #2369

Merged
chaliy merged 2 commits into
mainfrom
claude/recursive-work-count-zhxifr
Sep 1, 2026
Merged

fix(sqlite): count work performed inside a single engine step (TM-SQL-014)#2369
chaliy merged 2 commits into
mainfrom
claude/recursive-work-count-zhxifr

Conversation

@chaliy

@chaliy chaliy commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Supersedes #2367 — same threat, counted instead of denied.

What changed

SQL work performed inside one Statement::step() is now counted and bounded. Every 1024 VM instructions, turso's SQLite-compatible progress handler charges the request's ExecutionBudget one work unit and interrupts the VDBE once max_duration has passed or the budget is exhausted. The handler records why it fired, so the user-visible error stays query timed out / the budget error rather than a generic "interrupted", and an RAII guard clears it so an early return never leaves a stale callback (holding a cloned budget) on a cached file-backed connection.

WITH RECURSIVE keeps working, including the existing differential parity test against host sqlite3.

Why

The engine can only check limits between step() calls, and a step returns only when a row is produced, the program halts, or IO is needed. A query whose rows are consumed inside the VDBE never comes back, so neither the deadline nor the budget gets a turn:

WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r) SELECT count(*) FROM r;
SELECT * FROM t a, t b, t c, t d WHERE a.n < 0;   -- same shape, no CTE

Recursion is not the distinguishing property — "produces no rows for a long time" is. #2367 rejects WITH RECURSIVE at the SQL policy, which removes a supported feature and still leaves the filtered-cross-join case wide open. Turso exposes Connection::set_progress_handler as of 0.8.0-pre.7 (already the pinned version), so the in-flight callback that approach was waiting for now exists.

Before / After

Same query, same limits (max_duration 250 ms):

Before — spins at 100% CPU inside one step, budget untouched. Killed manually after 38 minutes:

root 6178 99.9 0.2 147724 41388 ? Sl 22:58 38:22 bashkit-764130e212dee038 unbounded_recursive_cte

After:

test builtins::sqlite::tests::unbounded_recursive_cte_hits_deadline ... ok
test result: ok. 1 passed; finished in 0.25s

stderr: sqlite: query timed out

Terminating recursive CTEs are unaffected — bounded_recursive_cte_still_runs asserts 1\n2\n3\n4\n5\n.

Benchmarks: scripts/bench-sqlite.sh run committed under crates/bashkit/benches/results/, plus a same-machine criterion A/B against main. Deltas straddle zero in both directions (±10% on this noisy 4-CPU runner); no overhead attributable to the handler.

Risk

  • Low
  • The handler is installed per execute() and cleared on drop, so a leaked callback would keep charging a stale budget on a cached connection — covered by the guard and by the existing file-backed engine cache tests. Queries that were previously "slow but finishing" now abort if they exceed the configured deadline or work budget; that is the intended enforcement, and both ceilings are operator-configurable (Duration::ZERO still disables the deadline).

Checklist

  • Tests added or updated — unbounded_recursive_cte_hits_deadline, rowless_cross_join_hits_deadline (the non-CTE shape fix(sqlite): reject recursive CTE workloads #2367 misses), bounded_recursive_cte_still_runs, unbounded_recursive_cte_consumes_shared_work_budget
  • Backward compatibility considered — no API change; WITH RECURSIVE stays supported

Docs: TM-SQL-014 added to knowledge/runtimes/sqlite-builtin.md (with a new "Bounding work inside one step" section), the threat-model ledger, and the public crates/bashkit/docs/threat-model.md.

Verification: sqlite lib slice (111 tests) and the integration binary (1265 tests) green; cargo fmt, cargo clippy --all-targets -D warnings, scripts/check_okf.py, and the doc-link check clean.

chaliy added 2 commits August 31, 2026 23:42
A turso `Statement::step()` returns only when a row is produced, the
program halts, or IO is needed, so the engine's between-step deadline and
`ExecutionBudget` checks never run for a query whose rows are consumed
inside the VDBE. `WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n+1
FROM r) SELECT count(*) FROM r` spins forever at 100% CPU with the budget
untouched; a filtered cross join does the same without any CTE.

Install turso's SQLite-compatible progress handler (available since
0.8.0-pre.7) so in-VDBE work is counted where it happens: every 1024 VM
instructions the callback charges one budget work unit and interrupts the
VDBE once the invocation deadline passes or the budget is exhausted. The
handler records why it interrupted, so the step loop keeps reporting
"query timed out" / the budget error rather than a generic "interrupted",
and an RAII guard clears it so no early return leaves a stale callback on
a cached connection.

TM-SQL-014; documented in knowledge/runtimes/sqlite-builtin.md, the threat
model ledger, and the public threat model.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 069c9ea Commit Preview URL

Branch Preview URL
Sep 01 2026, 12:32 AM

@chaliy
chaliy merged commit 2eceae2 into main Sep 1, 2026
47 checks passed
@chaliy
chaliy deleted the claude/recursive-work-count-zhxifr branch September 1, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant