fix(sqlite): count work performed inside a single engine step (TM-SQL-014) - #2369
Merged
Conversation
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.
Deploying with
|
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sExecutionBudgetone work unit and interrupts the VDBE oncemax_durationhas passed or the budget is exhausted. The handler records why it fired, so the user-visible error staysquery 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 RECURSIVEkeeps working, including the existing differential parity test against hostsqlite3.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:Recursion is not the distinguishing property — "produces no rows for a long time" is. #2367 rejects
WITH RECURSIVEat the SQL policy, which removes a supported feature and still leaves the filtered-cross-join case wide open. Turso exposesConnection::set_progress_handleras 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_duration250 ms):Before — spins at 100% CPU inside one step, budget untouched. Killed manually after 38 minutes:
After:
stderr:
sqlite: query timed outTerminating recursive CTEs are unaffected —
bounded_recursive_cte_still_runsasserts1\n2\n3\n4\n5\n.Benchmarks:
scripts/bench-sqlite.shrun committed undercrates/bashkit/benches/results/, plus a same-machine criterion A/B againstmain. Deltas straddle zero in both directions (±10% on this noisy 4-CPU runner); no overhead attributable to the handler.Risk
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::ZEROstill disables the deadline).Checklist
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_budgetWITH RECURSIVEstays supportedDocs: 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 publiccrates/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.