Repository navigation
Lite has two conventions for locking a DuckDB write, and one of them is written down as the rule #2463
Description
Activity
Resolved on
devby #2469 — with zero executable lines changed, which is the correct outcome and not the one the issue expected.Two of this issue's premises were wrong, and the second one matters.
The census was 11 write sites; it is 25.
OpenWriteConnectionAsynchas fourteen callers the issue missed (ServerTags ×8, DatabaseStates ×3, AlertHistory ×3).#2208 had already ruled this axis, in the opposite direction. It moved
GetDatabaseStateDeviationsAsyncfrom the read lock to the write lock, off a measured failure. So "unify on the read lock" — the tidy answer this issue invites — would have reverted a deliberate, evidence-based ruling using evidence from a different file. That is exactly the trap in filing a consistency complaint without checking whether the inconsistency was decided.They are not two answers to one question. They are answers to two questions, and there is one rule. The read lock excludes maintenance, which is all an append needs. The write lock additionally excludes other writers of the same rows — and that matters here specifically because DuckDB's concurrency is optimistic: it does not queue the loser, it fails it.
Measured on 1.5.5 with the transactions strictly interleaved: two appends commit; a disjoint retention DELETE commits; two UPDATEs, two upserts, and two delete-then-reinsert compounds each fail the second. Worth noting the first attempt at that measurement, without a barrier, returned "both committed" for everything — a useless artefact, and precisely how this question reads as a tidiness complaint rather than a correctness one.
That rule describes the existing code with two named exceptions (
RecordAlertAsyncandMuteRuleStore.InsertAsync, appends holding the write lock), recorded as over-locked and deliberately unchanged. And the headline pair in this issue turns out not to be the same species at all: nothing but the analysis pass writesanalysis_muted, whileconfig_mute_rulesis UPDATEd by an operator and DELETEd by a timer.On the latent
Disposehazard — the mitigation I suggested is a bug amplifier, and that was measured rather than argued. A foreign thread seesIsReadLockHeld == false, so a guard skips the exit; the original thread's entry is then held forever, and with it heldTryEnterWriteLock(400ms)fails. The guard would trade a loudSynchronizationLockExceptionfor a silently leaked reader that permanently wedges every CHECKPOINT, archival and compaction. Throwing is the better failure, and the doc now says so — so the next reader does not reach for the guard I would have reached for.What ships instead is a tripwire on the premise:
DuckDbAsyncStillCompletesOnTheCallingThread(200 awaits,Task.Run, no SynchronizationContext). If DuckDB.NET ever goes genuinely async, that fails and the hazard stops being latent.Two things deferred deliberately: the two recorded over-locks (a behaviour change for an unmeasured benefit, in the files a reader is most likely to copy from), and replacing
ReaderWriterLockSlimwith a non-thread-affine primitive — unfiled because there is no trigger yet, and the tripwire is the trigger.
Found while ruling on #2455, and deliberately not fixed there — #2455 is one file's lock choice, this is the model it sits in, and changing that inside a three-method PR is how a repo-wide convention gets altered without anyone deciding to.
#2455 asked whether
FindingStore's read-lock-around-a-write is correct. It is, on the axis the class doc names:DuckDbInitializer.s_dbLockcoordinates everyone against MAINTENANCE (CHECKPOINT, archive DELETEs, compaction), which takes the exclusive write lock, and a held read lock blocksEnterWriteLock— so holding one is how a write says "not while I am in flight". Verified rather than assumed, on DuckDB.NET 1.5.5: two connections appending to the same table in concurrent transactions both commit, and the retention DELETE overlaps an insert batch cleanly.What that ruling surfaced is that the codebase does not have one convention here. It has two.
The split
Writes that take the WRITE lock — and one of them states it as the rule:
LocalDataService.OpenWriteConnectionAsync(Lite/Services/LocalDataService.cs:62), whose own doc comment reads: "Creates and opens a DuckDB connection wrapped in an exclusive write lock. Use for UPDATE/DELETE/INSERT operations that must not race with archival or compaction." That is a house rule, written down, with the same justificationFindingStoreuses to reach the opposite answer.DuckDbAlertHistoryStore— 6 sites, all ordinary alert-state writes (alert log INSERT, watermark upsert, incident-occurrence delete+insert).DuckDbMuteRuleStore— 5 sites, ordinary mute-rule CRUD.Writes that take the READ lock:
FindingStore.InsertFindingsAsync,MuteStoryAsync,CleanupOldFindingsAsync.The genuinely maintenance-shaped callers (
ArchiveService,QueryStoreSliceRepairService,DuckDbInitializer, the post-collectionCHECKPOINTinRemoteCollectorService) take the write lock and are not in question.So
DuckDbMuteRuleStore.InsertAsyncwritesconfig_mute_rulesunder a write lock whileFindingStore.MuteStoryAsyncwritesanalysis_mutedunder a read lock. Same species of write, same store, opposite lock, no note anywhere saying why either is right.Why it matters more than a tidiness complaint
Both cannot be the rule, and the difference is not free in either direction.
FindingStoreis wrong and every finding batch can currently overlap a CHECKPOINT-adjacent operation the other stores exclude.AcquireWriteLock()with no timeout blocks behind archival indefinitely.LocalDataServiceis the only one that passes a timeout (5 s, explicitly "prevents UI freeze if archival currently holds the lock"); the other ten would simply wait.My reading is that the read lock is right and the write-lock sites are over-locking, because DuckDB does its own concurrency control and the only thing the lock has to exclude is the file being reorganized underneath a running statement. But that is a judgement about eleven call sites in three files I did not measure, and it should be made deliberately, with the answer written into
DuckDbInitializer's class doc so the next writer inherits a rule instead of picking one.A second, latent hazard in the same model
Worth recording while the lock is in view, and explicitly not a live defect today.
ReaderWriterLockSlimis thread-affine:ExitReadLockthrowsSynchronizationLockExceptionwhen called from a thread that did not enter.LockReleaser.Disposecalls it unguarded, and every one of these locks is held acrossawait:The analysis pass runs under
Task.Runwith noSynchronizationContext, so a continuation is free to resume on a different pool thread. It does not, and the reason is measured rather than lucky: DuckDB.NET 1.5.5's async methods complete synchronously. Thread id acrossOpenAsync, a DDL statement, 200ExecuteNonQueryAsynccalls and anExecuteReaderAsync/ReadAsyncdrain:4, 4, 4, 4, 4. Nothing ever yields, so the entering thread is always the exiting thread.That makes the whole
AcquireReadLock/awaitpattern — 66 call sites inLite/Analysisalone per #2443 — dependent on an undocumented property of the driver's async implementation. A DuckDB.NET release that made any of these genuinely asynchronous would turn it into intermittentSynchronizationLockExceptions across the store layer, in the paths that hold locks longest. Cheap mitigations if it is worth pre-empting: guardLockReleaser.DisposewithIsReadLockHeld/IsWriteLockHeld, or replace the lock with aSemaphoreSlim(not thread-affine, and it has a genuineWaitAsync(CancellationToken)— which would also retire #2443's 50 ms poll).Suggested shape
DuckDbInitializer's class doc, which is where every caller already looks.Not urgent on any of the three. Filed so the split is recorded rather than rediscovered by the next person who reads
FindingStoreand reaches forAcquireWriteLock.Related
FindingStorekeeps the read lock and now says why)