mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-14 07:48:41 +01:00
A content flush conditioned on the handle's base failed with EIO whenever a peer had changed the file since, so two mounts appending to one file could not both close it cleanly. On a refused condition, reread the entry and replay this handle's changes since its base onto it: the chunks it added or dropped, and the attributes, extended keys and inline content it changed. A local truncation keeps its size and clips the peer's chunks to it; otherwise the larger size wins. The merged entry is published against the stored one, up to three times, and installed in the handle so its next flush keeps the peer's chunks. A replaced or deleted file is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
13 KiB
Go
352 lines
13 KiB
Go
//go:build !freebsd
|
|
|
|
package mount
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/go-fuse/v2/fuse"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
type contentFlushConditionFiler struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
mu sync.Mutex
|
|
entry *filer_pb.Entry
|
|
calls int
|
|
}
|
|
|
|
func (s *contentFlushConditionFiler) CreateEntry(ctx context.Context, r *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.calls++
|
|
for _, c := range r.GetCondition().GetClauses() {
|
|
matches := false
|
|
switch c.Kind {
|
|
case filer_pb.WriteCondition_IF_ENTRY_EQUAL:
|
|
matches = proto.Equal(c.ExpectedEntry, s.entry)
|
|
case filer_pb.WriteCondition_IF_NOT_EXISTS:
|
|
matches = s.entry == nil
|
|
}
|
|
if !matches {
|
|
return &filer_pb.CreateEntryResponse{Error: "precondition failed", ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
|
|
}
|
|
}
|
|
// Like the filer, stamp the stored version and announce what was stored.
|
|
old := s.entry
|
|
s.entry = proto.Clone(r.Entry).(*filer_pb.Entry)
|
|
s.entry.CommitTsNs = 1000 + int64(s.calls)
|
|
return &filer_pb.CreateEntryResponse{LogTsNs: 2000, MetadataEvent: &filer_pb.SubscribeMetadataResponse{
|
|
Directory: r.Directory,
|
|
TsNs: s.entry.CommitTsNs,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: old,
|
|
NewEntry: proto.Clone(s.entry).(*filer_pb.Entry),
|
|
},
|
|
}}, nil
|
|
}
|
|
func (s *contentFlushConditionFiler) LookupDirectoryEntry(ctx context.Context, r *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.entry == nil {
|
|
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
|
}
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(s.entry).(*filer_pb.Entry)}, nil
|
|
}
|
|
|
|
func flushConditionEntry(ids ...uint64) *filer_pb.Entry {
|
|
e := &filer_pb.Entry{Name: "file", Attributes: &filer_pb.FuseAttributes{Inode: 42, FileMode: 0644, Uid: 2000, Gid: 20, FileSize: 3}}
|
|
for i, id := range ids {
|
|
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: id, Cookie: 1}, Offset: int64(i), Size: 1, ModifiedTsNs: int64(id)})
|
|
}
|
|
return e
|
|
}
|
|
|
|
// Exercise the actual mount content-publication caller. A peer's append is
|
|
// merged with ours; a replaced or deleted file is not ours to merge into, so
|
|
// the refused flush keeps our dirty entry instead of overwriting the peer.
|
|
func TestContentFlushPreservesPeerChunks(t *testing.T) {
|
|
for _, name := range []string{"single-writer", "peer-append", "replaced", "deleted", "deferred-create", "deferred-create-race"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
wfs := newInvalidateTestWFS(t)
|
|
observed := flushConditionEntry(1)
|
|
live := flushConditionEntry(1, 3)
|
|
live.Chunks[1].Offset = 2
|
|
current := flushConditionEntry(1)
|
|
wantError, wantCalls := false, 1
|
|
switch name {
|
|
case "peer-append":
|
|
current = flushConditionEntry(1, 2)
|
|
wantCalls = 2
|
|
case "replaced":
|
|
current = flushConditionEntry(4)
|
|
current.Attributes.Inode = 99
|
|
wantError = true
|
|
case "deleted":
|
|
current = nil
|
|
wantError = true
|
|
case "deferred-create":
|
|
observed = nil
|
|
current = nil
|
|
case "deferred-create-race":
|
|
observed = nil
|
|
current = flushConditionEntry(4)
|
|
current.Attributes.Inode = 99
|
|
wantError = true
|
|
}
|
|
if observed != nil {
|
|
wfs.mapPbIdFromFilerToLocal(observed)
|
|
}
|
|
wfs.mapPbIdFromFilerToLocal(live)
|
|
fake := &contentFlushConditionFiler{entry: current}
|
|
startFakeFiler(t, wfs, fake)
|
|
fh := newFileHandle(wfs, 1, 42, live)
|
|
t.Cleanup(func() { fh.dirtyPages.Destroy() })
|
|
fh.baseEntry.Store(observed)
|
|
fh.dirtyMetadata = true
|
|
fh.RememberPath(util.FullPath("/dir/file"))
|
|
before := proto.Clone(current)
|
|
code := wfs.doFlush(context.Background(), fh, 1000, 20, false)
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if wantError {
|
|
if code == fuse.OK {
|
|
t.Fatal("stale content flush acknowledged after a peer changed the entry")
|
|
}
|
|
if !proto.Equal(fake.entry, before) {
|
|
t.Fatalf("peer entry changed: %v", fake.entry)
|
|
}
|
|
if !fh.dirtyMetadata {
|
|
t.Fatal("refused flush lost dirty metadata")
|
|
}
|
|
if len(fh.entry.Entry.Chunks) != 2 || fh.entry.Entry.Chunks[1].GetFileIdString() != live.Chunks[1].GetFileIdString() {
|
|
t.Fatal("refused flush lost pending content")
|
|
}
|
|
} else {
|
|
if code != fuse.OK {
|
|
t.Fatal(code)
|
|
}
|
|
if fh.dirtyMetadata {
|
|
t.Fatal("successful flush remains dirty")
|
|
}
|
|
want := []uint64{1, 3}
|
|
if name == "peer-append" {
|
|
want = []uint64{1, 2, 3}
|
|
}
|
|
if got := chunkFileKeys(fake.entry); !slices.Equal(got, want) {
|
|
t.Fatalf("stored chunks %v, want %v", got, want)
|
|
}
|
|
if got := chunkFileKeys(fh.GetEntry().GetEntry()); !slices.Equal(got, want) {
|
|
t.Fatalf("handle chunks %v, want %v", got, want)
|
|
}
|
|
}
|
|
if fake.calls != wantCalls {
|
|
t.Fatalf("unexpected replay: %d calls", fake.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Each acknowledged flush must leave the handle a base the next flush on the
|
|
// same handle can match: the stored entry, with the filer's own commit stamp.
|
|
func TestContentFlushRepeatsOnOneHandle(t *testing.T) {
|
|
wfs := newInvalidateTestWFS(t)
|
|
stored := flushConditionEntry(1)
|
|
stored.CommitTsNs = 7
|
|
fake := &contentFlushConditionFiler{entry: proto.Clone(stored).(*filer_pb.Entry)}
|
|
startFakeFiler(t, wfs, fake)
|
|
observed := proto.Clone(stored).(*filer_pb.Entry)
|
|
wfs.mapPbIdFromFilerToLocal(observed)
|
|
fh := newFileHandle(wfs, 1, 42, proto.Clone(observed).(*filer_pb.Entry))
|
|
t.Cleanup(func() { fh.dirtyPages.Destroy() })
|
|
fh.RememberPath(util.FullPath("/dir/file"))
|
|
for i := uint64(2); i <= 3; i++ {
|
|
fh.UpdateEntry(func(e *filer_pb.Entry) {
|
|
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: i, Cookie: 1}, Offset: int64(i - 1), Size: 1, ModifiedTsNs: int64(i)})
|
|
e.Attributes.FileSize = i
|
|
})
|
|
fh.dirtyMetadata = true
|
|
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
|
|
t.Fatalf("flush %d: %v", i-1, code)
|
|
}
|
|
}
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if len(fake.entry.Chunks) != 3 || fake.calls != 2 {
|
|
t.Fatalf("stored %d chunks after %d calls", len(fake.entry.Chunks), fake.calls)
|
|
}
|
|
}
|
|
|
|
func chunkFileKeys(e *filer_pb.Entry) (keys []uint64) {
|
|
for _, c := range e.GetChunks() {
|
|
keys = append(keys, c.GetFid().GetFileKey())
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// The periodic metadata flush publishes the same whole entry, so it must not
|
|
// overwrite a peer's append either.
|
|
func TestPeriodicMetadataFlushPreservesPeerChunks(t *testing.T) {
|
|
wfs := newInvalidateTestWFS(t)
|
|
current := flushConditionEntry(1, 2)
|
|
fake := &contentFlushConditionFiler{entry: proto.Clone(current).(*filer_pb.Entry)}
|
|
startFakeFiler(t, wfs, fake)
|
|
observed := flushConditionEntry(1)
|
|
wfs.mapPbIdFromFilerToLocal(observed)
|
|
live := flushConditionEntry(1, 3)
|
|
live.Chunks[1].Offset = 2
|
|
wfs.mapPbIdFromFilerToLocal(live)
|
|
fh := newFileHandle(wfs, 1, 42, live)
|
|
t.Cleanup(func() { fh.dirtyPages.Destroy() })
|
|
fh.baseEntry.Store(observed)
|
|
fh.dirtyMetadata = true
|
|
fh.RememberPath(util.FullPath("/dir/file"))
|
|
if err := wfs.flushFileMetadata(fh); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if got := chunkFileKeys(fake.entry); !slices.Equal(got, []uint64{1, 2, 3}) || fake.calls != 2 {
|
|
t.Fatalf("stored chunks %v after %d calls", got, fake.calls)
|
|
}
|
|
if got := chunkFileKeys(fh.GetEntry().GetEntry()); !slices.Equal(got, []uint64{1, 2, 3}) {
|
|
t.Fatalf("handle chunks %v", got)
|
|
}
|
|
if !fh.dirtyMetadata {
|
|
t.Fatal("periodic flush cleared dirty metadata")
|
|
}
|
|
}
|
|
|
|
// Every merged flush is followed by a flush that matches what it stored, so
|
|
// the peer's chunk is not dropped by the next publication from this handle.
|
|
func TestContentFlushMergeThenRepeat(t *testing.T) {
|
|
wfs := newInvalidateTestWFS(t)
|
|
fake := &contentFlushConditionFiler{entry: flushConditionEntry(1, 2)}
|
|
startFakeFiler(t, wfs, fake)
|
|
observed := flushConditionEntry(1)
|
|
wfs.mapPbIdFromFilerToLocal(observed)
|
|
live := flushConditionEntry(1, 3)
|
|
live.Chunks[1].Offset = 2
|
|
wfs.mapPbIdFromFilerToLocal(live)
|
|
fh := newFileHandle(wfs, 1, 42, live)
|
|
t.Cleanup(func() { fh.dirtyPages.Destroy() })
|
|
fh.baseEntry.Store(observed)
|
|
fh.RememberPath(util.FullPath("/dir/file"))
|
|
for i := uint64(4); i <= 5; i++ {
|
|
fh.dirtyMetadata = true
|
|
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
|
|
t.Fatalf("flush before chunk %d: %v", i, code)
|
|
}
|
|
fh.UpdateEntry(func(e *filer_pb.Entry) {
|
|
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: i, Cookie: 1}, Offset: int64(i - 1), Size: 1, ModifiedTsNs: int64(i)})
|
|
e.Attributes.FileSize = i
|
|
})
|
|
}
|
|
fh.dirtyMetadata = true
|
|
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
|
|
t.Fatal(code)
|
|
}
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if got := chunkFileKeys(fake.entry); !slices.Equal(got, []uint64{1, 2, 3, 4, 5}) || fake.calls != 4 {
|
|
t.Fatalf("stored chunks %v after %d calls", got, fake.calls)
|
|
}
|
|
}
|
|
|
|
// The merge keeps a peer's attribute and extended changes the handle did not
|
|
// make, applies the ones it did, and lets a local truncation clip the peer's
|
|
// chunks past the new end.
|
|
func TestMergeContentEntry(t *testing.T) {
|
|
base := flushConditionEntry(1, 2)
|
|
base.Extended = map[string][]byte{"user.a": []byte("1"), "user.b": []byte("1")}
|
|
stored := proto.Clone(base).(*filer_pb.Entry)
|
|
stored.Chunks = append(stored.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: 9, Cookie: 1}, Offset: 2, Size: 4})
|
|
stored.Attributes.FileMode, stored.Attributes.FileSize = 0600, 6
|
|
stored.Extended["user.b"] = []byte("peer")
|
|
stored.CommitTsNs = 77
|
|
|
|
local := proto.Clone(base).(*filer_pb.Entry)
|
|
local.Attributes.Mtime = 123
|
|
local.Extended["user.a"] = []byte("mine")
|
|
delete(local.Extended, "user.b")
|
|
local.Chunks = append(local.Chunks, &filer_pb.FileChunk{FileId: "1,0300000001", Offset: 2, Size: 1, ModifiedTsNs: 3})
|
|
|
|
merged, ok := mergeContentEntry(stored, base, local, local)
|
|
if !ok {
|
|
t.Fatal("not merged")
|
|
}
|
|
if got := len(merged.Chunks); got != 4 || merged.Attributes.FileSize != 6 {
|
|
t.Fatalf("merged %d chunks, size %d", got, merged.Attributes.FileSize)
|
|
}
|
|
if merged.Attributes.FileMode != 0600 || merged.Attributes.Mtime != 123 || merged.CommitTsNs != 77 {
|
|
t.Fatalf("attributes %v", merged.Attributes)
|
|
}
|
|
if string(merged.Extended["user.a"]) != "mine" || merged.Extended["user.b"] != nil {
|
|
t.Fatalf("extended %v", merged.Extended)
|
|
}
|
|
|
|
truncated := proto.Clone(base).(*filer_pb.Entry)
|
|
truncated.Chunks = truncated.Chunks[:1]
|
|
truncated.Attributes.FileSize = 1
|
|
merged, ok = mergeContentEntry(stored, base, truncated, truncated)
|
|
if !ok {
|
|
t.Fatal("truncation not merged")
|
|
}
|
|
if got := chunkFileKeys(merged); !slices.Equal(got, []uint64{1}) || merged.Attributes.FileSize != 1 {
|
|
t.Fatalf("truncated merge kept %v, size %d", got, merged.Attributes.FileSize)
|
|
}
|
|
|
|
stored.Attributes.Inode = 99
|
|
if _, ok := mergeContentEntry(stored, base, local, local); ok {
|
|
t.Fatal("merged into a replaced file")
|
|
}
|
|
}
|
|
|
|
// A miss on state no peer changed is republished once against the stored
|
|
// entry: a rename while open, or a filer uid the local id mapping cannot
|
|
// reproduce (newInvalidateTestWFS maps local 1000 to filer 2000).
|
|
func TestContentFlushRepublishesUnchangedEntry(t *testing.T) {
|
|
for _, name := range []string{"renamed", "colliding-uid"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
wfs := newInvalidateTestWFS(t)
|
|
stored := flushConditionEntry(1)
|
|
stored.CommitTsNs = 7
|
|
observed := proto.Clone(stored).(*filer_pb.Entry)
|
|
path := util.FullPath("/dir/file")
|
|
switch name {
|
|
case "renamed":
|
|
wfs.mapPbIdFromFilerToLocal(observed)
|
|
stored.Name, stored.CommitTsNs = "moved", 8
|
|
path = "/dir/moved"
|
|
case "colliding-uid":
|
|
stored.Attributes.Uid, observed.Attributes.Uid = 1000, 1000
|
|
}
|
|
fake := &contentFlushConditionFiler{entry: proto.Clone(stored).(*filer_pb.Entry)}
|
|
startFakeFiler(t, wfs, fake)
|
|
live := proto.Clone(observed).(*filer_pb.Entry)
|
|
live.Name = stored.Name
|
|
live.Chunks = append(live.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: 3, Cookie: 1}, Offset: 1, Size: 1, ModifiedTsNs: 3})
|
|
fh := newFileHandle(wfs, 1, 42, live)
|
|
t.Cleanup(func() { fh.dirtyPages.Destroy() })
|
|
fh.baseEntry.Store(observed)
|
|
fh.dirtyMetadata = true
|
|
fh.RememberPath(path)
|
|
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
|
|
t.Fatal(code)
|
|
}
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if len(fake.entry.Chunks) != 2 || fake.calls != 2 {
|
|
t.Fatalf("stored %d chunks after %d calls", len(fake.entry.Chunks), fake.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|