From 8499d787cd5a14d19e5d678da86a4b6c4a049f26 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 12 Nov 2026 17:12:34 -0800 Subject: [PATCH] mount: refuse to merge a whole-file rewrite over a peer's chunk change maybeMergeChunks re-uploads the handle's whole view as unstamped chunks. Merged after a peer's change, they tie with a peer's own rewrite or bring back a range the peer truncated, so that flush fails as before. Co-Authored-By: Claude Opus 5.5 --- weed/mount/content_flush_condition_test.go | 9 +++++++++ weed/mount/weedfs_file_sync.go | 15 +++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/weed/mount/content_flush_condition_test.go b/weed/mount/content_flush_condition_test.go index 7e1e043917..565fce30df 100644 --- a/weed/mount/content_flush_condition_test.go +++ b/weed/mount/content_flush_condition_test.go @@ -308,6 +308,15 @@ func TestMergeContentEntry(t *testing.T) { t.Fatal("merged into a replaced file") } + rewritten := proto.Clone(base).(*filer_pb.Entry) + rewritten.Chunks = []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: 8, Cookie: 1}, Size: 2}} + if _, ok := mergeContentEntry(stored, base, rewritten, rewritten); ok { + t.Fatal("merged a stale whole-file rewrite over a peer append") + } + if _, ok := mergeContentEntry(proto.Clone(base).(*filer_pb.Entry), base, rewritten, rewritten); !ok { + t.Fatal("refused a rewrite with no peer chunk change") + } + empty := flushConditionEntry() inline := proto.Clone(empty).(*filer_pb.Entry) inline.Content = []byte("peer") diff --git a/weed/mount/weedfs_file_sync.go b/weed/mount/weedfs_file_sync.go index df764347af..9b468d45a9 100644 --- a/weed/mount/weedfs_file_sync.go +++ b/weed/mount/weedfs_file_sync.go @@ -373,9 +373,10 @@ const maxContentFlushMerges = 3 // content it changed, taking those values from sent (local in stored's id // space). Everything else keeps the stored value. A local truncation keeps // its size and clips stored chunks to it; otherwise the larger size wins. -// A different file at the path, inline content changed on both sides, or -// inline content next to chunks (reads stop at the inline bytes) cannot be -// merged. +// A different file at the path, inline content changed on both sides, +// inline content next to chunks (reads stop at the inline bytes), or a local +// whole-file rewrite (maybeMergeChunks, unstamped chunks) over a peer's chunk +// change cannot be merged: the rewrite carries the handle's stale view. func mergeContentEntry(stored, base, local, sent *filer_pb.Entry) (*filer_pb.Entry, bool) { if stored == nil || base == nil || stored.IsDirectory || !bytes.Equal(stored.HardLinkId, base.HardLinkId) { return nil, false @@ -400,14 +401,20 @@ func mergeContentEntry(stored, base, local, sent *filer_pb.Entry) (*filer_pb.Ent for _, c := range local.Chunks { inLocal[contentChunkKey(c)] = true } + peerChanged := len(stored.Chunks) != len(base.Chunks) merged.Chunks = merged.Chunks[:0] for _, c := range stored.Chunks { - if key := contentChunkKey(c); !inBase[key] || inLocal[key] { + key := contentChunkKey(c) + peerChanged = peerChanged || !inBase[key] + if !inBase[key] || inLocal[key] { merged.Chunks = append(merged.Chunks, proto.Clone(c).(*filer_pb.FileChunk)) } } for _, c := range local.Chunks { if !inBase[contentChunkKey(c)] { + if peerChanged && c.ModifiedTsNs == 0 && !c.IsChunkManifest { + return nil, false + } merged.Chunks = append(merged.Chunks, proto.Clone(c).(*filer_pb.FileChunk)) } }