Repository navigation
Say which DuckDB lock a write takes, and why both answers were right (#2463) - #2469
Merged
Merged
Conversation
…2463) Lite had two conventions for locking a DuckDB write and only one of them was written down. FindingStore's three write paths take the READ lock; twenty-five call sites take the WRITE lock, and LocalDataService.OpenWriteConnectionAsync stated that as the house rule in its own doc comment -- "use for UPDATE/DELETE/INSERT operations that must not race with archival or compaction" -- using the same justification FindingStore uses to reach the opposite answer. They are not two answers to one question. They are answers to two questions, and the axis that separates them belongs to DuckDB rather than to anything in DuckDbInitializer. The READ lock excludes MAINTENANCE. A held read lock blocks EnterWriteLock, so holding one IS how a statement says "not while the file is being reorganized under me", and that is the whole of what an append needs. The WRITE lock additionally excludes OTHER WRITERS OF THE SAME ROWS -- which matters because DuckDB's concurrency control is optimistic and does not queue the second writer, it FAILS it. Measured on DuckDB.NET 1.5.5 with the two transactions strictly interleaved: two appends of new rows both commit; a retention DELETE overlapping a disjoint append both commit; two UPDATEs of one row, two upserts of one key, and two delete-then-reinsert compounds each fail the second with a TransactionContext conflict. The interleave is load-bearing -- let the connections merely start together and one commits before the other begins, and every case reads as "both committed", which is how this looks like a tidiness complaint instead of a rule. So neither set of callers is wrong and nothing here changes behaviour: the diff is doc comments and one test file, and it adds no executable line to any of the five sources. FindingStore appends and its retention DELETE is disjoint, so the read lock is sufficient. The alert store's watermark upserts, its incident-occurrence delete-then-reinsert and its two config_database_state_expected UPDATEs all genuinely collide -- the UPDATEs against #2208's maintenance block, which writes the same table -- and the mute store's UPDATEs and DELETEs collide with a timer-driven expiry purge. The pair the issue called "same species, same store, opposite lock" turns out not to be the same species at all: nothing but the analysis pass writes analysis_muted, while config_mute_rules is UPDATEd by an operator and DELETEd by a timer. Two appends do hold the write lock and by the rule do not need it -- DuckDbAlertHistoryStore.RecordAlertAsync and DuckDbMuteRuleStore.InsertAsync. They are recorded as over-locked rather than corrected: each is one method on a store whose siblings need it, both are alert- or operator-frequency, and making two of eleven spell it differently costs more legibility than it recovers lock time. A rule that admits its own two exceptions is more useful than one quietly enforced over them. The rule goes on DuckDbInitializer.s_dbLock, which is where every caller already looks, and each store on either side of the split points at it, so whichever a reader lands on first is where they find it. LocalDataService's doc keeps the 5-second timeout note -- which is real and is its own contribution -- and stops claiming to be the rule; the INSERT in its old sentence was the part that was wrong. On the latent hazard, the answer is not "leave it documented". LockReleaser calls ExitReadLock unguarded across an await, safe today only because DuckDB.NET's async completes synchronously (thread id 4, 4, 4, 4, 4 across OpenAsync, DDL, 200 inserts and a reader drain, reproduced here). The cheap mitigation the issue floated -- guard Dispose with IsReadLockHeld -- is a bug amplifier, and that is measured rather than argued: a thread that did not enter observes IsReadLockHeld as false, so the guard SKIPS the exit, the original thread's entry is then held forever, and with it held TryEnterWriteLock(400ms) fails. The guard would trade a loud, attributable SynchronizationLockException for a silently leaked reader that permanently wedges every CHECKPOINT, archival and compaction for the life of the app. Throwing is the better failure, and the doc now says so rather than leaving the next reader to reach for the guard. A real fix has to make the releaser not thread-affine at all, which means replacing ReaderWriterLockSlim, which is a change to the lock primitive and not to any caller. It is not worth doing while the premise holds, so what ships is a tripwire on the premise: DuckDbAsyncStillCompletesOnTheCallingThread asserts the thread never moves across OpenAsync, DDL, 200 awaited inserts and a reader drain, under Task.Run with no SynchronizationContext. Two hundred awaits because one moved continuation could land back on the same pool thread by chance and two hundred could not. Only the rule pin can be red on dev, and that is honest rather than a gap. The other two assert facts that are already true; a test for a fact is a tripwire, and writing one that failed today would mean breaking the thing first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed. This is a clean, well-verified documentation PR — no correctness, security, or Lite/Darling parity concerns. Verified independently (not just taken on faith from the PR description):
Lite/Darling parity: No concern — this is DuckDB-specific locking machinery. Darling has no DuckDB usage ( T-SQL style / SQL injection / missing-index folklore: N/A — no SQL or T-SQL touched in this PR. No inline comments to add; nothing here needs a change. |
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.
Closes #2463. Lite only. No behaviour changes anywhere —
git diffon the five modified sources adds zero executable lines; every added line is a doc comment. The only new code is one test file.Verifying the issue first
Mostly accurate, with one incomplete census and one prior ruling it did not account for.
LocalDataService.OpenWriteConnectionAsyncdoc states the house rule verbatimDuckDbAlertHistoryStore— 6 write-lock sitesDuckDbMuteRuleStore— 5 write-lock sitesFindingStore— 3 write paths on the read lockLocalDataServiceis the only caller passing a timeout (5 s)OpenWriteConnectionAsynchas 14 callers of its own the census missed —LocalDataService.ServerTags.cs×8,.DatabaseStates.cs×3,.AlertHistory.cs×3The prior ruling: #2208 already decided this axis, in the opposite direction, off a measured failure.
LocalDataService.GetDatabaseStateDeviationsAsyncmoved from the read lock to the write lock, and says why at the site — an unrelated server-tags test timed out acquiring the write lock while this method held the read lock across four statements. So "unify on the read lock" would have reverted a deliberate ruling backed by an observed failure, on the strength of a measurement taken against a different file. That is precisely the "repo-wide convention altered without anyone deciding to" the issue was filed to prevent.The two conventions are answers to two different questions
The resolution is neither "unify" nor "state both and shrug". There is one rule, and it explains both sets:
Measured on DuckDB.NET 1.5.5, with the two transactions strictly interleaved — the second statement runs while the first transaction is still open and uncommitted:
TransactionContext Error: Conflict on update!Conflict on update!Conflict on tuple deletion!The interleave is load-bearing. My first pass let both connections merely start together, and every case came back "both committed" — a slow connection open lets one transaction commit before the other begins, so the probe measures nothing. That artefact is exactly how this reads as a tidiness complaint instead of a rule, and it is why the numbers above are reported with the barrier described.
What the rule says about the actual call sites
It describes the code as it stands, with two named exceptions.
Read lock, correct —
FindingStore.InsertFindingsAsyncandMuteStoryAsyncappend new rows (#2464 moved their ids to the process-wideCollectionIdGenerator, so they cannot even collide on the key), andCleanupOldFindingsAsync's retention DELETE is disjoint from them. Nothing else writes those tables.Write lock, earned — the two watermark upserts,
SaveIncidentOccurrencesAsync's delete-then-reinsert, and the twoconfig_database_state_expectedUPDATEs (which collide with #2208's maintenance block, writing the same table); the mute store's UPDATEs and DELETEs, which race a timer-driven expiry purge; the 14OpenWriteConnectionAsynccallers, which are UPDATE, DELETE and #2208's compound.The pair that made this look like a contradiction resolves rather than persists. The issue's headline example —
DuckDbMuteRuleStore.InsertAsyncwritingconfig_mute_rulesunder a write lock whileFindingStore.MuteStoryAsyncwritesanalysis_mutedunder a read lock, "same species of write, same store, opposite lock" — turns out not to be the same species. Nothing but the analysis pass ever writesanalysis_muted.config_mute_rulesis UPDATEd by an operator and DELETEd by a timer that can be running at the same instant.Write lock, over-locked, and left that way — two appends that cannot collide:
DuckDbAlertHistoryStore.RecordAlertAsyncandDuckDbMuteRuleStore.InsertAsync. Each is one method on a store whose siblings genuinely need the write lock, both are alert- or operator-frequency rather than hot, and making two of eleven spell it differently costs more in legibility than it recovers in lock time. Recorded as over-locked so the next reader knows it was seen and decided rather than missed. A rule that names its own two exceptions is more useful than one quietly enforced over them.Where it is written
On
DuckDbInitializer.s_dbLock— the issue's own suggestion, and where every caller already looks.LocalDataService.OpenWriteConnectionAsynckeeps its 5-second-timeout note (real, and its own contribution) and stops claiming to be the rule; the INSERT in its old sentence was the part that was wrong, since excluding archival is what the read lock already does.FindingStore's #2464 paragraph said "which is right for the other eleven call sites is #2463 … deliberately not answered here" — that is now answered, so it says so. Both write-lock stores get a class note pointing at the same rule, so whichever a reader lands on first is where they find it.The latent hazard: not "documented and left"
LockReleaser.DisposecallsExitReadLockunguarded across anawait, safe today only because DuckDB.NET's async completes synchronously. Reproduced independently: thread id4, 4, 4, 4, 4acrossOpenAsync, DDL, 200ExecuteNonQueryAsyncand a reader drain.The cheap mitigation the issue floated is a bug amplifier, and that is measured rather than argued. Guarding
DisposewithIsReadLockHeld:A thread that did not enter sees
false, so the guard skips the exit — andReaderWriterLockSlimentries are per-thread, so the original thread's entry is then held forever. With it held, no writer can get in. The guard would trade a loud, attributableSynchronizationLockExceptionfor a silently leaked reader that permanently wedges every CHECKPOINT, archival and compaction for the life of the app. Throwing is the better failure, and the doc now says so rather than leaving the next reader to reach for the guard and make it worse.A real fix has to make the releaser not thread-affine at all — replacing
ReaderWriterLockSlim, which would also retire #2443's 50 ms poll. That is a change to the lock primitive, not to any caller, and it is not worth doing while the premise holds. So what ships is a tripwire on the premise:DuckDbAsyncStillCompletesOnTheCallingThreadasserts the thread never moves acrossOpenAsync, DDL, 200 awaited inserts and a reader drain, underTask.Runwith noSynchronizationContext. Two hundred awaits because one moved continuation could land back on the same pool thread by chance and two hundred could not. If a driver bump turns it red, theLockReleaserdoc is the place to start.Verification
Full-solution rebuild on macOS (
EnableWindowsTargeting,-t:Rebuild): 0 errors, 30 warnings, warning-code set byte-identical todev's (CA1720×8,CA2016×20,xUnit1031×2,xUnit2031×30). The 20CA2016are Twenty CA2016 warnings, all in QueryStoreSliceRepairService: a token in scope that never reaches the lock #2465's, in a file this branch does not touch.git diff -U0on the five modified sources yields zero added non-comment lines.Lite.Testsisnet10.0-windows, so the realLite.Tests/DuckDbLockModelTests.cswas compiled into anet10.0xUnit shim with the realLite.Tests/ParitySource.cs(no stubs needed) and run against both branches: 3/3 pass here, 1/3 fails ondev:Only that one can be red, and the class doc says so. The other two assert facts that are already true — the driver's synchrony, and what a guard would cost. A test for a fact is a tripwire, not a regression test; writing one that failed today would mean breaking the thing first.
Rebased onto
devat9d1337a5and every check above re-run after.Deferred
ReaderWriterLockSlimwith a non-thread-affine primitive is left unfiled: it is a real option, it would retire The analysis token is armed but 167 Darling and ~138 Lite store calls never receive it #2443's poll, and it has no trigger yet. The tripwire is the trigger.Co-Authored-By: Claude Opus 5 noreply@anthropic.com