From 4086e14452debc3674f851f9628408eeccbef660 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 17 Oct 2026 21:07:35 -0700 Subject: [PATCH] filer: free orphaned hard-link chunks in the replayed delete sweep The subtree sweep on a replayed directory delete skipped every hard-linked child, so a link that was the inode's last owner leaked its shared chunks. Check the inode record after the store delete; when it is gone, queue the child's chunks like any other swept entry. --- weed/filer/meta_aggregator_replay_test.go | 106 ++++++++++++++++++++++ weed/filer/meta_replay.go | 10 +- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/weed/filer/meta_aggregator_replay_test.go b/weed/filer/meta_aggregator_replay_test.go index e41c222c19..bc4972f182 100644 --- a/weed/filer/meta_aggregator_replay_test.go +++ b/weed/filer/meta_aggregator_replay_test.go @@ -878,3 +878,109 @@ func TestReplayKeptDirectoryDeleteFencesStaleCreates(t *testing.T) { t.Fatal("stale create resurrected a child under a directory the origin deleted") } } + +// A swept hard link drops the shared inode record with the entry. When the +// record is gone the sweep must free the chunks no sibling link can reach. +func TestReplayDirectoryDeleteFreesOrphanedHardLinkChunks(t *testing.T) { + store := newStubFilerStore() + f := newReplayTestFiler(store) + f.FilerConf = NewFilerConf() + f.FileIdDeletionQueue = util.NewUnboundedQueue() + ctx := context.Background() + + hardLinkId := NewHardLinkId() + inode := &Entry{ + FullPath: "/d/a.txt", + Attr: Attr{Mtime: time.Now()}, + HardLinkId: hardLinkId, + HardLinkCounter: 1, + Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}}, + } + blob, err := inode.EncodeAttributesAndChunks() + if err != nil { + t.Fatal(err) + } + if err := store.KvPut(ctx, hardLinkId, blob); err != nil { + t.Fatal(err) + } + put := func(e *Entry) { + if err := store.InsertEntry(ctx, e); err != nil { + t.Fatal(err) + } + } + put(&Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}) + put(&Entry{FullPath: "/d/a.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 1}) + + event := &filer_pb.SubscribeMetadataResponse{ + Directory: "/", + EventNotification: &filer_pb.EventNotification{ + OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true}, + DeleteChunks: true, + }, + TsNs: time.Now().UnixNano(), + } + if err := f.Replay(event); err != nil { + t.Fatalf("replay: %v", err) + } + + if _, err := store.KvGet(ctx, hardLinkId); !errors.Is(err, ErrKvNotFound) { + t.Fatalf("hard link record should be gone, got %v", err) + } + if got := f.pendingDeletionCount(); got != 1 { + t.Fatalf("pending deletions = %d, want the orphaned shared chunks queued", got) + } +} + +// A sibling link outside the swept tree keeps the inode record, so the shared +// chunks stay owned and must not be deleted. +func TestReplayDirectoryDeleteKeepsSharedHardLinkChunks(t *testing.T) { + store := newStubFilerStore() + f := newReplayTestFiler(store) + f.FilerConf = NewFilerConf() + f.FileIdDeletionQueue = util.NewUnboundedQueue() + ctx := context.Background() + + hardLinkId := NewHardLinkId() + inode := &Entry{ + FullPath: "/d/a.txt", + Attr: Attr{Mtime: time.Now()}, + HardLinkId: hardLinkId, + HardLinkCounter: 2, + Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}}, + } + blob, err := inode.EncodeAttributesAndChunks() + if err != nil { + t.Fatal(err) + } + if err := store.KvPut(ctx, hardLinkId, blob); err != nil { + t.Fatal(err) + } + put := func(e *Entry) { + if err := store.InsertEntry(ctx, e); err != nil { + t.Fatal(err) + } + } + put(&Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}) + put(&Entry{FullPath: "/d/a.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 2}) + put(&Entry{FullPath: "/keep", Attr: Attr{Mode: os.ModeDir}}) + put(&Entry{FullPath: "/keep/b.txt", Attr: Attr{Mtime: time.Now()}, HardLinkId: hardLinkId, HardLinkCounter: 2}) + + event := &filer_pb.SubscribeMetadataResponse{ + Directory: "/", + EventNotification: &filer_pb.EventNotification{ + OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true}, + DeleteChunks: true, + }, + TsNs: time.Now().UnixNano(), + } + if err := f.Replay(event); err != nil { + t.Fatalf("replay: %v", err) + } + + if _, err := store.KvGet(ctx, hardLinkId); err != nil { + t.Fatalf("surviving link must keep the inode record: %v", err) + } + if got := f.pendingDeletionCount(); got != 0 { + t.Fatalf("pending deletions = %d, want 0 while a sibling link owns the chunks", got) + } +} diff --git a/weed/filer/meta_replay.go b/weed/filer/meta_replay.go index cad4f41cf2..dbfd81fdec 100644 --- a/weed/filer/meta_replay.go +++ b/weed/filer/meta_replay.go @@ -195,12 +195,12 @@ func (f *Filer) sweepReplayedFolder(ctx context.Context, dir util.FullPath, dele return false, fmt.Errorf("delete %s: %w", sub.FullPath, derr) } f.notePathChange(sub.FullPath, tsNs, true) - // hard-linked chunks are owned by the shared inode record; the - // origin's own events decide their deletion. A directory entry - // can still carry chunks of its own — an S3 child promotes an - // existing file to a directory without dropping them — so the + // chunks of a hard-linked child belong to the shared inode + // record; free them only once that record is gone. A directory + // entry can still carry chunks of its own — an S3 child promotes + // an existing file to a directory without dropping them — so the // entry's chunks are keyed to its own path whatever it is. - if deleteChunks && len(sub.HardLinkId) == 0 && len(sub.GetChunks()) > 0 { + if deleteChunks && len(sub.GetChunks()) > 0 && (len(sub.HardLinkId) == 0 || f.hardLinkIsOrphaned(ctx, sub.HardLinkId)) { sweep.add(sub.FullPath, sub.GetChunks()) if sweep.count >= folderSweepChunkBatch { sweep.flush(ctx, f)