Просмотр исходного кода

fix: allow deleted files to win conflict resolution (#10207)

We've always, since the introduction of conflicts, had the policy that
deletes lose against any other change, for safety's sake. This is a
problem, however, because it means the sort order of versions is not a
total order.

That is, given two versions `A` and `B` that are currently in conflict,
we will sort them in a given order (let's say `A, B`, so `A < B` for
ordering purposes: we say "A wins over B" or "A is newer than B") and
consider the first in the list the winner. The loser (who has `B` on
disk) will process the conflict at some point and move the file to a
conflict copy and announce `A'` as the resolved conflict. The winner
(with `A` on disk) doesn't do anything.

However, if `A` is deleted the ordering changes. We still have `A < B`
and, of course, `Adel < A` (this is not even a conflict, just linear
order). In most sane systems this would imply the ordering `Adel < A <
B`, however in our case we in fact have `B < Adel` because any version
wins over a deleted one, so there is no logical ordering at all of the
files at this point. `Adel < A < B < Adel ???` In practice the deleted
version may end up at the head or the tail of the list, depending on the
order we do the compares.

Hence, at this point, "whatever" happens and it's not guaranteed to make
any sense. 😬

I propose that we resolve this my simply letting deletes be versions
like anything else and maintain a total ordering based on just version
vectors with the existing tie breakers like always. That means a delete
can win in a conflict situation, and the result should be that the file
is moved to a conflict copy on the losing device. I think this retains
the data safety to almost the same degree as previously, while removing
probably an entire class of strange out of sync bugs...

---

(A potential wrinkle here is that, ideally, we wouldn't even create the
conflict copy when the delete and the losing version represent the same
data -- same as when we handle normal modification conflicts. However,
the deleted FileInfo doesn't carry any information on what the contents
were, so we can't do that right now. A possible future extension would
be to carry the block list hash of the deleted data in the deleted
FileInfo and use that for this purpose, but I don't want to complicate
this PR with that. The block list hash itself also isn't a
protocol-defined thing at the moment, it's something implementation
dependent that we just use locally.)
Jakob Borg 5 месяцев назад
Родитель
Сommit
7c07610ab2

+ 56 - 0
internal/db/sqlite/db_global_test.go

@@ -568,3 +568,59 @@ func TestNeedPagination(t *testing.T) {
 		t.Error("bad need")
 	}
 }
+
+func TestDeletedAfterConflict(t *testing.T) {
+	t.Parallel()
+
+	// A delete that comes after a conflict should be applied, not lose the
+	// conflict and suddenly cause an old conflict version to become
+	// promoted.
+
+	// 	D:\syncthing-windows-amd64-v2.0.0-rc.22.dev.11.gff88430e>syncthing --home=c:\PortableApp\SyncTrayzorPortable-x64\data\syncthing debug database-file tnhbr-gxtuf TreeSizeFreeSetup.exe
+	// DEVICE   TYPE  NAME                   SEQUENCE  DELETED  MODIFIED                      SIZE      FLAGS    VERSION                                BLOCKLIST
+	// -local-  FILE  TreeSizeFreeSetup.exe  499       del      2025-07-04T11:52:36.2804841Z  0         -------  HZJYWFM:1751507473,OMKHRPB:1751629956  -nil-
+	// J5WNYJ6  FILE  TreeSizeFreeSetup.exe  500       del      2025-07-04T11:52:36.2804841Z  0         -------  HZJYWFM:1751507473,OMKHRPB:1751629956  -nil-
+	// 23NHXGS  FILE  TreeSizeFreeSetup.exe  445       ---      2025-06-23T03:16:10.2804841Z  13832808  -nG----  HZJYWFM:1751507473                     7B4kLitF
+	// JKX6ZDN  FILE  TreeSizeFreeSetup.exe  320       ---      2025-06-23T03:16:10.2804841Z  13832808  -------  JKX6ZDN:1750992570                     7B4kLitF
+
+	db, err := OpenTemp()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() {
+		if err := db.Close(); err != nil {
+			t.Fatal(err)
+		}
+	})
+
+	// A file, updated by some remote device. This file is an old, conflicted copy.
+	file := genFile("test1", 1, 101)
+	file.ModifiedS = 1750992570
+	file.Version = protocol.Vector{Counters: []protocol.Counter{{ID: 5 << 60, Value: 1750992570}}}
+	if err := db.Update(folderID, protocol.DeviceID{5}, []protocol.FileInfo{file}); err != nil {
+		t.Fatal(err)
+	}
+
+	// The file, updated by a newer remote device. This file is the newer, conflict-winning copy.
+	file.ModifiedS = 1751507473
+	file.Version = protocol.Vector{Counters: []protocol.Counter{{ID: 2 << 60, Value: 1751507473}}}
+	if err := db.Update(folderID, protocol.DeviceID{2}, []protocol.FileInfo{file}); err != nil {
+		t.Fatal(err)
+	}
+
+	// The file, deleted locally after syncing the file from the remote above..
+	file.SetDeleted(4)
+	if err := db.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{file}); err != nil {
+		t.Fatal(err)
+	}
+
+	// The delete should be the global version
+	f, _, err := db.GetGlobalFile(folderID, "test1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !f.IsDeleted() {
+		t.Log(f)
+		t.Error("should be deleted")
+	}
+}

