mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-27 15:30:45 +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>
388 lines
13 KiB
Go
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")
|
|
}
|
|
}
|