Make state writes conditional, so a setting change is not lost to a check - #30
Merged
Conversation
…heck Reading a state, changing it and writing it back is three steps, and until now nothing guarded the gap. A check finishing between another writer's read and its write had its result overwritten -- or overwrote a setting somebody had just changed from the dashboard. CosmosDbStateProvider has documented this as a known limitation from the beginning, and the README listed fixing it as a breaking change awaiting a major release. It is not breaking. The new members are defaulted interface methods: SupportsOptimisticConcurrency feature detection, false by default GetStateEntryAsync the state plus the version it was read at TrySetStateAsync writes only if that version is current A provider written against the original two-method interface still compiles and still works. Its default GetStateEntryAsync reports no version, and its default TrySetStateAsync refuses a versioned write rather than performing an unconditional one -- ignoring the version would lose exactly the update the version was passed to protect, and would do it silently. A refused write returns false rather than throwing, unlike Orleans and EF Core. Under contention a conflict is the expected outcome, not an exceptional one, and the answer is always the same: read again, reapply, write again. StateProviderExtensions.UpdateStateAsync is that loop, and all nine setting changes on PulseChecker now go through it. Per provider: CosmosDB the document ETag, sent as If-Match; 412 means refused relational a version column, in the UPDATE's WHERE; 0 rows means refused in-memory a compare-and-swap on the stored version The relational providers add the column to an existing table on startup, probing the schema portably rather than relying on an IF NOT EXISTS that SQLite lacks for ADD COLUMN. Two cases needed their own handling, and collapsing either into its neighbour is a bug that tests here cover: Nothing stored yet has no version to compare, so two writers both finding nothing would both create and lose one of the two changes. AbsentVersion is the "only if it is still missing" precondition, spelled If-None-Match: * in HTTP; CosmosDB gets a create, the relational providers an INSERT ... WHERE NOT EXISTS, in-memory a TryAdd. A row written before the column existed is stored but unversioned, which is not the same as absent. Treating it as absent asks for a create that can never succeed, so the first setting change after upgrading would retry and throw on every existing checker. It writes unconditionally instead, exactly as it did before the upgrade, and carries a version from then on.
…tomic Three defects from review, all in the same direction: the guarantee was claimed more widely than the code delivered it. A check storing its result was never converted. TriggerAsync still read with GetStateAsync and wrote with SetStateAsync, unconditionally -- and because a result is written by replacing the whole state, that write put every other field back to what it was when the check started. So the exact sentence the changelog used to describe the fix, a setting change overwritten by a check that read the state first, was the case still broken. Within one process the semaphore hides it: a check holds it across both steps, so no setter lands in between. Across two replicas sharing one store, which is the only reason to version a write at all, nothing does. The retry loop is now split from the lock and the event, and TriggerAsync uses it. Same loop, one caller more. The test that was supposed to cover this could not fail. It raced TriggerAsync against SetGroupAsync on one checker, where the semaphore serialises them, and against the in-memory provider nothing suspends, so the two ran one after the other twenty times over. It passed against the unversioned code too. Replaced by the direction it was describing, driven through a provider that lets one competing write in between the read and the write. The interfering provider now hooks both read shapes, so moving a caller from one to the other cannot silently disarm a test again. INSERT ... SELECT ... WHERE NOT EXISTS is not atomic. Under READ COMMITTED two writers can both find no row, both insert, and the loser take a primary key violation -- an exception, where TrySetStateAsync's contract says a lost write returns false. The remarks on the SQL Server dialect in the same file already said precisely this about the upsert, and the comment added above the new statement asserted the opposite. Each dialect now resolves it in one step: ON CONFLICT DO NOTHING on PostgreSQL and SQLite, and on SQL Server the UPDLOCK, HOLDLOCK the upsert beside it already relies on. A dialect that supplies none keeps the portable form, which is documented as what it is. The version column migration had the same shape. Two instances starting together both find the column missing, both ALTER, and the loser fails against a database that is now correct -- out of IHostedService.StartAsync, so the host does not start. A failure is no longer taken at face value: the columns are read again, and one that left the column present was the other instance winning. Anything else is rethrown untouched, so this does not depend on matching error text and does not hide a real failure. InsertIfAbsentFormat is an optional record parameter rather than a required one, so a dialect built by hand for an engine this library has not heard of still compiles.
The provider's ETag paths were the one part of this feature verified by reading rather than by running, and I said so on the pull request instead of fixing it. Two of them are exactly the kind that look right and are not: whether the ETag that was read is actually attached to the write, and whether a refusal is reported as one rather than escaping as an exception. A provider that dropped the ETag entirely would have passed every other test here, by writing unconditionally and never being refused. Container is abstract, so the double is hand-written rather than mocked -- the repository has no mocking dependency and one type is not a reason to take one. Its forty-odd members live in StubContainer, where each one refuses, so the fake in the test overrides only the four the provider calls and the test stays about behaviour. Refusing rather than returning a default is the point: a test that reaches an unstubbed member has left the path it meant to exercise and should say so. The fake applies CosmosDB's documented rules and nothing else -- every write mints a new ETag, an If-Match against a stale one is refused with 412, and a second create for the same id is refused with 409. That answers what the provider asks for, which is what these tests are about. It does not prove the service behaves as documented; only a run against real CosmosDB does that, and the class says so rather than implying more than it has.
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.
Reading a state, changing it and writing it back is three steps, and until now nothing guarded the gap. A check finishing between another writer's read and its write had its result overwritten — or overwrote a setting somebody had just changed from the dashboard.
CosmosDbStateProvider's README has documented this as a known limitation from the beginning, the CHANGELOG listed it under Known issues, and the README roadmap said resolving it "needs the interface to carry a version, which is a breaking change and a major release."It is not breaking. That was the substantive finding here, and it is the third time in this series that a "needs a major version" claim turned out to be wrong on inspection.
The API
Three new members on
IStateProvider, all defaulted interface methods:SupportsOptimisticConcurrencyfalseby defaultGetStateEntryAsyncTrySetStateAsyncA provider written against the original two-method interface still compiles and still works. Its default
GetStateEntryAsyncreports no version, and its defaultTrySetStateAsyncrefuses a versioned write rather than performing an unconditional one — ignoring the version would lose exactly the update the version was passed to protect, and would do it silently.A refused write returns
falserather than throwing, unlike Orleans (InconsistentStateException) and EF Core (DbUpdateConcurrencyException). Under contention a conflict is the expected outcome, not an exceptional one, and the caller's answer is always the same: read again, reapply, write again.StateProviderExtensions.UpdateStateAsyncis that loop — the read-modify-write pattern the Azure SDK's conditional-request guidance describes.Both writers go through it: the nine setting changes on
PulseChecker, and a check storing its own result. The second one matters more than it looks, and I got it wrong first time round — see below.Per provider
If-MatchCreateItemAsync412/409versioninUPDATE ... WHEREON CONFLICT (name) DO NOTHINGWHERE NOT EXISTS (... WITH (UPDLOCK, HOLDLOCK))ON CONFLICT(name) DO NOTHINGTryUpdateCASTryAddThe relational providers add the column to an existing table on startup, probing the schema portably (
SELECT * WHERE 1 = 0+CommandBehavior.SchemaOnly) rather than relying on anIF NOT EXISTSthat SQLite lacks forADD COLUMN.Three cases, and collapsing any two is a bug
Nothing stored yet has no version to compare, so the write would go through unconditionally — and two writers both finding nothing, both creating, would lose one of the two changes.
IStateProvider.AbsentVersionis the "only if it is still missing" precondition, spelledIf-None-Match: *in HTTP.Stored and versioned is the ordinary case: the version goes in the write.
Stored but unversioned — a row written before the column existed — is not the same as absent. Treating it as absent asks for a create that can never succeed, so the first setting change after upgrading would retry five times and throw, on every existing checker. It writes unconditionally instead, exactly as it did before the upgrade, and carries a version from then on.
What review caught (second commit)
Three lenses ran over the first commit. All three findings pointed the same way — the guarantee was claimed more widely than the code delivered it:
The check's own write was never converted.
TriggerAsyncstill read withGetStateAsyncand wrote withSetStateAsync, unconditionally. Because a result is stored by replacing the whole state, that write put every other field back to what it was when the check started — so the exact sentence the CHANGELOG used to describe the fix was the case still broken. Within one process the semaphore hides it; across two replicas sharing one store, which is the only reason to version a write at all, nothing does. Two reviewers reproduced it independently, one against an isolated snapshot build.The test meant to cover that could not fail. It raced
TriggerAsyncagainstSetGroupAsyncon one checker, where the semaphore serialises them, and nothing suspends against the in-memory provider — so the two ran one after the other, twenty times over. It passed against the unversioned code too. My own mutation run had already said so and I misread it: the "checker writes unconditionally" mutation killed exactly one test, and it was the other one.INSERT ... SELECT ... WHERE NOT EXISTSis not atomic. Under READ COMMITTED two writers both find no row, both insert, and the loser takes a primary key violation — an exception, where the contract says a lost write returnsfalse. The SQL Server dialect's own remarks in the same file already said precisely this about the upsert, and the comment I added above the new statement asserted the opposite.The migration had the same shape — two replicas both find the column missing, both
ALTER, and the loser fails out ofIHostedService.StartAsync, so the host does not start. A failure is no longer taken at face value: the columns are read again, and one that left the column present was the other instance winning. Anything else is rethrown untouched, so this neither matches on error text nor hides a real failure.Verification
394 unit tests green on both net8.0 and net10.0; 11 consecutive clean runs of the full suite. The relational tests run against a real SQLite file, so the conditional
UPDATE, the create path and the column migration are executed rather than inspected.Mutation-tested, because a passing test proves nothing until it has been seen to fail:
Docs
CHANGELOG.md, the rootREADME.mdroadmap,Healthie.StateProviding.CosmosDb/README.mdandHealthie.LeaderElection/README.mdall asserted last-write-wins or "planned for the next major version". All four corrected.Not included
HEALTHIE_TEST_COSMOS-gated suite later.dotnet format --verify-no-changesfails on files outside this diff. Pre-existing, not a CI gate, deliberately left alone so the diff stays reviewable.