Skip to content

bisync: make max-delete aware of tracked renames - #9812

Merged
ncw merged 2 commits into
rclone:masterfrom
fzlzjerry:fix/8685-max-delete-renames
Sep 30, 2026
Merged

ncw merged 2 commits into
rclone:masterfrom
fzlzjerry:fix/8685-max-delete-renames

Conversation

@fzlzjerry

Copy link
Copy Markdown
Contributor

What does this change do?

Adds an opt-in --max-delete-renames-aware preflight for Bisync. When the raw deletion count would exceed --max-delete, Bisync can now use the configured track-renames strategy to subtract only guaranteed one-sided rename matches before deciding whether to abort.

The change also:

  • extracts the existing sync rename matcher into fs/sync/trackrenames so Bisync and sync use the same size/hash/leaf/modtime semantics;
  • keeps the default behavior unchanged unless both --track-renames and --max-delete-renames-aware are set;
  • retains the safety abort for unsupported capabilities, ambiguous matches, and remaining unmatched deletions;
  • adds CLI/RC documentation and a bidirectional golden regression covering the opt-in requirement, the unchanged default abort, successful Path1 and Path2 directory renames, and a partial-match abort.

Linked issue

Fixes #8685

For new or changed backends

Not applicable; this does not change a backend.

Validation

  • go build
  • RCLONE_CONFIG=/notfound go test ./fs/sync/... ./cmd/bisync -count=1
  • go test -race ./fs/sync/trackrenames
  • go test -race ./fs/sync -run '^TestSyncWithTrackRenames'
  • RCLONE_CONFIG=/notfound go test -race ./cmd/bisync -run '^TestBisyncRemoteRemote$' -remote local -case max_delete_track_renames
  • non-root full package run passed except cmd/mount, cmd/mount2, and cmd/serve/nfs; the local container has no /dev/fuse, and its overlay filesystem does not support the NFS name-to-handle operation. Those three packages were compile-checked with go test -run '^$'.
  • golangci-lint run --new-from-rev=upstream/master ./...
  • git diff --check

Checklist

  • This change is trivial OR it has been discussed and agreed in the linked issue.
  • I have read the contribution guidelines.
  • (If I used AI tools to help write this code) I have read and understood the AI-assisted contributions guidance, and I have tested and take ownership of this change myself.
  • I have added tests for all changes in this PR if appropriate.
  • I have added documentation for the changes if appropriate.
  • All commit messages are in house style.
  • (Backend changes only) Not applicable; this does not change a backend.
  • This Pull Request is ready for review.

Copilot AI lite review requested due to automatic review settings August 24, 2026 16:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ncw

ncw commented Aug 26, 2026

Copy link
Copy Markdown
Member

I think this needs your expert eyes @nielash :-)

@nielash

nielash commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

I think this needs your expert eyes @nielash :-)

I'm on it. 👍 Apologies in advance if it takes me a few days to get to it!

@nielash nielash left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is off to a good start, thank you! Some thoughts on the design:

  1. The new b.march.objects1 and b.march.objects2 retain an fs.Object for every file on both paths for the whole run. That's a significant memory cost for large runs, and I'm not sure it's necessary, given that we have a lighter option in plain sight: b.march.ls1 and ls2 (*fileList) already store the file's size and modtime, and have a hash field we simply aren't populating by default -- all the components needed for the renames strategy analysis.

    objects1 and objects2 exist only because the preflight needs hashes the listing doesn't carry -- the easier fix is to make the listings carry them. I'd suggest deleting the changes in march.go and instead, forcing a common hash into both listings in compare.go. setHashType's early-return branch already forces a common hash -- it's just a matter of making sure we end up there when --max-delete-renames-aware is in use with --track-renames-strategy hash, and reconciling conflicting flags (--ignore-listing-checksum) with appropriate ignore / error logic (see setCompareDefaults for examples). Note that the preflight's strategy must be equal to or stronger than what the sync will actually use -- so when in doubt, it's safer to error or disable, not fall back to a looser strategy.

    Since CountGuaranteedMatches is not currently used by sync, we could change the function signature without changing anything in sync. Currently you have:

func (m *Matcher) CountGuaranteedMatches(sources []fs.Object) int

Consider instead something like:

func CountGuaranteedMatches(strategy Strategy, modifyWindow time.Duration, srcs, dsts []Candidate) int

where Candidate is a struct like:

