Skip to content

Add copy.on_dest_newer conflict resolution (default: skip) - #74

Open
srhoods wants to merge 3 commits into
masterfrom
copy-on-dest-newer
Open

Add copy.on_dest_newer conflict resolution (default: skip)#74
srhoods wants to merge 3 commits into
masterfrom
copy-on-dest-newer

Conversation

@srhoods

@srhoods srhoods commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • New job-spec option copy.on_dest_newer: skip (default) | overwrite, resolving a gap where drsync always overwrote the destination on any mtime disagreement, regardless of direction — correct for a one-directional migration, but silently destructive for a dataset-merge scenario where the destination was edited out-of-band and is now newer.
  • skip (new default): a destination file strictly newer than the source is left completely untouched (no copy, no metadata fixup) and recorded as JR_SKIPPED_NEWER — every occurrence, not sampled, since it's a real conflict an operator needs to audit.
  • overwrite: restores the exact pre-existing behavior (source always wins) for a strict one-directional mirror.
  • A skipped file emits neither JR_COPIED nor JR_META_FIXED, so it's naturally invisible to VERIFY (seedVerify only seeds from those two record types) — no risk of VERIFY's own recopy-on-mismatch undoing the skip.
  • Threaded end to end: proto (ConflictPolicy enum, JR_SKIPPED_NEWER, skipped_newer counter) → spec.go (default/validate/encode) → SQLite schema+migration+accumulation → API/CLI/WebUI surfacing → agent decode → walker.c's diff predicate.

Bug caught along the way

Testing the agent-side decode surfaced a real mixed-fleet safety hole before it shipped: pb_put_msg omits an entirely zero-valued CopyOptions submessage from the wire rather than sending an empty one, so an old coordinator's JobOptions never reaches dec_copy_opts's field loop at all. Setting the skip default only inside dec_copy_opts left that case silently resolving to overwrite — exactly backwards from the intended mixed-fleet safety. Fixed by pre-setting the default in dec_job_options itself, before any field parsing.

Test plan

  • gofmt -l . / go vet ./... clean
  • go test -count=1 ./... — all green, including new spec tests (default, explicit overwrite, invalid-value rejection)
  • make -C agent test — all green, including a new C unit test (on_dest_newer_test.c) driving the real dec_work_grant -> dec_job_options -> dec_copy_opts decode path across absent-field / CONFLICT_UNSPECIFIED / _SKIP / _OVERWRITE — this test caught the mixed-fleet bug above before it was fixed
  • New e2e script (test/on_dest_newer_e2e.sh, added to the CI matrix) covering dest-newer-skip, source-newer-still-copies, an unchanged file staying clean, and explicit overwrite — run locally end to end, and falsified (temporarily disabled the walker-side check, confirmed the e2e fails for the expected reason, restored)

🤖 Generated with Claude Code

https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm

srhoods and others added 3 commits August 20, 2026 14:45
drsync always overwrote the destination when source and destination
mtimes disagreed, regardless of direction (times_equal is an unsigned
|diff| check) — correct for a one-directional migration, but a
destination edited out-of-band during a dataset merge would be
silently clobbered on the next pass with no way to protect it short of
excluding the path entirely.

copy.on_dest_newer (proto CopyOptions.ConflictPolicy) adds a
direction-aware check ahead of the normal diff:
  - skip (new default): a destination file strictly newer than the
    source (beyond mtime_slop_ns) is left completely untouched - no
    copy, no owner/mode/xattr fixup either - and recorded as
    JR_SKIPPED_NEWER (own counter, not sampled: every occurrence is a
    real conflict an operator needs to audit).
  - overwrite: the pre-existing behavior, source always wins
    regardless of direction. The right choice for a strict
    one-directional mirror; opt in explicitly.

A skipped file emits neither JR_COPIED nor JR_META_FIXED, so it's
naturally invisible to VERIFY (passctrl.seedVerify seeds only from
those two record types) - no change needed there, and no risk of
VERIFY's own recopy-on-mismatch silently undoing the skip.

Threaded end to end: proto (new ConflictPolicy enum, JR_SKIPPED_NEWER
journal type, skipped_newer counter) -> spec.go (default/validate/
encode) -> SQLite schema+migration+accumulation -> API/CLI/WebUI
surfacing -> agent decode -> walker.c's diff predicate.

Caught a real mixed-fleet safety bug while testing the agent decode:
pb_put_msg omits an entirely zero-valued CopyOptions submessage from
the wire rather than sending an empty one, so an old coordinator's
JobOptions (or any JobOptions whose CopyOptions all happens to be at
proto zero values) never reaches dec_copy_opts's field loop at all -
setting the skip default only there left exactly that case silently
resolving to overwrite. Fixed by pre-setting the default in
dec_job_options itself, before any field parsing.

Tests: Go spec tests (default/explicit-overwrite/invalid-value), a new
C unit test driving the real wire-decode path (which caught the bug
above before it shipped), and a full e2e script covering dest-newer
skip, source-newer still copying, an unchanged file staying clean, and
explicit overwrite - added to the CI matrix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm
scale_e2e.sh seeds $DST/bigdir/f0001.txt with unbackdated (now) content
to test that a stale destination file gets replaced. With
copy.on_dest_newer now defaulting to skip, that destination write
landing after the source's own write (moments earlier in the same
setup loop) made the destination read as newer — so the file was
correctly left alone by the new default, not overwritten, and the
test's content-match assertion failed.

Backdate the destination fixture to 2000-01-01, matching the existing
precedent in direct_write_e2e.sh's own stale-destination fixture. This
is a real interaction with the new default, not a bug in it: caught by
CI on the copy.on_dest_newer PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm
JOB_TEMPLATE (webui/console.html) is a separately-maintained copy of
template.yaml for the WebUI's "new job" editor and had drifted: it was
missing copy.on_dest_newer entirely, so a job created from the WebUI
template would fall back to the coordinator's own default (correct,
since it's unset) but without the operator ever seeing the option or
its explanation the way template.yaml readers do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsdNZLfmAFrMX2VUtkLtmm
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