Skip to content

Preserve local changes when the server reports a conflicting version - #504

Open
leifrossau wants to merge 1 commit into
jstaf:masterfrom
leifrossau:pr/delta-conflict-copies
Open

leifrossau wants to merge 1 commit into
jstaf:masterfrom
leifrossau:pr/delta-conflict-copies

Conversation

@leifrossau

Copy link
Copy Markdown

Depends on #502 and #503: this touches fs/upload_manager.go too and conflicts with both. I'll rebase it once they are merged (or the other way round, if you'd rather take this one first).

Problem

When the delta loop sees a newer server version of a file, applyDelta overwrites the local inode's metadata and sets hasChanges = false (right next to //TODO check if local has changes and rename the server copy if so). If the file has local edits that aren't uploaded yet (still dirty, or fsynced with the upload still queued), those edits are silently lost. Deltas are polled every 30 s, so editing the same file on two machines, or in the web UI while a local save is uploading, is enough to trigger it.

Fix

For files, the decision is now made by content hash rather than modification time. The logic lives in a small pure package, fs/conflict, so it can be unit tested without an account:

Situation Action
remote hash == local hash, or remote hash is one we uploaded ourselves skip
no local changes and no pending upload overwrite (as before), and drop stale cached content if the file isn't open
local changes and different remote content keep local, save the server version as name (conflict <host> YYYY-MM-DD HHMMSS).ext (downloaded and uploaded as a new item)
local changes, server gave no hash keep local, log a warning

Folders still just accept newer server metadata.

Supporting changes in the upload manager:

  • IsUploadPending(ids...) lets the delta goroutine ask whether an upload is queued or in flight. sessions is still only written by uploadLoop, but those writes are now guarded by an RWMutex so other goroutines can read it. The upload manager never takes an inode lock while holding it.
  • Fsync reserves its upload before clearing hasChanges, so the delta loop never sees "no local changes and no pending upload" while content is still waiting to go out.
  • The last few uploaded content hashes per item are remembered (UploadedHashes), so our own uploads coming back through the delta feed aren't treated as conflicts.
  • The overwrite re-checks hasChanges and pending uploads under the inode lock, to close the race with a concurrent write or fsync.

Testing

  • go test ./fs/conflict/ covers the decision table, the conflict-name format (extensions, dotfiles, multi-dot names, invalid hostname characters, same-second counter) and hostname sanitizing.
  • go build ./... and go vet ./... pass. I could not run the live-account suite; the existing TestDeltaContentChangeBoth should behave the same (same content while dirty → skip; clean file with different content → overwrite), but that is from reading the test, not from running it.
  • The same logic runs in my fork, where it is covered by end-to-end tests against a mock Graph server (local edit with the upload held + server-side modification → conflict copy, local content wins). The conflict path itself has not been triggered against a real account yet.

Notes for reviewers

  • Upload sessions use conflictBehavior=replace and no If-Match, so after a conflict the local version still replaces the server item as before; the server version survives as the conflict copy (and in OneDrive's version history).
  • The conflict copy is downloaded synchronously in the delta goroutine and its checksum is verified against the delta. If that fails, the error is logged and the local version is still uploaded.

🤖 Generated with Claude Code

applyDelta overwrote the local metadata and cleared hasChanges whenever the
server reported a newer version of a file, so edits that were not yet
uploaded (still dirty, or fsynced with the upload still queued) were
silently lost on the next delta poll. This is the
"TODO check if local has changes and rename the server copy if so".

File deltas are now decided by content hash instead of modification time,
using a small pure package fs/conflict (unit tested offline):

- identical content, or an echo of content we uploaded ourselves: skip
- no local changes and no pending upload: overwrite as before, and drop
  the stale cached content if the file is not open
- local changes and different remote content: keep the local version and
  save the server version as "name (conflict <host> YYYY-MM-DD HHMMSS).ext",
  downloaded and uploaded as a new item
- local changes but no remote hash to compare against: keep local

Folders keep accepting newer server metadata as before.

To tell whether an upload is pending, the delta goroutine now reads the
upload manager's state. The sessions map is still only written by the
upload loop, but writes are guarded by a new RWMutex so other goroutines
can read it. Fsync reserves its upload before clearing hasChanges, so the
delta loop cannot observe a moment with neither local changes nor a pending
upload while content is still waiting to be uploaded. The upload manager also
remembers the last few content hashes it uploaded per item, so our own
uploads coming back through the delta feed are not mistaken for conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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