Use a dedicated Lock for the export claim, not Umbraco's dictionary - #1020
Merged
Conversation
ClaimItemForExport locked on notification.State - Umbraco's dictionary, not ours. Anything else holding a reference to it could contend with us on an object neither side knows the other is using. Replaced with a private static Lock, following the pattern from #998. Statics on a generic type are per closed type, so documents and elements get a lock each. That suits us: they never share a notification state, so they have nothing to contend over. Added a test that races the claim across threads. Verified it fails without the lock - concurrent HashSet.Add corrupts the set and throws IndexOutOfRangeException - so it actually proves the guard is doing something. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 18, 2026
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.
Follow-up to the note on #1019.
What
PublishableContentHandlerBase.ClaimItemForExport(added in #1018) locked onnotification.State— Umbraco's dictionary, not ours. Anything else holding a reference to it could contend with us on an object neither side knows the other is using. That's the pattern #998 movedTemplateWatcheraway from.Now a
private static readonly Lock, matching #998.Why a static
The obvious alternative — a per-instance lock — doesn't work: the whole point of the claim is that it's shared across the notifications of one operation, and handlers are resolved per notification dispatch. A static is what covers them all.
Statics on a generic type are per closed type, so
PublishableContentHandlerBase<IContent>and<IElement>get one each. That's a bonus rather than a problem: documents and elements never share a notification state, so they have nothing to contend over.Contention isn't a concern either way — the critical section is a dictionary lookup and a
HashSet.Add, and uSync's own imports pause events, so this only runs on editor saves.Test
Added
Concurrent_Claims_Only_Let_One_Caller_Through: 50 items × 8 threads racing the claim throughParallel.For, asserting exactly 50 claims succeed.I checked it actually fails without the lock rather than passing regardless — it throws
IndexOutOfRangeExceptionout ofHashSet.Addas concurrent adds corrupt the set:Builds clean; 211 tests pass.
Note
No changelog entry — this is an internal correctness fix to code that hasn't shipped in a release yet (#1018 is unreleased on
v18/main), and the behaviour is unchanged.Worth saying plainly: Umbraco raises the notifications for one operation sequentially, so this guard has always been belt-and-braces rather than something we expect to contend on. It matters because the state dictionary is a plain
DictionaryandSyncScopedNotificationPublishercan dispatch on a queued background thread whenBackgroundNotificationsis on — the cost of being wrong is a corrupted set, not just a duplicate export.🤖 Generated with Claude Code