fix(sql): stop clearing the shared command builder outside the connection gate - #19
Merged
Merged
Conversation
Every operation captured its command StringBuilder from a ThreadLocal on the calling thread, then wrote to it after awaiting the connection gate, on a pool thread. The ThreadLocal keyed the builder per *thread* while the state it holds is per *operation*, so two overlapping operations issued from one thread got the same instance -- and the property's Clear() ran at capture time, outside the gate, concurrently with an in-flight operation's Append. Three field symptoms, one cause: SqliteException 'near "JSON_EXTRACT" / "$fp0" / "AND": syntax error' from SQL truncated mid-build; a permanent hang spinning inside StringBuilder on a corrupted chunk chain, which never releases the gate and wedges every later operation on that database; and, on 4.4.x, filtered reads silently returning zero rows where one exists. The sharing was never the problem -- the side effect at capture time was. A gate-concurrency probe confirmed the semaphore admits exactly one operation at a time, and every write to the builder (including FilterBuilder/SortBuilder Build) already happens inside a connection block, with results materialized to a List before return and no deferred enumeration anywhere in the file. So a single builder per database, captured by a side-effect-free reference read and cleared by each block as its first statement, is safe under the mutex that is already there. This costs nothing: 4786 B/read before and after, ~69us/read either way over 5000 filtered reads. A fresh builder per operation also fixes the race but adds 2120 B of gen0 per read (1024 chars, not bytes). The regression test carries a Timeout because the original defect hangs rather than throws; without it a regression wedges CI instead of failing it. It goes red on both serializers in under 100ms against the previous implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a concurrency bug in Tycho’s SQL command construction where a ThreadLocal<StringBuilder> was being cleared on the caller thread before awaiting the connection gate, racing with an in-flight operation appending on a pool thread (leading to truncated SQL, SqliteException parse errors, and potential hangs from StringBuilder corruption).
Changes:
- Replaces the per-thread
ThreadLocal<StringBuilder>with a single per-databaseStringBuilderinstance and removes the “clear-on-capture” accessor. - Updates affected connection-block call sites to pass the shared builder by reference (side-effect-free capture) and rely on in-gate
Clear()at first mutation. - Adds a regression test that runs many concurrent filtered reads and enforces the invariant that command building stays inside the gate (with a load-bearing timeout to catch hangs).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| TychoDB/Tycho.cs | Switches command builder to a per-instance StringBuilder and updates connection-block state passing to avoid pre-gate clearing races. |
| TychoDB.UnitTests/TychoDbTests.cs | Adds a concurrent filtered-reads regression test to detect command corruption/hangs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
The bug
Every operation captured its command
StringBuilderfrom aThreadLocalon the calling thread, then wrote to it after awaiting the connection gate — on a pool thread. TheThreadLocalkeyed the builder per thread while the state it holds is per operation, so two overlapping operations issued from one thread received the same instance. Worse, theReusableStringBuilderproperty calledClear()at capture time, outside the gate, concurrently with an in-flight operation'sAppend.Three field symptoms, one cause:
SqliteException: near "JSON_EXTRACT" / "$fp0" / "AND": syntax error— SQL truncated mid-build, matching the Sentry signature reported from the fieldStringBuilderon a corrupted chunk chain. It never releases the gate, so every later operation on that database queues behind it forever. No exception, nothing in SentryReproduced deterministically: 3 concurrent tasks × 30 filtered
ReadObjectsAsynccalls goes red 10/10 in ~100ms. One task × 50 reads is green, so concurrency is load-bearing. Also reproduced against tagv4.4.1.The fix
The sharing was never the problem — the side effect at capture time was.
One builder per database, captured by a side-effect-free reference read, cleared by each block as its first statement (which every block already did).
This is safe because of three things I verified rather than assumed:
SemaphoreSlimadmits exactly one operation at a time — max simultaneous holders was 1 even while the SQL was being corrupted, which is what ruled out "the gate is broken" as the cause.FilterBuilder.Build/SortBuilder.Build) are inside connection blocks.List<T>before return, and there is noyield returnanywhere inTycho.cs.Cost
Measured over 5000 filtered reads, single-threaded:
ThreadLocalnew StringBuilder(1024)per operationA fresh builder per operation also fixes the race, but costs 2120 B of gen0 per read —
new StringBuilder(1024)reserves 1024 chars, not bytes.Test
TychoDb_ConcurrentFilteredReads_ShouldNotCorruptCommands— 8 tasks × 250 filtered reads, both serializers. Red in <100ms against the previous implementation, green here. Full suite: 182 passed.The
[Timeout]is load-bearing: the original defect hangs rather than throws, so without it a regression wedges CI instead of failing it.The test also pins the invariant this fix now depends on — move any command building back outside the gate and it goes red.
Trade-off
The builder is retained for the instance lifetime at its high-water-mark capacity, so a one-off query with a huge
IN-list would leave that buffer resident where a per-operation builder would be collected. Trivial to bound later (reset capacity in the block above a threshold); left out as premature.Notes
feature/per-operation-command-builder, which carried the per-operation-allocation variant against a staledevelopbase.release/4.4.2deliberately keeps the per-operation allocation — its sync paths callrateLimiter.AttemptAcquire()without checking the lease, so a patch release for users already in production shouldn't rest on a gate invariant that branch only partially upholds.🤖 Generated with Claude Code