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.
This commit is contained in:
Chris Lu
2026-10-17 21:07:35 -07:00
parent 42644b33a6
commit 4086e14452
2 changed files with 111 additions and 5 deletions
+106
View File
@@ -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)
}
}
+5 -5
View File
@@ -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)