Repository navigation
GitSync: a server-side addition was silently deleted one sync cycle later - #675
Merged
Merged
Conversation
…n cannot skip past them Two-way sync protects a server-side change two ways: it refuses to OVERWRITE it (upsert conflict) and it refuses to PRUNE it (the repo has no copy — a server-side ADDITION). Only the first incremented StaticRepoImportResult.Preserved. The prune path filtered the node out of toPrune and reported nothing. GitHubSyncService.ReimportAtCommit uses exactly that number to decide whether to advance the sync baseline LastSyncedAt, with a comment stating the invariant: a preserving import leaves the mesh ahead of the repo, so advancing the baseline would move it past the very node two-way just protected. Because a kept-not-pruned node reported Preserved = 0, the baseline advanced past it and the protection (node.LastModified > LastSyncedAt) silently disarmed. The NEXT import that actually runs prunes the node — the user's content is deleted. This surfaced as a CI flake (TwoWaySyncTest.TwoWay_Update_KeepsServerAddition_ UnlessForced, run 30250682456 shard 5, WaitForNode timeout at :51). The test usually passes only because the second update short-circuits on the unchanged content fingerprint (Skipped, so no prune runs). That short-circuit reads an eventually consistent query — the importer itself documents a stale miss as "wasteful, not wrong". Under CI load the read misses, the second update really imports, prune runs against the advanced baseline, and the node is gone. So the flake is a real data-loss bug whose usual concealment is a cache-ish short-circuit. Fix: kept-not-pruned nodes count as Preserved and get a "↩ Kept …" line in the import activity, exactly like kept-not-overwritten ones. Only GitSync constructs a two-way policy and Preserved has exactly one consumer, so git-first, boot and InstanceSync imports are bit-for-bit unchanged. Evidence. Pre-fix, instrumented, the failing test's own sequence: baseline=…30.4931640 serverOnly.LastModified=…30.5879100 protected (T1 > T0) update1 outcome=Imported preserved=0 baseline=…30.8564380 baseline jumped PAST T1 update2 outcome=Skipped -> ServerOnly survives (passes by luck) With the short-circuit disabled (activity marker dropped = the CI query miss), pre-fix: update2 outcome=Imported -> ServerOnly absent = True, i.e. the CI failure reproduced exactly. Post-fix 3/3: update1 preserved=1 with the baseline unchanged, update2 preserved=2, ServerOnly absent = False. New test TwoWay_ServerAddition_SurvivesEveryLaterRepoPush makes every update import a genuinely new commit so prune runs for real each time, and asserts the invariant directly (Preserved > 0, LastSyncedAt unchanged) rather than only the symptom. Unfixed it fails 3/3 in ~0.7s; fixed it passes. GitSync.Test 142/142 ×3, Graph.Test 633/633, Content.Test 249+1 skipped, full solution Release/-warnaserror clean. What this does NOT establish: the original test passes locally both before and after — locally the short-circuit always engages, so it never reproduced untouched. Green runs of it prove nothing; the load-bearing evidence is the deterministic new test plus the marker-drop emulation. Behaviour change to weigh: freezing the baseline does refuse some later repo edits. Measured — with a server addition pending, a repo edit to Welcome applies (v2), but a second edit (v3) is preserved instead of applied, because the import itself re-stamped Welcome.LastModified past the frozen horizon so it looks "server-newer" though no user touched it. This false-conflict is inherent to the existing horizon design (it already occurred for upsert-preserved nodes); this change makes it reachable more often. It is not data loss — v3 stays in git and the activity says "↩ Kept …" — but a subsequent commit-back mirrors mesh state and would revert v3 in the repo, visible in the diff. Clean follow-up: record per-node import write-times so "server-newer" means "changed since the import wrote it" rather than "changed since a global timestamp". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a two-way GitSync data-loss scenario where a server-side addition could be silently deleted on a later sync cycle because the prune-side “kept” path wasn’t counted as Preserved, allowing the sync baseline (LastSyncedAt) to advance past the protected node.
Changes:
- Count “kept-not-pruned” nodes as
PreservedinStaticRepoImporterand include them in the import summary/result. - Add a deterministic regression test that forces real imports across new commits to ensure prune executes and the baseline invariant holds.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/MeshWeaver.GitSync.Test/TwoWaySyncTest.cs | Adds a deterministic two-way sync regression test covering server-side additions across multiple repo commits. |
| src/MeshWeaver.Graph/StaticRepoImporter.cs | Counts prune-side preserved nodes in Preserved and surfaces them in activity/log summary and the import result. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
871
to
874
| if (policy is { PreserveServerNewer: true, Force: false, Since: { } pruneSince }) | ||
| { | ||
| keptFromPrune = toPrune.Where(n => n.LastModified > pruneSince).ToArray(); | ||
| toPrune = toPrune.Where(n => n.LastModified <= pruneSince).ToArray(); |
Comment on lines
+877
to
+882
| logger?.LogInformation( | ||
| "[StaticRepoImport] {Partition}: two-way — keeping server-added {Path} (not pruned).", | ||
| source.Partition, kept.Path); | ||
| NodeTypeCompilationActivity.AppendLog(hub, activityPath, | ||
| $"↩ Kept {kept.Path} (added on the server — commit to sync it back).", logger!); | ||
| } |
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
rbuergi
added a commit
that referenced
this pull request
Aug 7, 2026
…l prune protects and names server additions Fixes #677, fixes #604 — the two remaining routes to GitSync server-side data loss after #675. #677: ReimportAtCommit advanced the sync baseline via RecordLastSync (both LastSyncedAt AND LastSyncCommitSha) whenever Preserved == 0 — including the fingerprint no-op ('Skipped'). LastSyncedAt is the conflict horizon behind PreservesServerCopyOf; advancing it without reconciling disarmed the protection, so a LATER push pruned server-side additions. The fields are now split by meaning: a Skipped update records only LastSyncCommitSha (the 'seen this commit' marker — up-to-date display and git-diff base stay correct, a node-free repo commit doesn't leave the space 'behind'); only an import that actually reconciled records the horizon. Diff-scope and unchanged-token skips now count a server-newer node as Preserved (the server-edited-node-whose-file-didn't-change shape). #604: the prune loop logged paths only on FAILURE and the summary carried a bare count — a successful prune was untraceable. And prune protection required TwoWay; a Bidirectional space without TwoWay inherited FullReplace and mirror-pruned uncommitted server additions. ImportConflictPolicy gains PreserveServerAdditions (prune-only protection, on for Bidirectional, reusing #675's kept-from-prune machinery); every prune logs its path and the activity summary names all pruned paths. ImportOnly mirrors keep FullReplace semantics — pinned by a guard test. Fail-without evidence (pre-fix src, new tests): horizon moved on a Skipped update; Bidirectional server addition pruned; no 'Pruned' activity line — 3 failed / mirror-guard passed. With the fix: GitSync.Test 157/157, Graph.Test 820/820, Release -warnaserror clean incl. the Memex.Portal.Monolith umbrella. Closes #677 Closes #604 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Sep 7, 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.
TwoWaySyncTest.TwoWay_Update_KeepsServerAddition_UnlessForcedwas on the flake list. It is not a flake — GitSync silently deletes a server-side addition one sync cycle later.Mechanism
Two-way sync protects a server-side change two ways: it refuses to overwrite it (upsert conflict) and it refuses to prune it (the repo has no copy — a server-side addition). Only the first path incremented
StaticRepoImportResult.Preserved; the prune path filtered the node out oftoPruneand reported nothing.GitHubSyncService.ReimportAtCommituses exactly that number to decide whether to advance the sync baselineLastSyncedAt, and its comment states the invariant: a preserving import leaves the mesh ahead of the repo, so advancing the baseline would move it past the very node two-way just protected. A kept-not-pruned node reportedPreserved = 0, so the baseline advanced past it and the protection (node.LastModified > LastSyncedAt) silently disarmed. The next import that actually runs prunes the node.Why it looked like a flake: the test usually passes only because the second update short-circuits on the unchanged content fingerprint (
Skipped, so prune never runs). That short-circuit reads an eventually-consistent query — the importer itself documents a stale miss as "wasteful, not wrong". Under CI load the read misses, the second update really imports, prune runs against the advanced baseline, and the node is gone (run30250682456, shard 5,WaitForNodetimeout atTwoWaySyncTest.cs:51). A cache-ish short-circuit was concealing a data-loss bug.Evidence
Pre-fix, instrumented, the failing test's own sequence:
With the short-circuit disabled (activity marker dropped — i.e. the CI query miss), pre-fix:
update2 outcome=Imported → ServerOnly absent = True, reproducing the CI failure exactly. Post-fix, 3/3:update1 preserved=1, baseline unchanged;update2 preserved=2;ServerOnly absent = False.New test
TwoWay_ServerAddition_SurvivesEveryLaterRepoPushmakes every update import a genuinely new commit so prune runs for real each time, and asserts the invariant (Preserved > 0,LastSyncedAtunchanged) rather than only the symptom. Unfixed it fails 3/3 in ~0.7 s; fixed it passes.GitSync.Test142/142 ×3,Graph.Test633/633,Content.Test249 + 1 skipped, full solution-c Release -p:CIRun=true -warnaserrorclean.What this does not establish: the original test passes locally both before and after — locally the short-circuit always engages, so it never reproduced untouched. Green runs of it prove nothing; the load-bearing evidence is the deterministic new test plus the marker-drop emulation.
Freezing the baseline does refuse some later repo edits. Measured: with a server addition pending, a repo edit to
Welcomeapplies (v2), but a second edit (v3) is preserved instead of applied — because the import itself re-stampedWelcome.LastModifiedpast the frozen horizon, so it looks "server-newer" though no user touched it.This false-conflict is inherent to the existing horizon design (it already occurred for upsert-preserved nodes); this change makes it reachable more often. It is not data loss — v3 stays in git and the import activity says
↩ Kept …— but a subsequent commit-back mirrors mesh state and would revert v3 in the repo, visible in the diff.Clean follow-up: record per-node import write-times in the manifest so "server-newer" means "changed since the import wrote it" rather than "changed since a global timestamp".
Found, not fixed — same defect, second live route
The fingerprint short-circuit returns
Preserved = 0, so a no-op "Update to latest" records last sync and advances the horizon past pending uncommitted changes although it verified nothing (pre-fix probe:update2 outcome=Skipped,LastSyncedAtmoved …30.8564380 → …30.8765430). The git-diff scope skip has the same shape for a server-edited node whose repo file didn't change. Both can still lose a server addition on a later push.The honest fix is to split the two fields —
LastSyncCommitShais safe to advance,LastSyncedAt(the conflict horizon) is not — but that changes the meaning of a persisted field and affects "are we up to date?" (a repo commit touching no node files would otherwise leave the space permanently "behind"). Left as a maintainer decision rather than bundled into this fix.No What's New entry — silent data-loss fix; no user-visible behaviour change when sync is working.
🤖 Generated with Claude Code