Repository navigation
A fresh replica's first write to a path was guarded by nothing — the invariant moves into the store - #988
Conversation
…dePatchMerge Checkpoint. Not yet building; store-level CAS still to come. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR closes the cross-process gap in monotonic mesh-node persistence by moving the “version never regresses” invariant into the storage backends (where possible) and teaching the write-integrity guard to resolve refused/regressing writes by merging into durable truth and recording any dropped fields.
Changes:
- Add version-conditional upserts to Postgres, Snowflake, and the in-memory adapter; refused writes return the stored (winning) row and do not emit change notifications.
- Introduce a store-refusal conflict resolution path in
MonotonicWriteGuardStorageAdapterthat merges the losing write into the latest durable row and writes anActivityLogsatellite when values are dropped. - Add deterministic tests pinning store-level refusal + merge behavior and a “What’s New” entry documenting the user-visible outcome.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/MonotonicWriteGuardTests.cs | Updates test commentary to reflect the new store-level version gate and the “delete + recreate” legitimate rewind shape. |
| test/MeshWeaver.Hosting.Monolith.Test/StoreLevelWriteConflictTest.cs | Adds monolith integration tests pinning store-level refusal and merge-with-activity-log behavior. |
| src/MeshWeaver.Mesh.Contract/Services/IStorageAdapter.cs | Documents the new conditional-write contract and “refused write emits stored row / publishes no Changes” behavior. |
| src/MeshWeaver.Mesh.Contract/MeshNodeConflictMerge.cs | Adds MeshNode-level conflict merge mapping over the new two-way JSON merge policy. |
| src/MeshWeaver.Hosting/Persistence/MonotonicWriteGuardStorageAdapter.cs | Routes both verification-based and store-refusal conflicts through a single merge path and writes ActivityLog satellites for dropped members. |
| src/MeshWeaver.Hosting/Persistence/InMemoryStorageAdapter.cs | Implements version-conditional “upsert” semantics via ConcurrentDictionary.AddOrUpdate and avoids publishing changes on refused writes. |
| src/MeshWeaver.Hosting.Snowflake/SnowflakeStorageAdapter.cs | Adds version-conditional upsert semantics (MERGE and emulator fallback) and refusal reporting. |
| src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs | Adds a version predicate to the upsert and plumbs refusal detection through Write and WriteMany. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-write-conflicts-merge-not-vanish.md | Documents the new end-user behavior: stale saves no longer silently overwrite newer content; dropped fields are recorded. |
| src/MeshWeaver.Data/Serialization/MeshNodePatchMerge.cs | Adds ApplyTwoWay + TryMergeTwoWay for base-less conflict resolution (store-refusal merge policy). |
| src/MeshWeaver.Data.Contract/ActivityCategory.cs | Introduces WriteConflict activity category for durable conflict-trace records. |
Suppressed comments (1)
src/MeshWeaver.Mesh.Contract/MeshNodeConflictMerge.cs:196
JsonNode.Deserialize(type, options)can throwNotSupportedExceptionas well asJsonException. Catching onlyJsonExceptionmeans conflict resolution can still throw for unsupported content types; it should instead reportContentas overwritten and keep latest.
catch (JsonException)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fix/971-store-level-write-cas
Copilot review on #988: SerializeToNode/Deserialize throw NotSupportedException when no converter exists for the type, not only JsonException — so content this serializer cannot project escaped the merge and faulted the whole write, which is exactly what the guard promises never to do. Both documented failures now report the member and keep the newer row. Not widened to a blanket catch: an arbitrary converter exception is a real fault and must still propagate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Copilot's review caught a real one, now fixed in 7750ac8.
Both documented serialization failures now report Deliberately not widened to a blanket catch: an arbitrary exception from a custom converter is a genuine fault, not an unmergeable input, and it must still propagate. Swallowing it would hide a defect behind a plausible-looking latest-wins — the exact shape this change exists to eliminate. A loud failure is acceptable; a silent wrong one is not. Write-integrity pins re-run after the change: 8/8. |
Test Results (shard 0)685 tests 685 ✅ 5m 15s ⏱️ Results for commit 7750ac8. |
Test Results (shard 3) 11 files 11 suites 5m 6s ⏱️ Results for commit 7750ac8. |
Test Results (shard 4)1 219 tests 1 212 ✅ 6m 6s ⏱️ Results for commit 7750ac8. |
Test Results (shard 5)1 713 tests 1 522 ✅ 6m 8s ⏱️ Results for commit 7750ac8. |
Test Results (shard 2)1 885 tests 1 883 ✅ 6m 32s ⏱️ Results for commit 7750ac8. |
Test Results (shard 1)1 188 tests 1 188 ✅ 7m 22s ⏱️ Results for commit 7750ac8. |
Test Results 65 files 65 suites 36m 32s ⏱️ Results for commit 7750ac8. |
The heaviest project in the suite and the one that sets the shard floor
(shard-assign.sh: "The floor for ANY sharding is the heaviest single project").
threads=1 512 tests, 0 failed, 314.7s
threads=4 512 tests, 0 failed, 122.5s 2.57x
Held back in #979 because threads=4 red two tests. Both are now resolved, and
neither by suppression:
1. StaleActivationDurableFirstSeedTest (durable-seed / routing-cache) — GONE.
#980 closed it as already fixed by the store-level write CAS (#971 / PR #988).
The read-back of a value BELOW the one written was never a visibility lag; it
was a concurrent rollback, which the CAS now refuses at the store. Verified:
it does not reproduce at threads=4 on current main.
2. The GC-reachability family — SCOPED, and this is the part worth reading.
## Why a serial collection here is scoping, not a band-aid
Four classes assert PROCESS-WIDE reachability — "after disposing X, nothing still
roots it" — with WeakReference plus a forced full GC.Collect:
MeshHubDisposalLeakTest, NodeTypeAssemblyLeakTest,
KernelScriptMemoryLeakTest, ConsoleCaptureExecutionContextLeakTest
That assertion is only meaningful in a quiet process. With neighbours allocating
and holding live graphs, a forced collect cannot be relied on to reclaim the
target, so the test reports a "leak" that is really a busy heap. Under parallelism
such a test is not flaky — it is WRONG, because its precondition no longer holds.
Declaring that precondition via [CollectionDefinition(DisableParallelization=true)]
IS the fix. A retry, a widened timeout or a [Skip] would have been the band-aid.
All four are tagged, not only the one that failed locally: NodeTypeAssemblyLeakTest
red here, while MeshHubDisposalLeakTest red identically on CI run 31300348689 this
morning and passed this run purely on timing. They are the same defect class.
🚨 The collection is for reachability assertions ONLY. A test that is merely slow,
or that races on shared STATE, must not be added — that is a real defect and
serialising it would hide it. The type doc says so.
Everything else in the assembly still runs at maxParallelThreads: 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The heaviest project in the suite and the one that sets the shard floor
(shard-assign.sh: "The floor for ANY sharding is the heaviest single project").
threads=1 513 tests, 0 failed, 317.6s
threads=4 513 tests, 0 failed, 110.2s 2.88x
Both measured with DOTNET_PROCESSOR_COUNT=4 — the runner's core count, not this
18-core dev box. That distinction is why the earlier attempt (#1033) passed
locally and red on CI: "4 threads" is four competing for four cores there and
four spread across eighteen here.
## GcReachabilityCollection — scoping, not a band-aid
Four classes assert PROCESS-WIDE reachability ("after disposing X, nothing still
roots it") with WeakReference + a forced full GC.Collect:
MeshHubDisposalLeakTest, NodeTypeAssemblyLeakTest,
KernelScriptMemoryLeakTest, ConsoleCaptureExecutionContextLeakTest
Test classes do NOT share a mesh (ShareMeshClusterEnabled is false, so every
[Fact] builds its own), but they DO share the heap. With neighbours allocating,
a forced collect cannot be relied on to reclaim the target, so the test reports a
"leak" that is really a busy heap. Under parallelism such a test is not flaky —
it is WRONG, because its precondition no longer holds.
[CollectionDefinition(DisableParallelization = true)] declares that precondition.
A retry, a widened timeout or a [Skip] would have been the band-aid.
🚨 The collection is for reachability assertions ONLY. A test that is merely slow,
or races on shared STATE, must not be added — that is a real defect and
serialising it would hide it. The type doc says so.
## The two other #1033 failures are gone
PixelFidelityOfferedOnlyWhenAvailableTest and StoreLevelWriteConflictTest do not
reproduce on current main at threads=4. Consistent with the store-level write CAS
(#971/#988) having hardened the path StoreLevelWriteConflictTest exercises — the
same fix that closed #980.
Verified: full solution build clean under CI's flags
(MW_ALLOW_SOLUTION_BUILD=1 dotnet build MeshWeaver.slnx -c Release -p:CIRun=true
-warnaserror). Building only the touched project is what let CS0236 reach CI on an
earlier attempt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #971.
Verdict up front
On a fresh replica's first write to a path, "the store never moves backward" was enforced by nothing at all. Not by the in-process guard — its high-water table is empty on a new process, so the write took the unverified fast path. Not by the database — the Postgres upsert carried no version condition, and the in-memory store was a plain
_nodes[path] = node. #968 closed the in-process window; this closes the cross-process one it explicitly recorded as residual.The failure is silent by construction: the stale write is acked, and the next write is minted one above the rolled-back row and accepted for a perfectly good reason — so the wrong lineage persists with no error anywhere.
The fix, in two halves
1. The invariant moves into the store
MeshNode.Versionis the node's forward-only revision counter, so a correctly-produced write is always at or above what is stored. Every backend that can express that now refuses a strictly-regressing write at the row and reports it by emitting the STORED node instead of the one it was handed:INSERT … AS target … ON CONFLICT (namespace, id) DO UPDATE SET … WHERE target.version <= EXCLUDED.version— row count 0 ⇒ refusedAddOrUpdatekeeping the higherVersion— atomic against every concurrent writer, no read-then-write windowWHEN MATCHED AND t."version" <= s."version" THEN UPDATE SET …; the no-MERGE emulator fallback gets a conditionalDELETE … AND "version" <= :versionplus aWHERE NOT EXISTSguard on theINSERTEqual versions still apply — re-persisting an unchanged node is a legitimate, common shape. A refused write publishes no change notification (nothing changed, and a notification carrying the loser would hand every subscriber the state the store just rejected), and on Snowflake none of the trigger-replacement steps run either, since they all derive from a row that was not written.
WriteManyreads per-commandNpgsqlBatchCommand.Rows, so a refused node in a batch is neither announced nor reported as committed, and the caller gets durable truth back in its original order.The contract is written into
IStorageAdapter.Write's doc, including the explicit licence for backends that cannot express it to behave as before.2. The conflict resolves by MERGING, never by refusing
Per the maintainer's policy on the issue. Callers of
stream.Updatedo not write conflict-retry loops and must not have to, soMonotonicWriteGuardStorageAdapternow funnels both conflict detectors — its own verification read, and a store that refused the write — into one resolution: re-read, merge the loser into durable truth, write once. Deliberately one attempt, never a retry loop: if the row moved again during the merge, the second refusal is logged and the newer row is kept.The merged node carries the latest row's version, specifically so re-persisting it cannot itself read as a rollback. That also preserves the
saved.Version > written.VersionsignalRefusedWriteOwnerRebaseTestpins, so the owner-rebase path is unchanged.How each policy rule maps onto the shared merge
You asked me not to write a second merge. I did not — but one rule genuinely could not be expressed through the existing entry point, and I want to be plain about which.
MeshNodePatchMerge.Apply(three-way) cannot serve this at all. Its whole classification turns onlive == base, and handedbaseValues: nullit takes the "no conflict signal" branch and applies the patch wholesale — i.e. the stale snapshot overwrites the newer row, which is the defect. There is no argument shape that makes it behave two-way. So the addition is a second entry point on the same class (ApplyTwoWay), sharing the recursion,StringDelta, andMergeArray, rather than a second merge implementation that could drift.MeshNodeConflictMergeon top of it owns only the MeshNode member mapping (which members are user data, which are the store's own bookkeeping, and howContentis projected to JSON and materialised back into its CLR type — the node as a whole is never serialized, becauseHubConfiguration/GlobalServiceConfigurationsare delegates).liveand reports the memberApplyalready refuses conflicting scalars and keeps liveStringDelta.Compute(latest, stale)reduces the pair to one splice; a pure insertion means stale ⊃ latest (take the superset), a pure removal means latest ⊃ stale (keep latest); a splice that both removes and inserts is a genuine divergence ⇒ not auto-resolvableApply's string merge rebases the writer's splice over the intervening edit — that needs a base. With none, only the superset case is decidable.MergeArraywith an empty base, which is exactly "neither side can be shown to have removed anything, so keep every element" — union by whole-element value equalityonOverwritten→OverwrittenMembers→ aLogWarningand a durableActivityLogMeshNodeApply'sonRefusehas no durable sink todayWhich MeshNode members take the string merge: only
NameandDescription— the prose.NodeType,Category,IconandDesiredIdare wire identifiers, not text; the superset rule would happily splice"Code"and"CodeCell"into one of them and silently retype the node, which drives routing and per-node hub activation.PreRenderedHtmlis derived fromContent, so it follows the merged content rather than being spliced into broken markup. All five take latest-wins-and-report, so the policy's fallback still covers them and nothing is dropped silently. The user data that actually benefits from merging lives inContent, which is merged leaf by leaf.Two ambiguities the base-less merge cannot resolve, decided deliberately and documented at the call site rather than hidden:
The warning is the load-bearing half
Latest-wins is acceptable; latest-wins invisibly is the #968 defect — an acked write that rolled a row back with no error anywhere. So every member the merge could not resolve produces both:
LogWarningnaming the path, both versions, and the merged / dropped member lists; andActivityLogMeshNode at{path}/_Activity/write-conflict-{latestVersion}, categoryWriteConflict,ActivityStatus.Warning, with oneLogLevel.Warningmessage per dropped member, naming it.The id is keyed on the durable version alone, never the loser's. That is the right granularity ("this revision had a conflict") and it is what bounds the litter: a wedged owner mints an increasing version on every retry — the #725/#872 fork shape, 834, 835, 836 … against a stuck 2423 — so keying on the losing version would accumulate one satellite per attempt, forever. Re-recording overwrites the same node at
Version = 1, which the store's equal-version rule accepts, so the record can never conflict with itself. It is best-effort by contract (a failed trace must not fail the resolution it describes) and skipped for paths already inside an_Activitynamespace, so a conflict on a trace can never write a trace about a trace.Snowflake
Fixed, not deferred — but unverified against a live endpoint.
SnowflakePartitionStorageProviderhas no write path of its own (grep forINSERT/MERGE/UPDATE/Write(in it returns nothing): it exposesSnowflakePathRoutingAdapter, whoseWritedelegates straight to the per-schemaSnowflakeStorageAdapter.Write. That is the path now made version-conditional, so the provider's gap closes with it.Its other per-process dictionaries (
_registeredPartitions,_schemasInitialized,_provisioned) are not the same defect and are deliberately untouched: they are idempotent provisioning promise-caches, not an authority on durable state. A second replica starting with an empty_provisionedjust re-runs an idempotentCREATE SCHEMA IF NOT EXISTS; nothing is lost.I could not execute this against Snowflake or its emulator — that backend has no CI coverage here. The change is compile-clean and shape-identical to the Postgres one, and that is the whole of my evidence for it.
Not covered, and why
FileSystem, Caching, Sqlite, Cosmos and AzureBlob adapters keep their previous behaviour, which the interface doc now explicitly sanctions. FileSystem/Caching/Sqlite are single-process dev and test hosts — there is no second replica, and a file-level read-then-write "CAS" would be racy anyway. Cosmos has ETag optimistic concurrency but no
version <=predicate: it would need a read plusIfMatchon every write, i.e. an extra round-trip on the hot path, against a backend that is emulator-unverified with a known contract gap. In every one of these the in-process guard remains the protection it always was, which is sound precisely because they are not multi-replica.Verification
Fail-without, run against current
main(the two behaviour-bearing files reverted toorigin/main, rebuilt, re-run) — both new tests fail, and the output names the defect exactly:AWriterWithNoHighWaterMark_CannotRollTheDurableRowBack→[replica write] emitted=v1/stale-snapshot,Expected 1 to be greater than or equal to 7000. On main the fresh replica's write landed: the v1 snapshot went straight over the v7000 row. That is the issue's silent rollback, reproduced.AConflictIsMerged_NotRefused_AndTheDroppedValueReachesTheActivityLog→[durable] v9000 name=durable-advance description=alpha beta,Expected "alpha beta gamma" … but found "alpha beta". On main the conflict was refused, so the losing write's text was destroyed rather than merged, and no activity-log record existed.Pass-with: 2/2. The property is constructed, not raced — "a second writer with no prior mark" is a fresh
MonotonicWriteGuardStorageAdapterinstance over the same store, which is exactly a newly started replica's state. No load, no sleeps, no timing budget.Regression, biased toward everything that writes an explicit
MeshNode.Version(found by grepping the test tree forVersion =alongside a.Write():MeshWeaver.Hosting.Test(whole project)MonotonicWriteGuardWindowTest,RefusedWriteOwnerRebaseTest,StaleActivationSeedRollbackTest,StaleActivationDurableFirstSeedTest+ the 2 newPartitionRootBootstrapTest,OwnerlessPartitionRepairTestGhostPartitionRootRecoveryTest,BulkSaveInstallTestOne existing test needed a setup change, and it is worth reading rather than skimming.
MonotonicWriteGuardTests.StaleHighWaterMark_DoesNotRefuse_WhenTheStoreMovedOutOfBandsimulated "another replica deleted + recreated the node" by writing v1 straight over v50 on the leaf, with no delete. The leaf is now version-conditional, so the store refuses that in-place rewind — which is precisely the behaviour this PR exists to add, so the setup, not the guard, was what broke. It now does what its own comment says:Deletethen recreate at v1, which is how a legitimate rewind has always been expressed (theRecreateAfterDelete*cases beside it, and the guard's "Legitimate rewinds" note — the row is gone, so the recreate faces nothing to regress against). The property it pins is untouched and still fails if the guard ever trusts its mark over a verification read. An in-place rewind throughIStorageAdapteris no longer possible on a conditional backend — that is a deliberate, load-bearing consequence, not a side effect: an out-of-band restore is done with database tooling, not through this interface.The Postgres SQL was executed against real Postgres, not merely compiled — the aliased-target + conditional-
ON CONFLICTshape on a scratch temp table: createINSERT 0 1, advance to 7000INSERT 0 1, regressing write at v1INSERT 0 0with the row untouched, equal-version rewriteINSERT 0 1. That zero row count is exactly whatWriteAsyncCorenow reads to report the refusal.dotnet build -c Release -warnaserrorclean onMeshWeaver.Hosting,MeshWeaver.Hosting.PostgreSql,MeshWeaver.Hosting.SnowflakeandMeshWeaver.Hosting.Monolith.Test, after merging current main.What I could not verify locally: the Postgres and Snowflake test suites. The PG suite needs its container fixture and this machine was already carrying heavy contention; the Snowflake one has no CI coverage at all. The SQL probe above is the strongest evidence I could get for the PG statement short of that suite — CI is the check for the rest, and the PG change touches every write, so it is the thing to watch on this PR.
🤖 Generated with Claude Code