Skip to content

perf(shared): queued SQLite reads stop waiting on one reader while another is free - #27979

Open
Pyseph wants to merge 5 commits into
pingdotgg:mainfrom
Pyseph:perf/reader-pool-first-free
Open

Pyseph wants to merge 5 commits into
pingdotgg:mainfrom
Pyseph:perf/reader-pool-first-free

Conversation

@Pyseph

@Pyseph Pyseph commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a read finds all four SQLite readers busy, it picks the least-claimed one and waits on that reader's own lock. It stays pinned there: if a different reader frees up first, the read keeps waiting for its own, and later reads can finish ahead of it on the free one. A point read that lands behind a long shell read waits for the whole shell read while other readers sit idle.

PixPMusic found this while reviewing #26816 and reproduced it on main with real workers. The review asked for it to be tracked separately from that PR.

Change

Admission to the pool is now one semaphore with a permit per reader. A read waits for any permit, then takes the lowest idle slot. Holding a permit always leaves an idle slot, so a queued read is no longer tied to one busy reader: it waits for any of them. Idle slots still never start a worker. As before, waiters are not strictly first in, first out; a read that arrives just as a permit frees can take it first. The permit and slot are released through the same finalizers as before: when a standalone read ends, or when a read transaction's scope closes. A scheduler yield still can't separate taking the permit from registering its release.

The queueing now happens while routing, so both acquisition paths start the db.lock_wait_ms timer before routing; otherwise a read that waited for a busy pool would report almost no lock wait.

Verification

New case in nodeSqliteClient.test.ts, "gives a queued read the first reader that frees up": four read-only transactions hold every reader, a fifth read queues, the second transaction ends, and the queued read must finish while the other three are still open. It uses Deferred milestones and real reader workers, with no sleeps. While the read waits, the test clock moves 1 s, and the read's span must report db.lock_wait_ms of exactly 1000.

  • On main's nodeSqliteClient.ts it fails at the 60 s test timeout, because the queued read waits on the first reader's transaction.
  • On this branch it passes, and so do the other SQLite client tests except "uses the writer when the reader cannot open the database". That one also fails on main on Windows, because it deletes an open database file (EBUSY); CI's Linux runners don't hit that.
  • vp run check:changed passes format, lint, knip and typecheck across the 33 dependent packages; its only test failure is that same Windows case.

Latency under load

To see the effect on real reads, I ran the SQLite client from main and from this branch with reader workers against a static copy of a real server's shell tables: 3,047 threads and 701,015 turn items, read-only. Three loops ran a shell-sized read (every thread's item count by run, about 0.5 s), with a 1 ms pause between runs. Point reads (one thread row by ID) arrived as new fibers in bursts of three every 5 ms, so at least one reader was always idle. Each arm ran 20 s, alternating main and branch, two rounds:

Point reads (~4,100 per run) main this PR
p50 0.49 / 0.70 ms 0.55 / 0.61 ms
p95 361 / 275 ms 0.98 / 1.11 ms
p99 517 / 801 ms 2.5 / 3.1 ms
max 702 / 1,702 ms 17 / 19 ms
waited over 50 ms 226 / 297 0 / 0

The heavy reads took the same time in both arms (p50 450 / 609 ms on main, 456 / 499 ms here). On main, a point read that arrived while the idle reader was busy queued behind a heavy read instead.

Not measured: a whole server under real traffic.

Claude Opus 5.5 in T3 Code (Claude Code harness).

A read that found every reader busy picked one and waited on that reader's
own lock, even when another reader freed up first. Admission is now one
semaphore with a permit per reader; a read waits for any permit and takes
the lowest idle slot.
@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Oct 31, 2026
The semaphore stops a queued read from being tied to one busy reader; it does not make waiters first in, first out.
Comment thread packages/shared/src/nodeSqliteClient.ts Resolved
@github-actions github-actions Bot added the size:M 30-99 changed lines (additions + deletions). label Oct 31, 2026
@Pyseph Pyseph changed the title perf(shared): a queued SQLite read takes the first reader that frees up perf(shared): queued SQLite reads stop waiting on one reader while another is free Oct 31, 2026
@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 985d25d and 626491b.

