Skip to content

fix: match a concurrency token read as zero to a document stored without one - #90

Merged
kerem-acer merged 1 commit into
mainfrom
fix/concurrency-token-without-stored-token
Oct 24, 2026
Merged

kerem-acer merged 1 commit into
mainfrom
fix/concurrency-token-without-stored-token

Conversation

@kerem-acer

Copy link
Copy Markdown
Contributor

Core wants the concurrency token on most of its aggregates (Proposal, Case, Customer, Policy and others), all of which already have documents in production. A document saved before the token was switched on has no token field. It deserializes with the token at zero, but the checked write's { Version: 0 } doesn't match a missing field. So every replace, update and delete made with such a document failed the save with ConcurrencyException, including a tracked document's changes.

The workaround was a backfill per collection, and it isn't enough on its own. During a rolling deploy, instances still on the old code keep inserting documents without the token after the new code's backfill has run, and those documents can never be saved again. Core's prod deploys overlap old and new instances for about 45 seconds, during which about three proposals a minute are created.

What it does

A token read as zero is matched with { Version: { $in: [0, null] } }, which accepts a stored zero or no token at all. Any other value is matched exactly, as before. The write's increment stores the token from zero:

  • a replace writes the document with the token at 1;
  • an update's $inc sets the missing field to 1.

A second write made with what was read before therefore still fails the check. Tracked documents need nothing more: their snapshot is the document as deserialized, with the token at zero, so the diff doesn't touch it.

docs/features.md says, under "Concurrency token", that switching the token on for a collection with documents takes no migration.

Tests

ConcurrencyTokenTests (integration). Each test seeds a contract as raw BSON without the token, as stored before the token was switched on:

  • Replace_DocumentStoredWithoutAToken_WritesAndStoresTheTokenIncrementedFromZero: the replace writes and stores ver: 1, and the document's token is 1.
  • Update_DocumentStoredWithoutAToken_AppliesTheUpdateAndStoresTheTokenIncrementedFromZero: the same for an update.
  • SaveAsync_TrackedDocumentStoredWithoutAToken_WritesItsChangesAndStoresTheTokenIncrementedFromZero: the same for a tracked change, with change tracking on.
  • Delete_DocumentStoredWithoutAToken_DeletesIt: the delete removes the document.
  • Replace_DocumentStoredWithoutATokenSavedSinceItWasRead_ThrowsConcurrencyException: two writers both read the document as zero. The first save stores 1, and the second fails with DocumentExists: true.

All five fail on main and pass here. ConcurrencyTokenTests, ChangeTrackingTests, LoggingTests, TelemetryTests and QueryableEncryptionTests pass locally on net10.0, and ConcurrencyTokenTests on net11.0 too. Formatting and the Release /warnaserror rebuild are clean.

Locally, two unit tests fail on main too, unrelated to this change: VaultCollectionTests.QueryAsync_JoinOnAPagedQuery_IsLeftToTheDriver and QueryAsync_JoinOnAQueryNotFromAVault_IsLeftToTheDriver time out instead of throwing ExpressionNotSupportedException. main's CI passes them, so it's something in the local setup.

🤖 Generated with Claude Code

…out one

A document saved before the token was switched on has no token field. It reads as
zero, but the checked write's { Version: 0 } doesn't match a missing field, so every
replace, update and delete made with it failed the save with ConcurrencyException.
Switching the token on for a collection with documents in it took a backfill, and a
second one after a rolling deploy, for the documents old instances kept inserting.

A token read as zero now matches { Version: { $in: [0, null] } }: a stored zero or no
token at all. The write's increment stores the token from zero, so a later write of
what was read before is checked as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kerem-acer
kerem-acer merged commit 2a09c86 into main Oct 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant