Files
seaweedfs/weed/mount/content_flush_condition_test.go
T
Chris LuandClaude Opus 5.5 bb7e4692c0 mount: merge a refused content flush onto the stored entry
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>
2026-11-12 16:48:21 -08:00

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)
}
})
}
}