Files
seaweedfs/weed/mount/content_flush_condition_test.go
T
d7ae08ecad fix(mount): refuse stale whole-entry content flushes (#12594)
* fix(mount): condition content flushes on the observed entry

* mount: stop retrying a flush the filer refused on its condition

The filer reports a failed CreateEntry condition as FilerError_PRECONDITION_FAILED
in the response, which surfaces as filer_pb.ErrPreconditionFailed rather than a
FailedPrecondition status. The flush retry loop treated it as transient and
replayed the refused flush three more times over 7 s before returning EIO.

The test filer now answers like the real one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: take the handle base from the entry the filer stored

After an acknowledged flush the handle base was the requested entry, but the
filer stores it with a fresh commit_ts_ns (and fills crtime, inode, atime), so
the next IF_ENTRY_EQUAL flush on the same handle never matched: write, fsync,
write, fsync returned EIO, as did close after a periodic metadata flush and the
first flush after an eager create. Use the stored entry from the
acknowledgement's metadata event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: condition the periodic metadata flush like the close flush

The periodic metadata flush of an open, dirty handle sent the same whole entry
through an unconditional CreateEntry, so a file held open past the flush
interval still overwrote a peer's acknowledged append, and the close flush then
matched the overwritten state. Both paths now publish through one helper that
carries the handle's write condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: republish a refused flush once when the stored entry is unchanged

A handle's base can differ from the stored entry without any peer write: a
rename while the file is open restamps the entry under its new name, and with
-map.uid a filer id that is also a mapped local id does not map back to itself.
Each made every flush of the handle fail with EIO. On a miss, read the stored
entry and, if it still holds the observed state apart from name, commit stamp,
id encoding and derived size, retry once with that entry as the condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: take the filer's entry once a write open holds the DLM lock

The lock orders this writer after the previous holder, but the holder's
flush may not have reached the local cache yet, so the handle's base
named the entry from before the wait and the conditional flush refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: share the open-existing path of Create

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: open the file a previous DLM holder created during a deferred create

A deferred create never asks the filer, so a create that waited for the
lock went on to publish over a file the previous holder had just made,
and its flush expecting no entry was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* master: send the lock ring snapshot to mounts

A mount running -dlm is a lock client like the S3 gateway, and needs the
ring on connect to reach a file's owner filer before the next membership
change is broadcast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: follow the masters' lock ring under -dlm

The lock client only knew the seed filer, so every lock took the forward
hop and the mount had no way to tell which filer owns a file. Subscribe to
the masters named by the filer, like the S3 gateway, and wait briefly for
the first ring before serving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: read the DLM-ordered entry from the file's owner filer

The filer checks a flush's condition on the owner of the path's write
route key, and another filer may not have applied the previous holder's
flush yet. Look the entry up on that owner once the ring is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:15:08 -08:00

249 lines
8.7 KiB
Go

//go:build !freebsd
package mount
import (
"context"
"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 successful
// append or replacement is authoritative; refusing our stale flush must retain
// our dirty entry, instead of refreshing a guard and replaying stale content.
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 := false
switch name {
case "peer-append":
current = flushConditionEntry(1, 2)
wantError = true
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")
}
if len(fake.entry.Chunks) != 2 || fake.entry.Chunks[1].Offset != 2 || fake.entry.Chunks[1].Size != 1 {
t.Fatalf("normal flush lost content: %v", fake.entry)
}
}
if fake.calls != 1 {
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)
}
}
// 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("periodic flush overwrote a peer's append")
}
fake.mu.Lock()
defer fake.mu.Unlock()
if !proto.Equal(fake.entry, current) || fake.calls != 1 {
t.Fatalf("peer entry changed after %d calls: %v", fake.calls, fake.entry)
}
if !fh.dirtyMetadata {
t.Fatal("refused periodic flush lost dirty metadata")
}
}
// 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)
}
})
}
}