+ 0 - 6
internal/db/sqlite/folderdb_update.go

@@ -458,12 +458,6 @@ func (e fileRow) Compare(other fileRow) int {
 			}
 			return -1 // they are invalid, we win
 		}
-		if e.Deleted != other.Deleted {
-			if e.Deleted { // we are deleted, we lose
-				return 1
-			}
-			return -1 // they are deleted, we win
-		}
 		if d := cmp.Compare(e.Modified, other.Modified); d != 0 {
 			return -d // positive d means we were newer, so we win (negative return)
 		}

+ 12 - 10
lib/model/folder_sendrecv.go

@@ -896,18 +896,20 @@ func (f *sendReceiveFolder) deleteFileWithCurrent(file, cur protocol.FileInfo, h
 		return
 	}
 
-	if f.inConflict(cur.Version, file.Version) {
-		// There is a conflict here, which shouldn't happen as deletions
-		// always lose. Merge the version vector of the file we have
-		// locally and commit it to db to resolve the conflict.
-		cur.Version = cur.Version.Merge(file.Version)
-		dbUpdateChan <- dbUpdateJob{cur, dbUpdateHandleFile}
-		return
-	}
+	switch {
+	case f.inConflict(cur.Version, file.Version) && !cur.IsSymlink():
+		// If the delete constitutes winning a conflict, we move the file to
+		// a conflict copy instead of doing the delete
+		err = f.inWritableDir(func(name string) error {
+			return f.moveForConflict(name, file.ModifiedBy.String(), scanChan)
+		}, cur.Name)
 
-	if f.versioner != nil && !cur.IsSymlink() {
+	case f.versioner != nil && !cur.IsSymlink():
+		// If we have a versioner, use that to move the file away
 		err = f.inWritableDir(f.versioner.Archive, file.Name)
-	} else {
+
+	default:
+		// Delete the file
 		err = f.inWritableDir(f.mtimefs.Remove, file.Name)
 	}
 

+ 17 - 6
lib/model/requests_test.go

@@ -903,14 +903,25 @@ func TestRequestDeleteChanged(t *testing.T) {
 		t.Fatal("timed out")
 	}
 
-	// Check outcome
-	if _, err := tfs.Lstat(a); err != nil {
-		if fs.IsNotExist(err) {
-			t.Error(`Modified file "a" was removed`)
-		} else {
-			t.Error(`Error stating file "a":`, err)
+	// Check outcome. The file may have been moved to a conflict copy.
+	remains := false
+	files, err := tfs.Glob("a*")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, file := range files {
+		if file == "a" {
+			remains = true
+			break
+		}
+		if strings.HasPrefix(file, "a.sync-conflict-") {
+			remains = true
+			break
 		}
 	}
+	if !remains {
+		t.Error(`Modified file "a" was removed`)
+	}
 }
 
 func TestNeedFolderFiles(t *testing.T) {

+ 0 - 9
lib/protocol/bep_fileinfo.go

@@ -200,15 +200,6 @@ func (f *FileInfo) WinsConflict(other FileInfo) bool {
 		return !f.IsInvalid()
 	}
 
-	// If a modification is in conflict with a delete, we pick the
-	// modification.
-	if !f.IsDeleted() && other.IsDeleted() {
-		return true
-	}
-	if f.IsDeleted() && !other.IsDeleted() {
-		return false
-	}
-
 	// The one with the newer modification time wins.
 	if f.ModTime().After(other.ModTime()) {
 		return true

+ 1 - 1
lib/protocol/conflict_test.go

@@ -14,7 +14,7 @@ func TestWinsConflict(t *testing.T) {
 	testcases := [][2]FileInfo{
 		// The first should always win over the second
 		{{ModifiedS: 42}, {ModifiedS: 41}},
-		{{ModifiedS: 41}, {ModifiedS: 42, Deleted: true}},
+		{{ModifiedS: 42, Deleted: true}, {ModifiedS: 41}},
 		{{Deleted: true}, {ModifiedS: 10, LocalFlags: FlagLocalRemoteInvalid}},
 		{{ModifiedS: 41, Version: Vector{Counters: []Counter{{ID: 42, Value: 2}, {ID: 43, Value: 1}}}}, {ModifiedS: 41, Version: Vector{Counters: []Counter{{ID: 42, Value: 1}, {ID: 43, Value: 2}}}}},
 	}

+ 4 - 0
relnotes/v2.0.md

@@ -36,3 +36,7 @@
   - netbsd/*
   - openbsd/386 and openbsd/arm
   - windows/arm
+
+- The handling of conflict resolution involving deleted files has changed. A
+  delete can now be the winning outcome of conflict resolution, resulting in
+  the deleted file being moved to a conflict copy.