type Candidate struct {
    Remote  string
    Size    int64
    ModTime time.Time
    Hash    string
}
  1. An invalid --track-renames-strategy (say, --track-renames-strategy banana) fails late, if at all. That's because trackrenames.ParseStrategy is called only in the event that someone exceeds the delete threshold. I'd move that check up to where your existing if opt.MaxDeleteRenamesAware && !ci.TrackRenames check is in Bisync().

  2. I like that it's gated behind if !opt.Force, but a consequence of this is that with --force --max-delete-renames-aware, it still does all of the setup but never reads it. Under the change I'm proposing in point 1, this still matters, as we'd be storing (perhaps even calculating) lots of hashes we might never need. I'd suggest checking for --force early and skipping most of the --max-delete-renames-aware logic when true.

  3. trackedRenameExemptionsForPath duplicates many of the checks from fs/sync/sync.go:240-260 (CanServerSideMove, strategy.UsesHash(), etc.), which risks the requirements diverging over time. I'd suggest refactoring these checks to one function in the fs/sync/trackrenames library, which both sync and bisync will call.

  4. Rather than threading an exemptions int parameter through effectiveDeletes / exceedsDeletes / excessDeletes, I'd suggest making it a field on deltaSet alongside deleted and oldCount. That also lets excessDeletes() keep its original zero-argument signature and shrinks the deltas.go diff.
    Housekeeping:

  5. cmd/bisync/rc.md is autogenerated and should not be edited (your edits would eventually get overwritten anyway)

  6. The refactoring from fs/sync/sync.go to fs/sync/trackrenames/trackrenames.go should be its own self-contained, behavior-preserving commit with no bisync changes. The bisync changes should then be a separate commit on top of that.

  7. Both commits should have a commit message in the house style (see CONTRIBUTING.md)

Overall design note: as much as possible, try to keep the --track-renames logic in fs/sync/trackrenames instead of duplicating it in sync and bisync, and try to keep the bisync-specific stuff in cmd/bisync/trackrenames.go when possible -- there's a maintenance cost to having to reason with this code in lots of key parts of bisync in the future, rather than reasoning with it once now in a separate file we shouldn't have to revisit much.

Thank you!

@fzlzjerry

Copy link
Copy Markdown
Contributor Author

Thanks, @nielash — I reworked this around the design you suggested.

  • The first commit is now a behavior-preserving extraction of strategy parsing, capability checks, and matching into fs/sync/trackrenames. It adds the lightweight Candidate API and CountGuaranteedMatches; sync still uses the object-backed matcher.
  • The bisync commit no longer retains fs.Object maps. It builds candidates from the existing listings, forces a common listing hash for hash-based matching, and rejects incompatible checksum/slow-hash settings rather than weakening the preflight.
  • Strategy validation now happens at bisync startup. --force skips the preflight and the extra hash/modtime collection.
  • Sync and bisync share the capability checks. Rename exemptions now live on deltaSet, with the zero-argument delete-check methods restored.
  • The generated cmd/bisync/rc.md change is gone, and the history is split into two house-style commits.

I also expanded the bisync scenario to cover both directions, invalid/conflicting options, the force path, and a partial-match case that must still abort. The focused bisync scenario, fs/sync/trackrenames and fs/sync tests, go build, targeted go vet, and changed-code golangci-lint all pass locally.

@nielash nielash left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice job with the revisions! It's very close now. See my comments inline.

Comment thread cmd/bisync/bisync_test.go Outdated
Comment thread cmd/bisync/bisync_test.go
Comment thread cmd/bisync/trackrenames.go Outdated
Comment thread cmd/bisync/trackrenames.go Outdated
Comment thread cmd/bisync/trackrenames.go Outdated
Comment thread cmd/bisync/testdata/test_max_delete_track_renames/scenario.txt Outdated
Comment thread fs/sync/trackrenames/trackrenames.go
Comment thread docs/content/bisync.md
Comment thread cmd/bisync/testdata/test_max_delete_track_renames/scenario.txt Outdated

@nielash nielash left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, thanks! I have just one inline comment here, and it's an easy fix 🙂

Comment thread cmd/bisync/bisync_test.go

@nielash nielash left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Thanks again for your work on this 🙂

(The lint CI failure is an unrelated issue already fixed on master)

@nielash nielash mentioned this pull request Sep 30, 2026
8 tasks done
Move strategy parsing, capability checks, and matcher behavior into a
reusable package without changing sync behavior. Add a lightweight
candidate API so callers can count guaranteed matches without
retaining filesystem objects.
Add an opt-in preflight that excludes only guaranteed tracked renames
from the max-delete count. Reuse normal bisync listing metadata,
validate strategy and flag conflicts early, and skip preflight work
when --force bypasses the safety check.

The max_delete_track_renames scenario uses the default hash
track-renames strategy, which requires a hash common to both paths.
Skip it on remote combinations without one, such as crypt, instead of
failing the integration tests.

Fixes rclone#8685
@ncw
ncw force-pushed the fix/8685-max-delete-renames branch from 772eceb to 62b691d Compare September 30, 2026 16:11
@ncw

ncw commented Sep 30, 2026

Copy link
Copy Markdown
Member

I rebased this and squashed into 2 commits - will merge when CI goes green.

@ncw
ncw merged commit 49158b2 into rclone:master Sep 30, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Bisync --max-delete behavior with --track-renames enabled

4 participants