mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-26 23:10:44 +01:00
* filer: support nanosecond timestamps for conditional delete (#11339) * shell: fs.verify prune carries the entry mtime in nanoseconds IfNotModifiedAfter now resolves to sub-second precision on the filer, so pass the looked-up mtime_ns along with mtime; the re-lookup check catches same-second rewrites that share the second, and the test mock mirrors the filer's seconds/nanoseconds cutoff. * shell: volume.fsck guards empty-directory deletes with mtime nanoseconds The looked-up mtime_ns joins the seconds in IfNotModifiedAfter so a directory rewritten within the same second is not dropped; the stub filer mirrors the filer's scale heuristic and the race fixtures use a realistic mtime so the nanosecond branch engages. * filer: cover the seconds guard and equality boundary Test second-resolution cutoffs on both sides of the entry's second and the exact-equality nanosecond guard that still deletes. * filer: compare the nanosecond guard as a time, not UnixNano UnixNano overflows for mtimes outside 1678-2262, letting a far-future entry slip past the guard; time.After compares exactly at every epoch. The seconds branch keeps Unix() so a same-second cutoff still deletes. * filer_pb: add explicit if_not_modified_after_ns to DeleteEntryRequest * filer: prefer explicit nanoseconds guard, keep if_not_modified_after as seconds * shell: send seconds and nanoseconds delete cutoffs, skip unexpressible mtimes * filer: merge conditional-delete guards into one nanosecond cutoff DeleteEntryMetaAndData carried both the seconds and nanoseconds guards even though the precedence between them is fixed, so every caller passed a dead parameter. The gRPC handler now folds if_not_modified_after into nanoseconds at the boundary, where (sec+1)*1e9-1 keeps the whole-second cutoff exact, and the filer takes a single ifNotModifiedAfterNs. * filer: hold the conditional-delete cutoff as a time, not int64 nanoseconds A seconds-only guard at or past year 2262 saturated to MaxInt64 nanoseconds (~2262), so a delete whose cutoff matched a far-future mtime was skipped while the request still reported success, and any entry between 2262 and the real cutoff survived pruning. The merged cutoff is now a time.Time, where a whole-second guard maps to the last nanosecond of its second for any int64 second. --------- Co-authored-by: user.email <chrislusf@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
172 lines
7.5 KiB
Go
172 lines
7.5 KiB
Go
package filer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// The path in a non-empty-folder failure is the client's, so the marker has to
|
|
// lead the message for a caller reading it off the wire to trust it.
|
|
func TestNonEmptyFolderClassification(t *testing.T) {
|
|
err := fmt.Errorf("%w: %s", ErrNonEmptyFolder, "/buckets/b/photos")
|
|
if !IsNonEmptyFolderError(err) {
|
|
t.Errorf("expected the sentinel to be recognized: %v", err)
|
|
}
|
|
if !errors.Is(DeleteEntryError(err.Error()), ErrNonEmptyFolder) {
|
|
t.Errorf("expected the wire text to classify: %v", err)
|
|
}
|
|
|
|
// an entry named after the marker cannot forge one: every other delete
|
|
// failure the filer builds leads with its own wrapper
|
|
spoofed := "delete file /buckets/b/" + MsgFailDelNonEmptyFolder + ": filer store delete: disk full"
|
|
if errors.Is(DeleteEntryError(spoofed), ErrNonEmptyFolder) {
|
|
t.Errorf("expected no forgery from the entry name: %v", spoofed)
|
|
}
|
|
if IsNonEmptyFolderError(errors.New(spoofed)) {
|
|
t.Errorf("expected no forgery from the entry name: %v", spoofed)
|
|
}
|
|
}
|
|
|
|
func TestKeepRemoteObjectMarkerOnlyLivesOnDeleteEvents(t *testing.T) {
|
|
store := newStubFilerStore()
|
|
f := newTestFiler(t, store, NewFilerRemoteStorage())
|
|
ctx := context.Background()
|
|
planted := func() map[string][]byte {
|
|
return map[string][]byte{ExtKeepRemoteObjectKey: []byte("true"), "Seaweed-Other": []byte("kept")}
|
|
}
|
|
dir := &Entry{FullPath: "/buckets/b/dir", Attr: Attr{Mode: os.ModeDir | 0755}}
|
|
file := &Entry{FullPath: "/buckets/b/dir/obj.bin", Attr: Attr{Mode: 0644}, Extended: planted()}
|
|
require.NoError(t, f.CreateEntry(ctx, dir, nil, false, false, nil, true, 255))
|
|
require.NoError(t, f.CreateEntry(ctx, file, nil, false, false, nil, true, 255))
|
|
updated := file.ShallowClone()
|
|
updated.Extended = planted()
|
|
require.NoError(t, f.UpdateEntry(ctx, file, updated, false))
|
|
|
|
stored := store.entries[string(file.FullPath)]
|
|
require.NotContains(t, stored.Extended, ExtKeepRemoteObjectKey, "a client must not be able to store the marker")
|
|
require.Contains(t, stored.Extended, "Seaweed-Other")
|
|
|
|
ordinaryCtx, ordinary := WithMetadataEventSink(ctx)
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ordinaryCtx, file.FullPath, false, false, false, false, nil, time.Time{}))
|
|
require.False(t, IsMetadataOnlyDelete(ordinary.Last().EventNotification.OldEntry))
|
|
|
|
require.NoError(t, f.CreateEntry(ctx, &Entry{FullPath: file.FullPath, Attr: Attr{Mode: 0644}}, nil, false, false, nil, true, 255))
|
|
keepCtx, keep := WithMetadataEventSink(WithKeepRemoteObject(ctx))
|
|
require.NoError(t, f.DeleteEntryMetaAndData(keepCtx, dir.FullPath, true, false, false, false, nil, time.Time{}))
|
|
require.Equal(t, "dir", keep.Last().EventNotification.OldEntry.Name)
|
|
require.True(t, IsMetadataOnlyDelete(keep.Last().EventNotification.OldEntry))
|
|
}
|
|
|
|
// A recursive delete that removes the last link to an inode must free the
|
|
// shared chunks: the record that owned them is gone, so nothing else will.
|
|
func TestDeleteFolderFreesLastHardLinkChunks(t *testing.T) {
|
|
store := newStubFilerStore()
|
|
f := newTestFiler(t, store, NewFilerRemoteStorage())
|
|
ctx := context.Background()
|
|
|
|
hardLinkId := NewHardLinkId()
|
|
require.NoError(t, f.CreateEntry(ctx, &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir | 0755}}, nil, false, false, nil, true, 255))
|
|
linkEntry := &Entry{
|
|
FullPath: "/d/a.txt",
|
|
Attr: Attr{Mode: 0644, Mtime: time.Now()},
|
|
HardLinkId: hardLinkId,
|
|
HardLinkCounter: 1,
|
|
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}},
|
|
}
|
|
require.NoError(t, f.CreateEntry(ctx, linkEntry, nil, false, false, nil, true, 255))
|
|
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, time.Time{}))
|
|
|
|
_, err := store.KvGet(ctx, hardLinkId)
|
|
require.ErrorIs(t, err, ErrKvNotFound)
|
|
require.Equal(t, 1, f.pendingDeletionCount())
|
|
}
|
|
|
|
// The same delete keeps the shared chunks while another link outside the
|
|
// folder still owns them.
|
|
func TestDeleteFolderKeepsSharedHardLinkChunks(t *testing.T) {
|
|
store := newStubFilerStore()
|
|
f := newTestFiler(t, store, NewFilerRemoteStorage())
|
|
ctx := context.Background()
|
|
|
|
hardLinkId := NewHardLinkId()
|
|
for _, dir := range []string{"/d", "/keep"} {
|
|
require.NoError(t, f.CreateEntry(ctx, &Entry{FullPath: util.FullPath(dir), Attr: Attr{Mode: os.ModeDir | 0755}}, nil, false, false, nil, true, 255))
|
|
}
|
|
for _, path := range []string{"/d/a.txt", "/keep/b.txt"} {
|
|
require.NoError(t, f.CreateEntry(ctx, &Entry{
|
|
FullPath: util.FullPath(path),
|
|
Attr: Attr{Mode: 0644, Mtime: time.Now()},
|
|
HardLinkId: hardLinkId,
|
|
HardLinkCounter: 2,
|
|
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6"}},
|
|
}, nil, false, false, nil, true, 255))
|
|
}
|
|
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, time.Time{}))
|
|
|
|
_, err := store.KvGet(ctx, hardLinkId)
|
|
require.NoError(t, err, "surviving link must keep the inode record")
|
|
require.Equal(t, 0, f.pendingDeletionCount())
|
|
}
|
|
|
|
func TestDeleteEntryMetaAndData_IfNotModifiedAfterSubsecond(t *testing.T) {
|
|
store := newStubFilerStore()
|
|
f := newTestFiler(t, store, NewFilerRemoteStorage())
|
|
ctx := context.Background()
|
|
|
|
baseTime := time.Date(2026, 10, 26, 12, 0, 0, 500_000_000, time.UTC) // 12:00:00.500
|
|
filePath := util.FullPath("/subsecond.txt")
|
|
|
|
entry := &Entry{
|
|
FullPath: filePath,
|
|
Attr: Attr{Mode: 0644, Mtime: baseTime},
|
|
}
|
|
require.NoError(t, f.CreateEntry(ctx, entry, nil, false, false, nil, true, 255))
|
|
|
|
// Guard is 100ms before entry Mtime (within the same second)
|
|
guardNs := baseTime.Add(-100 * time.Millisecond).UnixNano()
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(0, guardNs)))
|
|
|
|
// Entry must NOT be deleted because entry's Mtime is newer than guardNs
|
|
stored, err := f.FindEntry(ctx, filePath)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored, "entry modified after guardNs must be preserved")
|
|
|
|
// Guard is at or after entry Mtime
|
|
olderGuardNs := baseTime.Add(100 * time.Millisecond).UnixNano()
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(0, olderGuardNs)))
|
|
|
|
// Entry must be deleted now
|
|
_, err = f.FindEntry(ctx, filePath)
|
|
require.ErrorIs(t, err, filer_pb.ErrNotFound)
|
|
|
|
// A guard equal to the entry's Mtime still deletes
|
|
require.NoError(t, f.CreateEntry(ctx, entry, nil, false, false, nil, true, 255))
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, baseTime))
|
|
_, err = f.FindEntry(ctx, filePath)
|
|
require.ErrorIs(t, err, filer_pb.ErrNotFound)
|
|
|
|
// An entry mtime beyond the UnixNano range compares as a time, not an int
|
|
farFuture := &Entry{FullPath: filePath, Attr: Attr{Mode: 0644, Mtime: time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC)}}
|
|
require.NoError(t, f.CreateEntry(ctx, farFuture, nil, false, false, nil, true, 255))
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, baseTime))
|
|
stored, err = f.FindEntry(ctx, filePath)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored, "entry past the UnixNano range must be preserved")
|
|
|
|
// A whole-second cutoff beyond the UnixNano range still matches: the
|
|
// unchanged year-3000 entry is at its guard's second, so it is deleted
|
|
require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(farFuture.Attr.Mtime.Unix(), 999_999_999)))
|
|
_, err = f.FindEntry(ctx, filePath)
|
|
require.ErrorIs(t, err, filer_pb.ErrNotFound)
|
|
}
|