Files selected for processing: 2
  • packages/shared/src/nodeSqliteClient.test.ts
  • packages/shared/src/nodeSqliteClient.ts

Limit details: You’ve used all 10 included reviews currently available.

Walkthrough

Read-only routing now uses semaphore permits to claim available reader slots. Borrow and transaction lock-wait timing includes time spent waiting for reader admission. A regression test checks that a queued read completes after a held transaction releases its slot and records a 1000 ms wait.

Changes: SQLite reader scheduling

Layer / File(s) Summary
Reader slot acquisition
packages/shared/src/nodeSqliteClient.ts
Routing waits for a permit before claiming an available reader slot. A registered release clears the claim and returns the permit. If reader routing is unavailable, the request routes to the writer.
Lock-wait timing and validation
packages/shared/src/nodeSqliteClient.ts, packages/shared/src/nodeSqliteClient.test.ts
Borrow and transaction timing starts before routing, so it includes reader-admission wait time. Transaction acquisition registers its lock-release finalizer without an intervening scheduler yield. The regression test checks that a queued read returns 1 and records a 1000 ms wait.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Possibly related issues

  • pingdotgg/t3code#24660: The issue describes reads blocked behind a slow read. This change lets queued reads claim available reader slots.
  • pingdotgg/t3code#17765: The issue describes snapshot reads queued behind a single reader permit. This change admits reads through the reader pool.
  • pingdotgg/t3code#24895: The issue describes requests queued behind long reads. This change lets queued reads use available reader slots.
  • pingdotgg/t3code#24192: The issue describes server-side lookups queued on one SQLite connection. This change updates reader admission, but does not address the reported client-side behavior.

Suggested reviewers: juliusmarminge

Merge Risk: Minimal · up to 62649

This change lets a queued SQLite read take whichever reader frees up first, and it counts reader-wait time in the lock-wait metric. No actionable merge-blocking risk was found. Whole-server behavior under real traffic was not measured, so a normal check after rollout is reasonable.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: queued SQLite reads can use another available reader instead of remaining tied to one busy reader.
Description check Passed The description is mostly complete. It includes detailed Problem, Change, and Verification sections, with reproduction context, implementation details, test results, and load measurements. The Scope a…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is mostly complete. It includes detailed Problem, Change, and Verification sections, with reproduction context, implementation details, test results, and load measurements. The Scope and approval section is not explicitly labeled and does not provide a direct approval link or comment, but the reference to issue #26816 and the focused bug-fix context provide relevant scope information.

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Usage-based review receipt

Note

This review was completed with usage-based billing: files reviewed beyond your plan's included limits are billed at $0.25/file. View usage-based billing.

Pyseph and others added 3 commits October 31, 2026 05:18
Queueing moved from each reader's lock into routing, which ran before the lock-wait timer started, so a read that waited for a busy pool reported almost no db.lock_wait_ms. Both acquisition paths now start timing before routing.
…t-free

# Conflicts:
#	packages/shared/src/nodeSqliteClient.test.ts

Copy link
Copy Markdown
Collaborator

Note

Written by gpt-6.1-sol on behalf of Maria

merged current main without rebasing, preserving both sets of test imports. kept first-free reader admission and the contributor's lock-wait timing fix.

fixed one cancellation edge: reader admission now uses the transaction's restore policy, matching lock acquisition. extended the existing regression to cancel a queued transaction before any of the four held readers is released. it timed out before the fix and passes afterward.

blacksmith verification: all 20 sqlite tests pass; vp run check:changed refs/remotes/github/main passes format, lint, knip, typechecks for 33 affected packages, and 21 tests. two independent reviews found no remaining issues at 626491b1d9. the pr still changes only the sqlite client and its existing test file.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants