AuditLog set-based ingest deadlocks (1205) under concurrent writers #124
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the #49
[xc-4]run (docs/plans/2026-11-10-wp4-residual-scope-results.md§5,ResidualBench audit).Four concurrent callers of
AuditLogRepository.InsertManyIfNotExistsAsync, each inserting 256-row packets into a freshly migrated database, hit SQL error 1205 (deadlock victim) within seconds. The deadlock is in the set-basedINSERT … SELECT … WHERE NOT EXISTS (EventId)ofInsertChunkAsync. A single serial writer, which is the shapeAuditLogIngestActor(a cluster singleton) actually runs, inserted 1 M rows with zero deadlocks.Production can still run concurrent inserts into
AuditLog:SiteAuditReconciliationActorsingleton (alsoInsertManyIfNotExistsAsync);CentralAuditWriterdirect writes.AuditIngestErrorClassifieralready treats 1205 as transient, so a site re-sends its packet and nothing is lost. The cost is a wasted round trip plus a Warning, and a risk of retry storms if both singletons ingest a large backlog at once (e.g. after an outage).Worth checking:
WITH (UPDLOCK, HOLDLOCK)on the probe, orROWLOCK) removes the cycle.The deadlock graph was not captured.
Fixed in
424db155.Captured deadlock graph. Both processes are
INSERTs at READ COMMITTED, holding and waiting on page locks onAuditLog(one holdsSon page A and wantsSon page B; the other holdsIXon B and wantsIXon A). TheNOT EXISTS (… WHERE x.EventId = v.EventId)probe of a 256-row packet was being answered by scanning the EventId index. A scan keeps its current page's S lock while it requests the next, and the other writer had just written into those pages.Fix. The probe is
FROM dbo.AuditLog x WITH (FORCESEEK): one seek per VALUES row, locking only the key it reads and only while reading it. A seek can still wait on another writer's uncommitted row with the same EventId. That is a genuine duplicate, and the existing duplicate-key fallback already handles it.Measured with a new
ResidualBench audit-contentionmode: N concurrent writers of 256-row packets on a throwaway migrated database. One writer replays already-sent packets, as a reconciliation pull overlaps live telemetry.READPASTon top ofFORCESEEKadded nothing (4,007 rows/s), so it was left out.Regression test.
AuditLogRepositoryTests.InsertManyIfNotExistsAsync_ConcurrentWriters_DoNotDeadlock(real SQL Server fixture) seeds 10 k rows, then runs 4 writers × 15 packets over the same time window. Without the hint it failed 4 out of 4 runs (40–43 of 60 packets lost to 1205); with the hint it passed 3 out of 3. The full AuditLog repository suite passes (129).On the three questions in the issue: (1) concurrent singletons deadlocking each other: yes, that was this cycle, and it is gone. (2) A local 1205 retry: not added. The cycle is removed rather than retried, and a retry would be wrong inside the cached-telemetry dual-write's ambient transaction, which a deadlock rolls back as a whole. (3) A lock hint:
FORCESEEKis the one that removes the cycle.