Files
seaweedfs/weed/mount/weedfs_attr_cas_test.go
T
c2b2ed769b fix(mount): preserve appended chunks during stale attribute updates (#12593)
* fix(mount): preserve acknowledged chunks during attribute updates

* mount: keep the WFS field block aligned

Adding the publication-lock fields inside the aligned block made gofmt
re-indent 25 unrelated lines. Put them in their own group after
dirIdleEvict, matching the enterprise layout, so syncs do not conflict there.

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

* mount: finish attribute saves through a FUSE interrupt

saveEntryAttributes derived its RPC context from the syscall's cancel
channel, so an interrupt while the conditional UpdateEntry was at the
owner returned EIO for an update the owner then applied, and skipped the
local install and retention. Keep the metadata deadline but drop the
cancellation, like the other metadata mutations.

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

* mount: map attribute CAS ids in place instead of cloning the entry

saveEntryAttributes cloned the whole entry three times per attempt only
to rewrite uid/gid for the filer, and each clone is as large as the
chunk list. Switch the ids in place and restore the exact local values
after the call, keeping one clone. The test now checks the reply carries
the local uid.

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

* mount: compare attribute updates against the filer's own uid/gid

With -map.uid/-map.gid, a filer id that is also a mapped local id reads
back unchanged and maps forward to another filer id. The IF_ENTRY_EQUAL
expected entry then never matched, so chmod, utimes and xattr changes on
such files failed with EAGAIN after three attempts, and an update would
have rewritten the owner. Keep the filer ids read with the entry for the
ids the syscall leaves alone.

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

* fix(mount): align attribute retention with read lock order

* mount: take the handle lock before entryLock in the attribute snapshot

attributeEntrySnapshot held entryLock while it queued for the shared
handle lock. A Read holding that lock waits on entryLock, the snapshot
waits behind a queued exclusive (an invalidation or a Write), and the
exclusive waits on the Read.

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

* fix(mount): complete handle lock order and preserve explicit ID intent

* mount: re-read a refused attribute update from the file's owner filer

The filer checks the IF_ENTRY_EQUAL condition on the owner of the path's
write route key. The retry read any filer, so behind one that had not
applied the owner's latest update, every attempt sent the same stale base
and the syscall failed with EAGAIN. Read from the owner once the lock ring
is known, like lookupFilerEntry, but keep the filer's raw ids.

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:28:47 -08:00

757 lines
28 KiB
Go

//go:build !freebsd
package mount
import (
"context"
"fmt"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb"
"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"
)
// The writer's acknowledged entry and a second mount's cached entry diverge
// before the attribute syscall. The filer implements the existing atomic
// IF_ENTRY_EQUAL contract; no sleeps or FUSE device are needed for this order.
type attributeCASFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.Mutex
entry *filer_pb.Entry
updates, conflicts int
beforeUpdate func(int, *filer_pb.Entry)
afterCommit func()
updateErr error
missing bool
}
func (s *attributeCASFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.missing {
return nil, status.Error(codes.NotFound, "deleted")
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(s.entry).(*filer_pb.Entry)}, nil
}
func (s *attributeCASFiler) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.updates++
if s.updateErr != nil {
return nil, s.updateErr
}
if s.beforeUpdate != nil {
s.beforeUpdate(s.updates, s.entry)
}
if clauses := req.GetCondition().GetClauses(); len(clauses) > 0 && !proto.Equal(clauses[0].GetExpectedEntry(), s.entry) {
s.conflicts++
return nil, status.Error(codes.FailedPrecondition, "entry changed")
}
s.entry = proto.Clone(req.Entry).(*filer_pb.Entry)
if s.afterCommit != nil {
// Release the owner lock: concurrent CreateEntry must be able to publish
// while the committed UpdateEntry response is held at this barrier.
s.mu.Unlock()
s.afterCommit()
s.mu.Lock()
}
return &filer_pb.UpdateEntryResponse{LogTsNs: 2000}, nil
}
func TestAttributeOnlyConflictOutcomes(t *testing.T) {
for _, tc := range []struct {
name string
flags uint32
prepare func(*attributeCASFiler)
want fuse.Status
calls int
}{
{"bounded", 0, func(s *attributeCASFiler) {
s.beforeUpdate = func(n int, e *filer_pb.Entry) { e.Extended["concurrent"] = []byte(fmt.Sprint(n)) }
}, fuse.Status(syscall.EAGAIN), 3},
{"replacement", 0, func(s *attributeCASFiler) { s.entry.Attributes.Inode = 99 }, fuse.Status(syscall.ESTALE), 1},
{"deleted", 0, func(s *attributeCASFiler) { s.missing = true }, fuse.ENOENT, 1},
{"create-existing", xattr_CREATE, func(s *attributeCASFiler) { s.entry.Extended["xattr-user.test"] = []byte("other mount") }, fuse.Status(syscall.EEXIST), 1},
{"replace-removed", xattr_REPLACE, func(s *attributeCASFiler) { delete(s.entry.Extended, "xattr-user.test") }, fuse.ENODATA, 1},
{"permission", 0, func(s *attributeCASFiler) { s.updateErr = status.Error(codes.PermissionDenied, "refused") }, fuse.Status(syscall.EACCES), 1},
} {
t.Run(tc.name, func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
cached := attributeAppendEntry(1)
if tc.flags == xattr_REPLACE {
cached.Extended["xattr-user.test"] = []byte("cached")
}
fake := &attributeCASFiler{entry: attributeAppendEntry(300)}
tc.prepare(fake)
startFakeFiler(t, wfs, fake)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(util.FullPath("/dir"), 1, true, false, 0, false)
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
wfs.inodeToPath.MarkChildrenCached(util.FullPath("/dir"))
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", cached), 1); err != nil {
t.Fatal(err)
}
in := &fuse.SetXAttrIn{Flags: tc.flags}
in.NodeId = 42
if code := wfs.SetXAttr(nil, in, "user.test", []byte("changed")); code != tc.want {
t.Fatalf("got %v, want %v", code, tc.want)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.updates != tc.calls {
t.Fatalf("updates=%d want %d", fake.updates, tc.calls)
}
if len(fake.entry.Chunks) != 300 || fake.entry.Attributes.FileSize != 76800 {
t.Fatal("failed attribute op changed content")
}
})
}
}
func (s *attributeCASFiler) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.entry = proto.Clone(req.Entry).(*filer_pb.Entry)
return &filer_pb.CreateEntryResponse{LogTsNs: 2000}, nil
}
func attributeAppendEntry(records int) *filer_pb.Entry {
e := &filer_pb.Entry{Name: "file", Attributes: &filer_pb.FuseAttributes{
Inode: 42, Uid: 2000, Gid: 20, FileMode: 0644, FileSize: uint64(records * 256), Mtime: 10,
}, Extended: map[string][]byte{"xattr-user.other": []byte("keep"), "xattr-user.remove": []byte("remove")}}
for i := 0; i < records; i++ {
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: uint64(i + 1), Cookie: 1}, Offset: int64(i * 256), Size: 256, ModifiedTsNs: int64(i + 1)})
}
return e
}
func TestAttributeOnlyOpsPreserveAcknowledgedAppends(t *testing.T) {
for _, op := range []string{"chmod", "chown", "utimes", "setxattr", "removexattr"} {
for _, stale := range []bool{false, true} {
for _, open := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/stale=%t/open=%t", op, stale, open), func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
current := attributeAppendEntry(300)
fake := &attributeCASFiler{entry: proto.Clone(current).(*filer_pb.Entry)}
startFakeFiler(t, wfs, fake)
cached := proto.Clone(current).(*filer_pb.Entry)
if stale {
cached = attributeAppendEntry(1)
cached.Extended["xattr-user.other"] = []byte("obsolete")
cached.Attributes.Gid = 17
}
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(util.FullPath("/dir"), 1, true, false, 0, false)
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
wfs.inodeToPath.MarkChildrenCached(util.FullPath("/dir"))
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", cached), 1); err != nil {
t.Fatal(err)
}
var fh *FileHandle
if open {
local := proto.Clone(cached).(*filer_pb.Entry)
wfs.mapPbIdFromFilerToLocal(local)
fh = &FileHandle{fh: 1, inode: 42, wfs: wfs, entry: &LockedEntry{Entry: local}}
fh.dirtyPages = newPageWriter(fh, 1024)
fh.RememberPath(path)
wfs.fhMap.inode2fh[42], wfs.fhMap.fh2inode[1] = fh, 42
t.Cleanup(func() { fh.dirtyPages.Destroy() })
}
var code fuse.Status
out := &fuse.AttrOut{}
if op == "removexattr" {
code = wfs.RemoveXAttr(nil, &fuse.InHeader{NodeId: 42}, "user.remove")
} else if op == "setxattr" {
in := &fuse.SetXAttrIn{}
in.NodeId = 42
code = wfs.SetXAttr(nil, in, "user.test", []byte("new"))
} else {
in := &fuse.SetAttrIn{}
in.NodeId = 42
switch op {
case "chmod":
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
case "chown":
in.Valid, in.Owner.Uid, in.Owner.Gid = fuse.FATTR_UID|fuse.FATTR_GID, 1001, 21
case "utimes":
in.Valid, in.Mtime, in.Mtimensec = fuse.FATTR_MTIME|fuse.FATTR_ATIME, 123, 456
}
code = wfs.SetAttr(nil, in, out)
}
if code != fuse.OK {
t.Fatalf("attribute syscall: %v", code)
}
if (op == "chmod" || op == "utimes") && out.Attr.Uid != 1000 {
t.Fatalf("reply uid %d, want the local id 1000", out.Attr.Uid)
}
if open {
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
t.Fatalf("flush after attribute syscall: %v", code)
}
}
fake.mu.Lock()
defer fake.mu.Unlock()
if len(fake.entry.Chunks) != 300 || fake.entry.Attributes.FileSize != 76800 {
t.Fatalf("lost fsync-acknowledged appends: chunks=%d size=%d, want 300/76800", len(fake.entry.Chunks), fake.entry.Attributes.FileSize)
}
for i, chunk := range fake.entry.Chunks {
if chunk.GetFileIdString() != current.Chunks[i].GetFileIdString() || chunk.Offset != int64(i*256) || chunk.Size != 256 {
t.Fatalf("acknowledged record %d no longer references its original bytes: %v", i, chunk)
}
}
switch op {
case "chmod":
if fake.entry.Attributes.FileMode != 0600 {
t.Fatal("mode not saved")
}
case "chown":
if fake.entry.Attributes.Uid != 1001 || fake.entry.Attributes.Gid != 21 {
t.Fatal("owner not saved")
}
case "utimes":
if fake.entry.Attributes.Mtime != 123 || fake.entry.Attributes.MtimeNs != 456 {
t.Fatal("mtime not saved")
}
case "setxattr":
if string(fake.entry.Extended["xattr-user.test"]) != "new" {
t.Fatal("xattr not saved")
}
case "removexattr":
if _, exists := fake.entry.Extended["xattr-user.remove"]; exists {
t.Fatal("xattr not removed")
}
}
if string(fake.entry.Extended["xattr-user.other"]) != "keep" {
t.Fatal("unrelated xattr lost")
}
if op != "chown" && (fake.entry.Attributes.Uid != 2000 || fake.entry.Attributes.Gid != 20) {
t.Fatal("unrelated owner or uid mapping lost")
}
})
}
}
}
}
// The filer holds the attribute RPC while the real WRITE handler dirties the
// same clean handle. Its subsequent flush must keep both the acknowledged
// attribute and the buffered bytes. The upload adapter stores bytes in memory.
func TestAcknowledgedAttributeSurvivesWriteDuringRPC(t *testing.T) {
for _, op := range []string{"chmod", "chown", "utimes", "setxattr", "removexattr"} {
t.Run(op, func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
wfs.option.ConcurrentWriters = 1
wfs.concurrentWriters = util.NewLimitedConcurrentExecutor(1)
current := attributeAppendEntry(1)
fake := &attributeCASFiler{entry: proto.Clone(current).(*filer_pb.Entry)}
entered, resume := make(chan struct{}), make(chan struct{})
var once sync.Once
t.Cleanup(func() { once.Do(func() { close(resume) }) })
fake.beforeUpdate = func(n int, e *filer_pb.Entry) {
if n == 1 {
close(entered)
<-resume
}
}
startFakeFiler(t, wfs, fake)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
local := proto.Clone(current).(*filer_pb.Entry)
wfs.mapPbIdFromFilerToLocal(local)
fh := newFileHandle(wfs, 1, 42, local)
fh.RememberPath(path)
wfs.fhMap.inode2fh[42], wfs.fhMap.fh2inode[1] = fh, 42
save := &flakySave{}
fh.dirtyPages.randomWriter.(*ChunkedDirtyPages).saveDataAsChunk = save.saveDataAsChunk
t.Cleanup(fh.dirtyPages.Destroy)
done := make(chan fuse.Status, 1)
go func() {
if op == "setxattr" {
done <- wfs.SetXAttr(nil, &fuse.SetXAttrIn{InHeader: fuse.InHeader{NodeId: 42}}, "user.test", []byte("acknowledged"))
} else if op == "removexattr" {
done <- wfs.RemoveXAttr(nil, &fuse.InHeader{NodeId: 42}, "user.remove")
} else {
in := &fuse.SetAttrIn{}
in.NodeId = 42
switch op {
case "chmod":
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
case "chown":
in.Valid, in.Owner.Uid, in.Owner.Gid = fuse.FATTR_UID|fuse.FATTR_GID, 1001, 21
case "utimes":
in.Valid, in.Mtime, in.Mtimensec = fuse.FATTR_MTIME, 123, 456
}
done <- wfs.SetAttr(nil, in, &fuse.AttrOut{})
}
}()
select {
case <-entered:
case <-time.After(10 * time.Second):
t.Fatal("attribute RPC never arrived")
}
payload := []byte("background write while attribute RPC waits")
in := &fuse.WriteIn{}
in.NodeId, in.Fh, in.Offset, in.Size = 42, 1, 256, uint32(len(payload))
if n, code := wfs.Write(nil, in, payload); code != fuse.OK || n != uint32(len(payload)) {
t.Fatalf("WRITE: %d %v", n, code)
}
once.Do(func() { close(resume) })
select {
case code := <-done:
if code != fuse.OK {
t.Fatalf("attribute: %v", code)
}
case <-time.After(10 * time.Second):
t.Fatal("attribute RPC did not finish")
}
if code := wfs.doFlush(context.Background(), fh, 1000, 20, false); code != fuse.OK {
t.Fatalf("flush: %v", code)
}
fake.mu.Lock()
defer fake.mu.Unlock()
switch op {
case "chmod":
if fake.entry.Attributes.FileMode != 0600 {
t.Fatal("flush undid acknowledged chmod")
}
case "chown":
if fake.entry.Attributes.Uid != 1001 || fake.entry.Attributes.Gid != 21 {
t.Fatal("flush undid acknowledged chown")
}
case "utimes":
if fake.entry.Attributes.Mtime != 123 || fake.entry.Attributes.MtimeNs != 456 {
t.Fatal("flush undid acknowledged utimes")
}
case "setxattr":
if string(fake.entry.Extended["xattr-user.test"]) != "acknowledged" {
t.Fatal("flush undid acknowledged setxattr")
}
case "removexattr":
if _, exists := fake.entry.Extended["xattr-user.remove"]; exists {
t.Fatal("flush undid acknowledged removexattr")
}
}
if len(save.bodies) != 1 || save.bodies[0] != string(payload) {
t.Fatalf("pending bytes changed: %q", save.bodies)
}
if fake.entry.Attributes.FileSize != 256+uint64(len(payload)) || len(fake.entry.Chunks) != 2 {
t.Fatalf("pending content lost: %v", fake.entry)
}
original, appended := false, false
for _, chunk := range fake.entry.Chunks {
switch chunk.Offset {
case 0:
original = chunk.GetFileIdString() == current.Chunks[0].GetFileIdString() && chunk.Size == 256
case 256:
appended = chunk.GetFileIdString() == "1,01" && chunk.Size == uint64(len(payload))
}
}
if !original || !appended {
t.Fatalf("chunk identity/range changed: %v", fake.entry.Chunks)
}
})
}
}
// Force owner-commit -> concurrent WRITE -> whole-entry flush -> RPC response.
// The corrected caller must serialize publication across the entire interval.
func TestAttributePublicationBeforeResponse(t *testing.T) {
for _, contention := range []string{"response", "installation", "retention"} {
for _, op := range []string{"chmod", "chown", "utimes", "setxattr", "removexattr"} {
t.Run(contention+"/"+op, func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
wfs.option.ConcurrentWriters = 1
wfs.concurrentWriters = util.NewLimitedConcurrentExecutor(1)
current := attributeAppendEntry(1)
fake := &attributeCASFiler{entry: proto.Clone(current).(*filer_pb.Entry)}
entered, resume := make(chan struct{}), make(chan struct{})
var once sync.Once
t.Cleanup(func() { once.Do(func() { close(resume) }) })
fake.afterCommit = func() { close(entered); <-resume }
startFakeFiler(t, wfs, fake)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
local := proto.Clone(current).(*filer_pb.Entry)
wfs.mapPbIdFromFilerToLocal(local)
fh := newFileHandle(wfs, 1, 42, local)
fh.RememberPath(path)
wfs.fhMap.inode2fh[42], wfs.fhMap.fh2inode[1] = fh, 42
save := &flakySave{}
fh.dirtyPages.randomWriter.(*ChunkedDirtyPages).saveDataAsChunk = save.saveDataAsChunk
t.Cleanup(fh.dirtyPages.Destroy)
done := make(chan fuse.Status, 1)
go func() {
if op == "setxattr" {
done <- wfs.SetXAttr(nil, &fuse.SetXAttrIn{InHeader: fuse.InHeader{NodeId: 42}}, "user.test", []byte("acknowledged"))
} else if op == "removexattr" {
done <- wfs.RemoveXAttr(nil, &fuse.InHeader{NodeId: 42}, "user.remove")
} else {
in := &fuse.SetAttrIn{}
in.NodeId = 42
switch op {
case "chmod":
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
case "chown":
in.Valid, in.Owner.Uid, in.Owner.Gid = fuse.FATTR_UID|fuse.FATTR_GID, 1001, 21
case "utimes":
in.Valid, in.Mtime, in.Mtimensec = fuse.FATTR_MTIME, 123, 456
}
done <- wfs.SetAttr(nil, in, &fuse.AttrOut{})
}
}()
select {
case <-entered:
case <-time.After(10 * time.Second):
t.Fatal("attribute RPC never arrived")
}
payload := []byte("background write while attribute RPC waits")
in := &fuse.WriteIn{}
in.NodeId, in.Fh, in.Offset, in.Size = 42, 1, 256, uint32(len(payload))
if n, code := wfs.Write(nil, in, payload); code != fuse.OK || n != uint32(len(payload)) {
t.Fatalf("WRITE: %d %v", n, code)
}
flushDone := make(chan fuse.Status, 1)
flushSettled, publicationEscaped := false, false
flush := func() { flushDone <- wfs.doFlush(context.Background(), fh, 1000, 20, false) }
// Probe ownership while the committed response is held. On the old caller
// no publication boundary exists, so force its whole-entry flush to commit
// BEFORE releasing the response. With coordination, queue that same flush
// behind the attribute and force contention at installation/retention.
table := wfs.metadataPublicationTable()
probe, unguarded := table.TryAcquireLock("test publication witness", path, util.ExclusiveLock)
if unguarded {
table.ReleaseLock(path, probe)
flush()
if code := <-flushDone; code != fuse.OK {
t.Fatalf("pre-response flush: %v", code)
}
once.Do(func() { close(resume) })
} else {
release := func() {}
if contention == "installation" {
active := wfs.fhLockTable.AcquireLock("test installation contention", fh.fh, util.ExclusiveLock)
release = func() { wfs.fhLockTable.ReleaseLock(fh.fh, active) }
} else if contention == "retention" {
fh.entryLock.Lock()
release = fh.entryLock.Unlock
}
go flush()
// Do not open the RPC response gate merely because flush was started:
// witness this caller actually asleep on the publication condition.
if blocked, code := waitFlushPublication(t, flushDone); !blocked {
flushSettled, publicationEscaped = true, true
if code != fuse.OK {
t.Errorf("flush before response gate: %v", code)
}
}
once.Do(func() { close(resume) })
if contention != "response" {
frame := "installAckedEntry"
if contention == "retention" {
frame = "retainAcknowledgedAttributes"
}
// A stack witness proves the real caller reached the held lock. Time
// only bounds a broken test; no latency threshold decides correctness.
blocked := waitAttributeFrame(frame)
if blocked && !flushSettled {
// Check again before releasing installation/retention. A prematurely
// unlocked publication lets the real flush finish while this gate is
// held: join that flush BEFORE retention, forcing the lost-attr order.
if waiting, code := waitFlushPublication(t, flushDone); !waiting {
flushSettled, publicationEscaped = true, true
if code != fuse.OK {
t.Errorf("flush before %s gate: %v", contention, code)
}
}
}
release()
if !blocked {
t.Fatalf("caller never contended at %s", frame)
}
}
}
select {
case code := <-done:
if code != fuse.OK {
t.Fatalf("attribute: %v", code)
}
case <-time.After(10 * time.Second):
t.Fatal("attribute did not finish")
}
if !unguarded && !flushSettled {
select {
case code := <-flushDone:
if code != fuse.OK {
t.Fatalf("flush: %v", code)
}
case <-time.After(10 * time.Second):
t.Fatal("flush did not finish")
}
}
fake.mu.Lock()
defer fake.mu.Unlock()
switch op {
case "chmod":
if fake.entry.Attributes.FileMode != 0600 {
t.Fatal("flush undid acknowledged chmod")
}
case "chown":
if fake.entry.Attributes.Uid != 1001 || fake.entry.Attributes.Gid != 21 {
t.Fatal("flush undid acknowledged chown")
}
case "utimes":
if fake.entry.Attributes.Mtime != 123 || fake.entry.Attributes.MtimeNs != 456 {
t.Fatal("flush undid acknowledged utimes")
}
case "setxattr":
if string(fake.entry.Extended["xattr-user.test"]) != "acknowledged" {
t.Fatal("flush undid acknowledged setxattr")
}
case "removexattr":
if _, exists := fake.entry.Extended["xattr-user.remove"]; exists {
t.Fatal("flush undid acknowledged removexattr")
}
}
if len(save.bodies) != 1 || save.bodies[0] != string(payload) {
t.Fatalf("pending bytes changed: %q", save.bodies)
}
if fake.entry.Attributes.FileSize != 256+uint64(len(payload)) || len(fake.entry.Chunks) != 2 {
t.Fatalf("pending content lost: %v", fake.entry)
}
original, appended := false, false
for _, chunk := range fake.entry.Chunks {
switch chunk.Offset {
case 0:
original = chunk.GetFileIdString() == current.Chunks[0].GetFileIdString() && chunk.Size == 256
case 256:
appended = chunk.GetFileIdString() == "1,01" && chunk.Size == uint64(len(payload))
}
}
if !original || !appended {
t.Fatalf("chunk identity/range changed: %v", fake.entry.Chunks)
}
if publicationEscaped {
t.Fatal("flush escaped publication before its attribute gate opened")
}
})
}
}
}
func waitAttributeFrame(frame string) bool {
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
if strings.Contains(string(buf[:n]), "."+frame+"(") {
return true
}
runtime.Gosched()
}
return false
}
// Require the flush goroutine's waiting state AND both publication frames.
// Seeing a frame alone also admits a runnable waiter awakened by an early
// unlock. Broadcast makes that goroutine runnable before retention is reached,
// so the early-unlock mutant must progress to its real CreateEntry instead.
func waitFlushPublication(t *testing.T, done <-chan fuse.Status) (bool, fuse.Status) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
select {
case code := <-done:
return false, code
default:
}
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
for _, stack := range strings.Split(string(buf[:n]), "\n\n") {
header, _, _ := strings.Cut(stack, "\n")
if strings.Contains(header, "[sync.Cond.Wait]") &&
strings.Contains(stack, ".flushMetadataToFiler(") && strings.Contains(stack, ".lockMetadataPublication(") {
t.Log("flush arrived and is blocked at publication before opening gate")
return true, fuse.OK
}
}
runtime.Gosched()
}
t.Error("flush neither blocked at publication nor settled")
return false, fuse.EIO
}
// The filer applies the update even though the caller's syscall was
// interrupted while the RPC was in flight.
type interruptingAttributeFiler struct {
*attributeCASFiler
once sync.Once
interrupt chan struct{}
}
func (s *interruptingAttributeFiler) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
s.once.Do(func() { close(s.interrupt) })
select {
case <-ctx.Done():
case <-time.After(100 * time.Millisecond):
}
return s.attributeCASFiler.UpdateEntry(context.Background(), req)
}
func TestAttributeSaveSurvivesInterrupt(t *testing.T) {
for _, op := range []string{"chmod", "setxattr"} {
t.Run(op, func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
current := attributeAppendEntry(300)
fake := &interruptingAttributeFiler{attributeCASFiler: &attributeCASFiler{entry: proto.Clone(current).(*filer_pb.Entry)}, interrupt: make(chan struct{})}
startFakeFiler(t, wfs, fake)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(util.FullPath("/dir"), 1, true, false, 0, false)
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
wfs.inodeToPath.MarkChildrenCached(util.FullPath("/dir"))
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", current), 1); err != nil {
t.Fatal(err)
}
var code fuse.Status
if op == "setxattr" {
in := &fuse.SetXAttrIn{}
in.NodeId = 42
code = wfs.SetXAttr(fake.interrupt, in, "user.test", []byte("new"))
} else {
in := &fuse.SetAttrIn{}
in.NodeId = 42
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
code = wfs.SetAttr(fake.interrupt, in, &fuse.AttrOut{})
}
if code != fuse.OK {
t.Fatalf("interrupted %s reported %v for an update the filer applied", op, code)
}
cached, _, err := wfs.metaCache.FindEntry(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if op == "chmod" && cached.Attr.Mode.Perm() != 0600 {
t.Fatalf("local cache mode %v, want 0600", cached.Attr.Mode.Perm())
}
if op == "setxattr" && string(cached.Extended["xattr-user.test"]) != "new" {
t.Fatal("local cache missing the saved xattr")
}
})
}
}
// The test mount maps local uid 1000 to filer uid 2000, so a file the filer
// records as uid 1000 reads back as local 1000 and maps forward to 2000.
func TestAttributeSaveKeepsCollidingFilerIds(t *testing.T) {
for _, op := range []string{"chmod", "setxattr"} {
t.Run(op, func(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
current := attributeAppendEntry(3)
current.Attributes.Uid = 1000
fake := &attributeCASFiler{entry: proto.Clone(current).(*filer_pb.Entry)}
startFakeFiler(t, wfs, fake)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(util.FullPath("/dir"), 1, true, false, 0, false)
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
wfs.inodeToPath.MarkChildrenCached(util.FullPath("/dir"))
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", current), 1); err != nil {
t.Fatal(err)
}
var code fuse.Status
if op == "setxattr" {
in := &fuse.SetXAttrIn{}
in.NodeId = 42
code = wfs.SetXAttr(nil, in, "user.test", []byte("new"))
} else {
in := &fuse.SetAttrIn{}
in.NodeId = 42
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
code = wfs.SetAttr(nil, in, &fuse.AttrOut{})
}
if code != fuse.OK {
t.Fatalf("%s on a colliding uid: %v", op, code)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.entry.Attributes.Uid != 1000 {
t.Fatalf("%s rewrote the filer uid to %d", op, fake.entry.Attributes.Uid)
}
cached, _, err := wfs.metaCache.FindEntry(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if cached.Attr.Uid != 1000 {
t.Fatalf("local cache uid %d, want 1000", cached.Attr.Uid)
}
})
}
}
// The seed filer has not applied the owner's latest update yet and forwards
// conditional writes to the owner, as filers do under the lock ring.
type laggingSeedFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
stale *filer_pb.Entry
owner *attributeCASFiler
}
func (s *laggingSeedFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(s.stale).(*filer_pb.Entry)}, nil
}
func (s *laggingSeedFiler) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
return s.owner.UpdateEntry(ctx, req)
}
func TestAttributeRetryReadsOwnerFiler(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.atimeMap = make(map[uint64]time.Time)
stale := attributeAppendEntry(1)
owner := &attributeCASFiler{entry: attributeAppendEntry(300)}
startFakeFiler(t, wfs, owner)
ownerAddress := wfs.option.FilerAddresses[0]
startFakeFiler(t, wfs, &laggingSeedFiler{stale: stale, owner: owner})
wfs.lockClient = cluster.NewLockClient(nil, wfs.option.FilerAddresses[0])
wfs.lockClient.SetRing([]pb.ServerAddress{ownerAddress}, 1)
path := util.FullPath("/dir/file")
wfs.inodeToPath.Lookup(util.FullPath("/dir"), 1, true, false, 0, false)
wfs.inodeToPath.Lookup(path, 1, false, false, 42, true)
wfs.inodeToPath.MarkChildrenCached(util.FullPath("/dir"))
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", stale), 1); err != nil {
t.Fatal(err)
}
in := &fuse.SetAttrIn{}
in.NodeId = 42
in.Valid, in.Mode = fuse.FATTR_MODE, 0600
if code := wfs.SetAttr(nil, in, &fuse.AttrOut{}); code != fuse.OK {
t.Fatalf("chmod behind a lagging seed filer: %v", code)
}
owner.mu.Lock()
defer owner.mu.Unlock()
if len(owner.entry.Chunks) != 300 || owner.entry.Attributes.FileMode&0777 != 0600 {
t.Fatalf("owner entry: %d chunks, mode %o", len(owner.entry.Chunks), owner.entry.Attributes.FileMode)
}
}