Files
seaweedfs/weed/filer/filer_delete_collection_test.go
T
a6be3c89c2 filer: support nanosecond timestamps for conditional delete (#11339) (#12071)
* 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>
2026-10-26 07:51:33 -07:00

388 lines
13 KiB
Go

package filer
import (
"context"
"fmt"
"net"
"os"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
)
// collectionDeleteMaster is just enough of a master for a MasterClient to
// consider itself connected, and records every CollectionDelete it is asked for.
type collectionDeleteMaster struct {
master_pb.UnimplementedSeaweedServer
calls chan collectionDeleteCall
}
type collectionDeleteCall struct {
name string
// budget is the time the RPC arrived with, or 0 when it carried no deadline.
budget time.Duration
}
// KeepConnected is what MasterClient waits on before it reports a master: one
// response is enough, then the stream idles until the server is torn down.
func (m *collectionDeleteMaster) KeepConnected(stream master_pb.Seaweed_KeepConnectedServer) error {
if _, err := stream.Recv(); err != nil {
return err
}
if err := stream.Send(&master_pb.KeepConnectedResponse{}); err != nil {
return err
}
<-stream.Context().Done()
return nil
}
func (m *collectionDeleteMaster) CollectionDelete(ctx context.Context, req *master_pb.CollectionDeleteRequest) (*master_pb.CollectionDeleteResponse, error) {
call := collectionDeleteCall{name: req.Name}
if deadline, ok := ctx.Deadline(); ok {
call.budget = time.Until(deadline)
}
m.calls <- call
return &master_pb.CollectionDeleteResponse{}, nil
}
// hookedStore is the stub store with one extra seam: a callback that runs once
// an entry has actually been removed, so a test can interleave an event -- a
// client hanging up, say -- between the store write and whatever follows it.
type hookedStore struct {
*stubFilerStore
onDeleteEntry func(util.FullPath)
}
func (s *hookedStore) DeleteEntry(ctx context.Context, p util.FullPath) error {
if err := s.stubFilerStore.DeleteEntry(ctx, p); err != nil {
return err
}
if s.onDeleteEntry != nil {
s.onDeleteEntry(p)
}
return nil
}
// newFilerWithFakeMaster builds a filer backed by the stub store, with its
// MasterClient connected to a fake master the test can observe.
func newFilerWithFakeMaster(t *testing.T) (*Filer, *hookedStore, *collectionDeleteMaster) {
t.Helper()
master := &collectionDeleteMaster{calls: make(chan collectionDeleteCall, 4)}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcServer := grpc.NewServer()
master_pb.RegisterSeaweedServer(grpcServer, master)
go func() { _ = grpcServer.Serve(lis) }()
t.Cleanup(grpcServer.Stop)
_, port, err := net.SplitHostPort(lis.Addr().String())
if err != nil {
t.Fatalf("split listener address: %v", err)
}
masterAddress := pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", port))
mc := wdclient.NewMasterClient(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"", cluster.FilerType, pb.ServerAddress("localhost:0"), "", "",
*pb.NewServiceDiscoveryFromMap(map[string]pb.ServerAddress{"m": masterAddress}),
)
connecting, stopConnecting := context.WithCancel(context.Background())
t.Cleanup(stopConnecting)
go mc.KeepConnectedToMaster(connecting)
waiting, cancelWaiting := context.WithTimeout(context.Background(), 20*time.Second)
defer cancelWaiting()
mc.WaitUntilConnected(waiting)
if waiting.Err() != nil {
t.Fatal("the master client never connected to the fake master")
}
store := &hookedStore{stubFilerStore: newStubFilerStore()}
f := &Filer{
DirBucketsPath: "/buckets",
RemoteStorage: NewFilerRemoteStorage(),
Store: NewFilerStoreWrapper(store),
FilerConf: NewFilerConf(),
MaxFilenameLength: 255,
MasterClient: mc,
FileIdDeletionQueue: util.NewUnboundedQueue(),
deletionQuit: make(chan struct{}),
LocalMetaLogBuffer: log_buffer.NewLogBuffer("test", time.Minute,
func(*log_buffer.LogBuffer, time.Time, time.Time, []byte, int64, int64) {}, nil, func() {}),
}
return f, store, master
}
// Deleting a bucket entry deletes its collection, and the request hanging up
// partway must not stop that: the bucket's metadata is already gone, so skipping
// it strands the bucket's volumes with nothing left to come back for them. The
// cleanup still has to carry a deadline of its own, or a master that is down
// parks this handler and every client retry behind it parks another.
//
// The cancellation lands between the store removing the entry and the collection
// delete, which is where a client disconnect actually bites: FilerStoreWrapper
// refuses a context that is already dead on entry, so an up-front cancellation
// fails the delete long before this point instead.
func TestDeleteEntryMetaAndDataDeletesCollectionWhenTheRequestIsCancelledMidDelete(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
const bucket = "bucket-a"
bucketPath := util.FullPath(f.DirBucketsPath + "/" + bucket)
if err := store.InsertEntry(context.Background(), &Entry{
FullPath: bucketPath,
Attr: Attr{Mode: os.ModeDir | 0755},
}); err != nil {
t.Fatalf("seed the bucket entry: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// The client hangs up just as the bucket entry comes out of the store.
store.onDeleteEntry = func(util.FullPath) { cancel() }
if err := f.DeleteEntryMetaAndData(ctx, bucketPath, true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
if ctx.Err() == nil {
t.Fatal("test setup: the request was never cancelled, so nothing was exercised")
}
var call collectionDeleteCall
select {
case call = <-master.calls:
case <-time.After(20 * time.Second):
t.Fatal("CollectionDelete never reached the master")
}
if call.name != bucket {
t.Errorf("master was asked to delete collection %q, want %q", call.name, bucket)
}
if call.budget <= 0 {
t.Error("CollectionDelete arrived with no deadline; a master that stops answering would hold this handler open")
}
if call.budget > collectionDeleteTimeout {
t.Errorf("CollectionDelete budget = %v, want at most collectionDeleteTimeout %v", call.budget, collectionDeleteTimeout)
}
if store.getEntry(string(bucketPath)) != nil {
t.Error("the bucket entry survived the delete")
}
}
func seedBucket(t *testing.T, store *hookedStore, path util.FullPath) {
t.Helper()
if err := store.InsertEntry(context.Background(), &Entry{
FullPath: path,
Attr: Attr{Mode: os.ModeDir | 0755},
}); err != nil {
t.Fatalf("seed bucket %s: %v", path, err)
}
}
// Two buckets resolving to one collection: deleting either must leave the
// collection for the other. Previously the delete dropped the collection
// named after the bucket regardless of where its data actually lived.
func TestDeleteBucketKeepsSharedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
seedBucket(t, store, util.FullPath("/buckets/b"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("shared collection was deleted: %q", call.name)
default:
}
if store.getEntry("/buckets/b") == nil {
t.Error("the surviving bucket's entry is gone")
}
}
// A bucket named after a collection other buckets resolve to is still just a
// bucket: deleting it must not take the shared collection down with it.
func TestDeleteBucketNamedAfterSharedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/shared"))
seedBucket(t, store, util.FullPath("/buckets/other"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/shared", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection backing other buckets was deleted: %q", call.name)
default:
}
}
// A rule pointing a non-bucket path at the same collection keeps it: the
// collection serves files the bucket delete must not orphan.
func TestDeleteBucketKeepsCollectionUsedByNonBucketPath(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "cold",
})
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/archives",
Collection: "cold",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection used by /archives was deleted: %q", call.name)
default:
}
}
// A broad prefix rule covering the whole tree keeps the collection even for a
// lone bucket: the same collection backs non-bucket paths too.
func TestDeleteBucketKeepsCollectionFromBroadRule(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/",
Collection: "everything",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection from a / rule was deleted: %q", call.name)
default:
}
}
// A bucket resolving to the filer's default collection keeps it: rule-less
// writes outside buckets land there too, so it is never one bucket's alone.
func TestDeleteBucketKeepsDefaultCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.metaLogCollection = "everything"
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "everything",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("the filer's default collection was deleted: %q", call.name)
default:
}
}
// A rule nested under a surviving bucket keeps the collection: the other
// bucket resolves elsewhere at its root, but objects deeper inside it still
// land in the shared collection.
func TestDeleteBucketKeepsCollectionFromNestedSiblingRule(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "shared",
})
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/b/deep",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
seedBucket(t, store, util.FullPath("/buckets/b"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection used under /buckets/b/deep was deleted: %q", call.name)
default:
}
}
// A grouped gateway writes to <group>_<bucket> regardless of the storage
// rules, so that is the collection the delete must drop -- and a rule-named
// collection the bucket never used must survive.
func TestDeleteBucketUnderFilerGroup(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.MasterClient.FilerGroup = "tenant1"
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/photos",
Collection: "archive",
})
seedBucket(t, store, util.FullPath("/buckets/photos"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/photos", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
if call.name != "tenant1_photos" {
t.Fatalf("CollectionDelete = %q, want %q", call.name, "tenant1_photos")
}
case <-time.After(20 * time.Second):
t.Fatal("CollectionDelete never reached the master")
}
}
// A collection only the deleted bucket resolves to is dropped under its real
// name, so a dedicated custom collection does not leak its volumes.
func TestDeleteBucketDeletesResolvedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/only",
Collection: "custom",
})
seedBucket(t, store, util.FullPath("/buckets/only"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/only", true, false, true, false, nil, time.Time{}); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
if call.name != "custom" {
t.Fatalf("CollectionDelete = %q, want %q", call.name, "custom")
}
case <-time.After(20 * time.Second):
t.Fatal("CollectionDelete never reached the master")
}
}