Repository navigation
Move thread list reads off the event loop - #1198
SawyerHood wants to merge 13 commits into
Conversation
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am now reviewing this change for security, code quality, performance, architecture, and end-to-end behavior. |
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5
This change gives a slow database job to a separate worker. The main server can then answer other requests. The idea works for GET /threads, but the normal app uses a different route. Startup and worker failures can also leave the server in a bad state.
I recommend fixes before merge. I posted a comment-only review, as required.
Findings
-
P1 — The normal app still uses the server event loop for thread-list reads. The app loads
/sidebar-bootstrap. That route andprojects?include=threadsstill run the thread queries and display queries on the server event loop. Move these complete read paths into the worker service. -
P1 — A later startup error can leave the process alive.
runServercreates the worker before later operations that can fail. The top-level error handler only setsprocess.exitCode. Two reviews reproduced a process that did not exit. Close the worker on every startup error. -
P1 — A worker fault can permanently break thread lists. The service does not wait for a ready message. It stores worker errors and rejects all later list requests. The server can still report that it is ready. Require worker readiness before listener startup. Restart the worker or stop the server after an unexpected exit.
Architecture and security
I found no reusable server worker pool in this repository. Extend this database service for the sidebar and project read paths. The browser worker pool does not fit this server use.
The worker validates its messages and opens the database with read-only and query-only controls. I found no direct path-trust or data-exposure fault.
Validation
- Three GPT-5.6 workers reviewed security, code quality, architecture, and performance. One GPT-5.6 gate checked the combined result.
- Turbo type checks passed for
@bb/serverand@bb/db. - Turbo tests passed for both packages. The server suite passed 1,368 tests in 159 files.
- The production server build passed and included the worker bundle.
- The source app started at the pull request SHA. A browser call to
GET /api/v1/threadsreturned status 200. - The browser received one seeded thread entry. The normal app displayed the same thread.
git diff --check origin/main...HEADpassed. The worktree stayed clean.
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am reviewing this pull request for security, code quality, performance, architecture, and product behavior. |
5376fe1 to
8f084cb
Compare
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5
The server used to stop and read a large list by itself. This change gives that job to a helper. It is like asking a friend to count blocks while you answer the door. The idea works, but the helper can get stuck and accept too much work.
Findings
- High: A shutdown can leave a request pending forever during replacement worker startup. I reproduced this race at the required SHA. Reject every unresolved readiness promise during
close(). Add a test for this shutdown point. - Medium: The single worker queue has no limit, deadline, or client abort support. Slow repeated requests can consume memory and delay current reads. Add load control and tests for overload and disconnects.
- Low: The startup test allows a ten-second child wait inside a five-second Vitest test. The full local suite failed at five seconds. Give the test a longer timeout, or reduce the child wait.
Architecture
The worker boundary otherwise looks sound. It uses a read-only connection, strict messages, and a fixed worker entry path. I found no SQL injection or access-control regression.
The direct service and worker entry duplicate operation dispatch. The contract also repeats the daemon-session schema. A shared operation definition would reduce future drift.
Thread search still uses synchronous database work. I did not count that existing path as a defect in this pull request.
Validation
- The exact-SHA server and database type checks passed.
- The full server and database tests completed 1,372 of 1,373 tests. The new startup test hit its five-second timeout.
- The focused startup test passed. GitHub checks for the required SHA also passed during this review.
- Browser checks returned HTTP 200 for health, thread list, and sidebar routes.
- With 250,000 temporary rows, health returned in 2 ms while a 278 ms list request still ran.
- A 10,000-row response kept the maximum observed health latency near 30 ms.
The main event-loop goal works. The shutdown race needs a fix before merge. I left three inline comments with specific changes.
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am now reviewing this pull request under the configured security, code quality, performance, architecture, and end-to-end guidelines. |
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5
The app gives a slow list job to a helper. It works like asking a friend to count toys while you keep playing.
Review
The main idea works. The database query now runs outside the server event loop. I found these issues:
- High: A large
limitvalue fails worker validation before the request gets an ID. The worker exits and rejects unrelated reads. - High: An active timeout settles the promise but leaves
activeReadset. Later reads wait or time out until the query returns. - Medium: The main thread validates every returned entry with Zod. Tests measured about 315 ms for transfer decode and validation of 100,000 rows.
- Medium: The service marks queue and timeout errors as retryable 503 responses. The app retry policy rejects all
HttpErrorvalues. - Medium: A client abort creates a plain
Error. The server then records an error log and maps the error to HTTP 500.
The read-only SQLite connection has the correct write controls. Existing project and thread access checks still run before worker reads.
The architecture scan found no reusable server worker pool, so this service does not duplicate an existing worker layer. However, the Zod schema repeats ListThreadsOptions. Add a key coverage guard to prevent silent contract drift.
The route and integration harnesses use the direct service. Thus, route tests do not test the production worker boundary. Add one route test with the worker and a full thread entry.
Validation passed:
- Server typecheck
- Server production build
- 14 focused server tests
- 5 database tests
- Local UI load with the real worker
- HTTP 200 from
/api/v1/sidebar-bootstrapand/api/v1/threads?limit=10 - No database worker errors in the local server log
I left this as a comment review. I did not approve the pull request or request changes.
|
Review follow-up for 3bdc475:
|
|
🚨 SLOP COP 🚨 · I am SlopCop. I am reviewing this pull request for security, code quality, performance, architecture, and end-to-end behavior. |
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5
This change gives slow list work to a helper. It is like asking a helper to sort cards. The main app can keep moving.
Two stop rules can fail. The helper can then block new work or return an error that the app will not retry.
Findings
I found four issues:
- High: An active request abort clears its timer but leaves the worker busy.
- High: An unexpected worker failure returns a non-retryable error, although the service starts a replacement.
- Medium: A slow read can trigger five 30-second attempts and five worker replacements.
- Medium: Sidebar bootstrap sends two related reads into one serial queue.
The inline comments contain the impact and a concrete fix for each issue.
The sidebar should send one request for all project identifiers. This refactor removes duplicate queue work and gives one result boundary.
I found no authorization, data exposure, injection, or unsafe path issue. The active-abort issue can cause an availability failure.
Validation
- I reviewed all 27 changed files at the stated commit.
git diff --checkpassed.- Turbo type checks passed for the app, server, SDK, and database packages.
- The focused worker test file passed all 12 tests.
- The local full run passed 1,383 of 1,385 tests.
- The relevant timeout test passed alone. The other local failure came from an unrelated plugin test.
- All GitHub checks passed.
- Browser QA created an isolated project and thread.
- The sidebar showed both records.
- The thread page opened successfully.
- All observed thread and list routes returned status 200.
I would fix the two high findings before merge. I left a comment review only. I did not approve or request changes.
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am reviewing this pull request for security, code quality, performance, architecture, and end-to-end behavior. |
|
🚨 SLOP COP 🚨 · ELI5: This change gives slow thread-list database work to a background worker. The main server can then answer other requests. I reviewed I found three medium-severity issues:
The first two issues need one consistent read transaction. The project and sidebar operations must move all response reads into that transaction. For cancellation, reject the caller but keep the worker request active. Discard its result, and replace a worker only after an internal timeout. The reviewed SHA also had an intermittent server test failure. Commit I found no reusable server worker framework. The dedicated service fits the current architecture. The direct and worker adapters repeat the operation dispatch. A shared operation function can remove that duplication. Validation passed locally:
I left this as a comment review. I did not approve the pull request or request changes. |
|
🚨 This query and The result can combine thread rows and activity rows from different WAL snapshots. Use one deferred read transaction for each worker request. This transaction will pin one consistent SQLite snapshot. |
|
🚨 The route builds projects and sources before this await. It reads defaults after the await. I reproduced a concurrent deletion during this wait. The response included the deleted project without its former thread. That response matched neither the state before deletion nor the state after deletion. Move the complete sidebar read into one worker-side read transaction. Include projects, sources, sections, defaults, and threads in that transaction. |
|
🚨 Each active cancellation starts a new worker and stops the current worker. The queue limit does not bound this restart path. A client can repeat a list request and close its socket after dispatch. This can deny service to other list requests. Reject the caller, but keep the active slot until the worker returns. Discard that result and reserve worker replacement for internal timeouts. |
|
Addressed all three findings from SlopCop run
All app, database, SDK, and server typechecks pass. The app, database, and SDK suites pass. The server suite passes 1,389 tests and has only the known 0664-versus-0644 skill-file failure. |
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am now reviewing this pull request for security, code quality, performance, architecture, and end-to-end behavior. |
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5: The server now gives large thread-list database work to a helper. The main server can answer other work during that read.
I found no security defects. I found three concrete reliability and performance defects.
- High: A list with 16,377 visible threads still fails with SQLite's variable limit. The background-task query binds every thread ID twice.
- Medium: The worker can return old host status. It records daemon sessions before the database read finishes.
- Medium: Worker recovery calls
startWorker()without a guard. A direct constructor error can stop the server, and readiness has no deadline.
The change improves event-loop availability, but it does not reduce the database cost. One slow read still blocks the single worker queue.
The active read also continues after a caller aborts. This keeps the worker busy until the query finishes or reaches its deadline.
The thread search route still runs its database read on the main event loop. This route remains outside the new service.
The project response code now repeats source grouping in two files. A shared pure helper can prevent future response drift.
Validation completed at exact SHA ba8543a7b61136154d2f0bcb75a7a4dee4828db5:
- The server, app, database, and SDK type checks passed.
- The production server build passed and included the worker entry.
- All 15 worker-service tests passed in an isolated rerun.
- The route, database, SDK, and retry test groups passed.
- The full parallel run passed 1,389 of 1,390 tests. One worker test reached its time limit under heavy host load.
- The same worker test passed alone in 3.6 seconds.
- The exact dev server returned valid sidebar and thread-list responses.
- A headless browser loaded the thread-list route without page errors.
I left inline notes on the three concrete defects. I did not approve or request changes.
|
🚨 SLOP COP 🚨 · I am the SlopCop. I am reviewing this pull request for security, quality, performance, architecture, and user behavior. I will send the checks to three parallel reviewers. I will post one final review after the checks finish. |
|
Closing this in favor of #1204. This PR answered the 11.2 second event-loop stall in #1131. A measurement pass The work here was good and the review found real problems, which is exactly why Two pieces of this PR outlive it:
Thanks for the thorough work on this one. |
… drop the thread-list GROUP BY (#1204) ## Summary This PR investigates issue #1131 with measurements. The result changes the diagnosis. The 11-second cold stall did not come from `listThreadsWithPendingInteractionState`. It came from `listLatestGoalEventRowsByThreadIds`, which the same route calls for every listed thread. Its `ORDER BY` made the planner walk `events_thread_sequence_idx` and fetch **every event row of every listed thread**. On a grown database, that read is close to the whole file. This PR fixes that query with one small partial index and a pinned rewrite. Cold reads for that query drop from **444,010 to 357** at 26K threads. The PR also replaces the thread-list `GROUP BY` + join with a correlated `EXISTS`. That removes both temp B-trees, but measurement shows the temp B-trees were never the real cost. ## How I measured - I built a 1.45GB `bb.db` at the reported scale with the real `migrate()`: 30,000 threads, 594,971 events (issue reports ~595K events, 1447MB events table), 29,842 interactions, 25 pending. I inserted rows in global timestamp order across concurrent threads, so `threads` pages interleave with `events` pages like a real grown file. No `ANALYZE`, because production never runs it. - I ran the real Drizzle query functions from `@bb/db` and captured the exact generated SQL and `EXPLAIN QUERY PLAN`. - Cold means evicted page cache: `posix_fadvise(DONTNEED)` on the db/wal/shm files, fresh process, verified with `/proc/self/io` read counters. - The host has a fast NVMe disk. Absolute cold times transfer badly, so I also report read-syscall counts, and I re-ran cold tests inside a cgroup capped at 1000 read IOPS to model a stressed cloud disk. All cold runs are latency-bound: time ≈ reads ÷ IOPS. ## What the 11.2s actually was `bb thread list --json` at 26,459 visible threads, cold: | query in the route | cold reads | cold (NVMe) | cold (1000 IOPS) | warm | |---|---|---|---|---| | `listThreadsWithPendingInteractionState` | 2,044 | 0.30s | 2.2s | 0.18s | | `listLatestGoalEventRowsByThreadIds` (before) | **444,010** | **10.5s** | ~7 min (projected) | 0.77s | | `listLatestGoalEventRowsByThreadIds` (after) | **357** | **0.10s** | 0.29s | 0.10s | The before-numbers reproduce the issue precisely: 10.5s cold matches the reported 11.2s stall, and 0.77s warm matches the reported ~0.5s warm repeats. Why it happened: the query had `ORDER BY thread_id, sequence`. The stats-less planner served that order from `events_thread_sequence_idx` to avoid a sort, then filtered by type per row. That plan walks and fetches every event row (multi-KB payloads included) of every listed thread. The fix has three parts: 1. A partial index `events_goal_thread_sequence_idx ON events (thread_id, sequence) WHERE type IN ('thread/goal/updated', 'thread/goal/cleared')`. Goal events are rare, so the index stays tiny. 2. The query spells the goal types as SQL literals and pins the index with `INDEXED BY`. Both are required: SQLite cannot match a partial index against a bound parameter, and the stats-less planner assumes every index is table-sized, so it never picks the small one on its own (verified: it kept the big index until pinned). 3. No `ORDER BY`. `sequence` is unique per thread, so the query returns at most one row per thread, and both callers sort or ignore order. I verified the new query returns row-identical results against the old SQL on a dataset with 10,579 goal events across 3,000 threads (2,750 result rows, zero differences). ## The thread-list query itself, and the temp B-tree theory The prior observation was correct: the query used temp B-trees for GROUP BY and ORDER BY, and `bb thread list` walked all threads in id-index order (random page order). The `EXISTS` rewrite in this PR removes the join, the GROUP BY, and one temp B-tree, with row-identical results (verified across 31,309 rows in four query shapes). Measured effect, before → after: | shape | rows | cold reads | cold (1000 IOPS) | warm | |---|---|---|---|---| | `bb thread list` (all visible) | 26,459 | 2,044 → 1,959 | 2.16s → 2.05s | 0.18s → 0.17s | | sidebar bootstrap (active, all projects) | 4,950 | 1,771 → 1,767 | 1.76s → 1.75s | 51ms → 47ms | | one project, active | 169 | 352 → 352 | 0.40s → 0.40s | 6ms | | one project, archived | 731 | 973 → 973 | 1.01s → 1.01s | 19ms | The temp B-trees were a signature, not the cost. The cold cost is the row fetches: the query returns every visible thread row, and those rows sit on ~2,000 pages scattered through the file. No index can remove that. I tested the extremes: - A 24-column covering partial index (the entire row duplicated): helps only the single-project shape (352 → 195 reads), the planner does not pick it for the IN-list sidebar shape, and it abandons it once stats exist. Not shipped. - Full `VACUUM` (contiguous pages) plus the ideal sequential scan: cli-list cold still needs 0.85s at 1000 IOPS. bb never full-vacuums an incremental-mode database in steady state, so real files stay fragmented. So for the thread-list query proper, the honest answer to "can an index make cold fast?" is **no**. About 1,700–2,000 scattered cold reads are inherent at this scale (roughly 2s at 1000 IOPS), and the ~50–170ms warm cost is per-row CPU (26K rows: ~63ms inside SQLite, the rest row mapping), which no index changes. ## Relationship to #1198 (now closed) and #1207 I checked these claims against the #1198 branch (`database-read-service.ts`, `database-read-worker-entry.ts`, `start-server.ts`), not from memory. - #1198 ran the whole route read inside its worker: the worker entry called `listThreadsWithPendingInteractionState` and `toThreadListEntryResponses`, and the latter reaches `listLatestGoalEventRowsByThreadIds`. So #1198 **would** have moved the 10.5s read off the event loop. The global freeze would stop: static assets and every other route would stay responsive. - #1198 would **not** have made the thread list fast. The request itself would still have taken the same ~10.5s cold inside the worker. It would not have 503'd: the request timeout default was 30 seconds (`DEFAULT_DATABASE_READ_TIMEOUT_MS = 30_000`, and the only construction site passed no override), which a 10.5s read does not reach. - The accurate division of labor: **#1198 contained the blast radius of a slow read; this PR removes the dominant slow read.** One masks the symptom, the other fixes the cause. - #1198 is now closed. The residual synchronous cost that remains after this PR — the inherent thread-list row fetches, roughly 2s cold at 1000 IOPS at 26K threads, tens of milliseconds on a healthy disk with a warm cache — is tracked in #1207. ## Related findings (not addressed here) - `listActiveBackgroundTaskCountsByThreadIds` (same route) has the same access-path class: ~4,150 cold reads at 5K threads, ~6,888 at 15K. Worse, it binds the thread-id list twice, so at 16,377 or more visible threads (measured boundary; the double-bound list plus fixed parameters exceeds SQLite's 32,766-variable limit) it throws `too many SQL variables` and the thread-list route fails outright. Tracked separately with a reproduction in #1205. - The route's per-thread queries all scale with visible-thread count. Pagination for `bb thread list` would cap every term. ## Test plan - `pnpm exec turbo run test --filter=@bb/db`: 359/359 pass. The migration-replay suite forced `CREATE INDEX IF NOT EXISTS` (same pattern as 0084/0086), and the index-inventory expectation in `migrate.test.ts` now includes the new index. - `pnpm exec turbo run test --filter=@bb/server`: 1366/1367 pass; the one failure is `internal-skill-trees.test.ts` expecting file mode 0644 under my machine's umask 0002, which also fails on `main` and is unrelated. - Row-identical result verification for both rewritten queries, described above. Refs #1131 (measurement basis; deliberately not "Fixes" — #1131 stays open for the maintainer to close once follow-up #1205 also lands). Residual event-loop work is tracked in #1207. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ookup, drop the thread-list GROUP BY (get-bb#1204) ## Summary This PR investigates issue get-bb#1131 with measurements. The result changes the diagnosis. The 11-second cold stall did not come from `listThreadsWithPendingInteractionState`. It came from `listLatestGoalEventRowsByThreadIds`, which the same route calls for every listed thread. Its `ORDER BY` made the planner walk `events_thread_sequence_idx` and fetch **every event row of every listed thread**. On a grown database, that read is close to the whole file. This PR fixes that query with one small partial index and a pinned rewrite. Cold reads for that query drop from **444,010 to 357** at 26K threads. The PR also replaces the thread-list `GROUP BY` + join with a correlated `EXISTS`. That removes both temp B-trees, but measurement shows the temp B-trees were never the real cost. ## How I measured - I built a 1.45GB `bb.db` at the reported scale with the real `migrate()`: 30,000 threads, 594,971 events (issue reports ~595K events, 1447MB events table), 29,842 interactions, 25 pending. I inserted rows in global timestamp order across concurrent threads, so `threads` pages interleave with `events` pages like a real grown file. No `ANALYZE`, because production never runs it. - I ran the real Drizzle query functions from `@bb/db` and captured the exact generated SQL and `EXPLAIN QUERY PLAN`. - Cold means evicted page cache: `posix_fadvise(DONTNEED)` on the db/wal/shm files, fresh process, verified with `/proc/self/io` read counters. - The host has a fast NVMe disk. Absolute cold times transfer badly, so I also report read-syscall counts, and I re-ran cold tests inside a cgroup capped at 1000 read IOPS to model a stressed cloud disk. All cold runs are latency-bound: time ≈ reads ÷ IOPS. ## What the 11.2s actually was `bb thread list --json` at 26,459 visible threads, cold: | query in the route | cold reads | cold (NVMe) | cold (1000 IOPS) | warm | |---|---|---|---|---| | `listThreadsWithPendingInteractionState` | 2,044 | 0.30s | 2.2s | 0.18s | | `listLatestGoalEventRowsByThreadIds` (before) | **444,010** | **10.5s** | ~7 min (projected) | 0.77s | | `listLatestGoalEventRowsByThreadIds` (after) | **357** | **0.10s** | 0.29s | 0.10s | The before-numbers reproduce the issue precisely: 10.5s cold matches the reported 11.2s stall, and 0.77s warm matches the reported ~0.5s warm repeats. Why it happened: the query had `ORDER BY thread_id, sequence`. The stats-less planner served that order from `events_thread_sequence_idx` to avoid a sort, then filtered by type per row. That plan walks and fetches every event row (multi-KB payloads included) of every listed thread. The fix has three parts: 1. A partial index `events_goal_thread_sequence_idx ON events (thread_id, sequence) WHERE type IN ('thread/goal/updated', 'thread/goal/cleared')`. Goal events are rare, so the index stays tiny. 2. The query spells the goal types as SQL literals and pins the index with `INDEXED BY`. Both are required: SQLite cannot match a partial index against a bound parameter, and the stats-less planner assumes every index is table-sized, so it never picks the small one on its own (verified: it kept the big index until pinned). 3. No `ORDER BY`. `sequence` is unique per thread, so the query returns at most one row per thread, and both callers sort or ignore order. I verified the new query returns row-identical results against the old SQL on a dataset with 10,579 goal events across 3,000 threads (2,750 result rows, zero differences). ## The thread-list query itself, and the temp B-tree theory The prior observation was correct: the query used temp B-trees for GROUP BY and ORDER BY, and `bb thread list` walked all threads in id-index order (random page order). The `EXISTS` rewrite in this PR removes the join, the GROUP BY, and one temp B-tree, with row-identical results (verified across 31,309 rows in four query shapes). Measured effect, before → after: | shape | rows | cold reads | cold (1000 IOPS) | warm | |---|---|---|---|---| | `bb thread list` (all visible) | 26,459 | 2,044 → 1,959 | 2.16s → 2.05s | 0.18s → 0.17s | | sidebar bootstrap (active, all projects) | 4,950 | 1,771 → 1,767 | 1.76s → 1.75s | 51ms → 47ms | | one project, active | 169 | 352 → 352 | 0.40s → 0.40s | 6ms | | one project, archived | 731 | 973 → 973 | 1.01s → 1.01s | 19ms | The temp B-trees were a signature, not the cost. The cold cost is the row fetches: the query returns every visible thread row, and those rows sit on ~2,000 pages scattered through the file. No index can remove that. I tested the extremes: - A 24-column covering partial index (the entire row duplicated): helps only the single-project shape (352 → 195 reads), the planner does not pick it for the IN-list sidebar shape, and it abandons it once stats exist. Not shipped. - Full `VACUUM` (contiguous pages) plus the ideal sequential scan: cli-list cold still needs 0.85s at 1000 IOPS. bb never full-vacuums an incremental-mode database in steady state, so real files stay fragmented. So for the thread-list query proper, the honest answer to "can an index make cold fast?" is **no**. About 1,700–2,000 scattered cold reads are inherent at this scale (roughly 2s at 1000 IOPS), and the ~50–170ms warm cost is per-row CPU (26K rows: ~63ms inside SQLite, the rest row mapping), which no index changes. ## Relationship to get-bb#1198 (now closed) and get-bb#1207 I checked these claims against the get-bb#1198 branch (`database-read-service.ts`, `database-read-worker-entry.ts`, `start-server.ts`), not from memory. - get-bb#1198 ran the whole route read inside its worker: the worker entry called `listThreadsWithPendingInteractionState` and `toThreadListEntryResponses`, and the latter reaches `listLatestGoalEventRowsByThreadIds`. So get-bb#1198 **would** have moved the 10.5s read off the event loop. The global freeze would stop: static assets and every other route would stay responsive. - get-bb#1198 would **not** have made the thread list fast. The request itself would still have taken the same ~10.5s cold inside the worker. It would not have 503'd: the request timeout default was 30 seconds (`DEFAULT_DATABASE_READ_TIMEOUT_MS = 30_000`, and the only construction site passed no override), which a 10.5s read does not reach. - The accurate division of labor: **get-bb#1198 contained the blast radius of a slow read; this PR removes the dominant slow read.** One masks the symptom, the other fixes the cause. - get-bb#1198 is now closed. The residual synchronous cost that remains after this PR — the inherent thread-list row fetches, roughly 2s cold at 1000 IOPS at 26K threads, tens of milliseconds on a healthy disk with a warm cache — is tracked in get-bb#1207. ## Related findings (not addressed here) - `listActiveBackgroundTaskCountsByThreadIds` (same route) has the same access-path class: ~4,150 cold reads at 5K threads, ~6,888 at 15K. Worse, it binds the thread-id list twice, so at 16,377 or more visible threads (measured boundary; the double-bound list plus fixed parameters exceeds SQLite's 32,766-variable limit) it throws `too many SQL variables` and the thread-list route fails outright. Tracked separately with a reproduction in get-bb#1205. - The route's per-thread queries all scale with visible-thread count. Pagination for `bb thread list` would cap every term. ## Test plan - `pnpm exec turbo run test --filter=@bb/db`: 359/359 pass. The migration-replay suite forced `CREATE INDEX IF NOT EXISTS` (same pattern as 0084/0086), and the index-inventory expectation in `migrate.test.ts` now includes the new index. - `pnpm exec turbo run test --filter=@bb/server`: 1366/1367 pass; the one failure is `internal-skill-trees.test.ts` expecting file mode 0644 under my machine's umask 0002, which also fails on `main` and is unrelated. - Row-identical result verification for both rewritten queries, described above. Refs get-bb#1131 (measurement basis; deliberately not "Fixes" — get-bb#1131 stays open for the maintainer to close once follow-up get-bb#1205 also lands). Residual event-loop work is tracked in get-bb#1207. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Reproduction
A cold thread-list query on a 2.0 GB test database took 1.150 seconds. A concurrent health request took 1.098 seconds, and the server logged a 1.166-second event-loop stall.
Root cause
The thread-list handler ran synchronous better-sqlite3 reads on the server event loop. SQLite used temporary B-trees for the list query, so every client waited for the read to finish.
Fix
Move the complete thread-list read phase to a worker thread with a read-only database connection. Each worker operation uses one deferred read transaction.
The worker derives database-backed disconnected state. After the worker returns, the main thread applies current live daemon session state from the notification hub. This prevents stale host status during a slow read.
The same cold query now leaves the health route responsive at 1.2 ms. Slow-query logs return to the server logger, worker messages use validated contracts, and shutdown terminates the worker.
Fixes #1131