mount: guard peer-truncate gaps, stale append EOF, and terminal writeback refusal (#12630)

* mount: guard append gaps over concurrent content merges

* mount: fence a writeback inode when its async publication is abandoned

A drain that gives up on a non-precondition error left the token clear, so a
reopened writeback handle could publish past the stored EOF.

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

* mount: fence a writeback inode when its async data flush fails

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

* mount: fence the inode when its last handle drops uncommitted content

A synchronous release whose final flush fails destroys the handle's dirty
pages, while under writeback the kernel EOF already counts them.

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>
This commit is contained in:
authored and GitHub committed 2026-11-14 01:13:37 -08:00
1 parent b50d4a9cab
commit 10f8b2d5b1
15 files changed
+1075 -22

No files matched your search

+1
View File
@@ -0,0 +1 @@
Concurrent writers to one file from different mounts require `-dlm`; without it, a conflicting write fails with EIO and the file may refuse further appends until reopened after the cache drops.
+81
View File
@@ -0,0 +1,81 @@
package mount
import (
"fmt"
"sort"
"sync/atomic"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// A publication owner carries provider refusal evidence through every retry.
// Helpers and the periodic flusher never retire content that is still owned.
type contentPublication struct {
refused bool
failure *atomic.Bool
}
func (p *contentPublication) retire(err error) {
if err != nil && p.refused {
p.abandon()
}
}
// An abandoned writeback drain leaves the kernel EOF past the stored one,
// whatever the filer answered.
func (p *contentPublication) abandon() {
if p.failure != nil {
p.failure.Store(true)
}
}
func (wfs *WFS) checkContentPublication(fh *FileHandle, base *filer_pb.Entry) error {
if wfs.option.WritebackCache && (fh.writebackContentError == nil || fh.writebackContentError.Load()) {
return fmt.Errorf("writeback inode awaits Forget after content refusal: %w", filer_pb.ErrPreconditionFailed)
}
// This guard shares the CAS version with CreateEntry. A rewritten or
// manifest bounding range cannot conceal the original kernel WRITE frontier.
var eof uint64
if base != nil {
eof = filer.FileSize(base)
}
if !wfs.option.WritebackCache && fh.pendingAppend && fh.pendingAppendOffset > eof {
return fmt.Errorf("append offset %d exceeds committed EOF %d: %w", fh.pendingAppendOffset, eof, filer_pb.ErrPreconditionFailed)
}
return nil
}
// Only used when a peer shrank the file: a merge must not acknowledge a gap
// between that stored EOF and local pending content. Ordinary sparse pwrite
// without a concurrent truncate is unaffected. Compare copies, never reorder
// a handle's chunks. A manifest's bounding range is not proof of coverage.
func contentGapAfter(entry *filer_pb.Entry, start uint64) bool {
end := filer.FileSize(entry)
if start >= end {
return false
}
chunks := append([]*filer_pb.FileChunk(nil), entry.Chunks...)
sort.SliceStable(chunks, func(i, j int) bool { return chunks[i].Offset < chunks[j].Offset })
covered := start
if uint64(len(entry.Content)) > covered {
covered = uint64(len(entry.Content))
}
for _, c := range chunks {
if covered >= end {
return false
}
if c.Offset < 0 || c.Size > ^uint64(0)-uint64(c.Offset) {
return true
}
left, right := uint64(c.Offset), uint64(c.Offset)+c.Size
if right <= covered {
continue
}
if left > covered || c.IsChunkManifest {
return true
}
covered = right
}
return covered < end
}
+212
View File
@@ -0,0 +1,212 @@
//go:build !freebsd
package mount
import (
"bytes"
"context"
"syscall"
"testing"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/protobuf/proto"
)
// These caller controls measure published bytes; none imports #12608 internals.
func masterAssertPeerMergeOutcome(t *testing.T, periodic bool) {
for _, scenario := range []string{"peer-append", "peer-replaced-file", "peer-truncate"} {
t.Run(scenario, func(t *testing.T) {
base := attributeAppendEntry(1)
stored := attributeAppendEntry(2)
pending := attributeAppendEntry(2)
pending.Chunks[1].Fid.FileKey = 3
pending.Chunks[1].Offset = 512
pending.Attributes.FileSize = 768
blobs := map[string][]byte{}
for i, c := range stored.Chunks {
blobs[c.GetFileIdString()] = bytes.Repeat([]byte{[]byte{'O', 'P'}[i]}, 256)
}
blobs[pending.Chunks[1].GetFileIdString()] = bytes.Repeat([]byte{'C'}, 256)
switch scenario {
case "peer-replaced-file":
stored = attributeAppendEntry(1)
stored.Attributes.Inode = 99
stored.Chunks[0].Fid.FileKey = 900
blobs[stored.Chunks[0].GetFileIdString()] = bytes.Repeat([]byte{'R'}, 256)
case "peer-truncate":
base = attributeAppendEntry(2)
stored = attributeAppendEntry(1)
pending = attributeAppendEntry(3)
blobs[pending.Chunks[2].GetFileIdString()] = bytes.Repeat([]byte{'C'}, 256)
blobs[base.Chunks[1].GetFileIdString()] = bytes.Repeat([]byte{'A'}, 256)
}
stored.Attributes.FileMode, stored.CommitTsNs = 0600, 20000
original := proto.Clone(stored).(*filer_pb.Entry)
peer := &g6ConditionalPeer{entry: proto.Clone(stored).(*filer_pb.Entry), blobs: blobs}
w := newInvalidateTestWFS(t)
startFakeFiler(t, w, peer)
w.mapPbIdFromFilerToLocal(base)
w.mapPbIdFromFilerToLocal(pending)
fh := newFileHandle(w, 1, 42, pending)
t.Cleanup(fh.dirtyPages.Destroy)
fh.RememberPath("/dir/file")
fh.baseEntry.Store(base)
fh.dirtyMetadata = true
code := fuse.OK
if periodic {
if err := w.flushFileMetadata(fh); err != nil {
code = fuse.EIO
t.Logf("periodic error: %v", err)
}
} else {
code = w.doFlush(context.Background(), fh, 1000, 20, false, 0)
}
peer.mu.Lock()
defer peer.mu.Unlock()
actual := g6StoredBytes(t, peer)
t.Logf("scenario=%s status=%v size=%d rejected=%d gap[256,512)-zero=%v", scenario, code, len(actual), peer.rejected, len(actual) >= 512 && bytes.Equal(actual[256:512], make([]byte, 256)))
if scenario == "peer-replaced-file" {
if code != fuse.EIO || !g6EntryEqual(peer.entry, original) || !bytes.Equal(actual, bytes.Repeat([]byte{'R'}, 256)) {
t.Fatal("replacement must be refused without publishing stale content")
}
return
}
if scenario == "peer-truncate" && code == fuse.EIO {
if !g6EntryEqual(peer.entry, original) || !bytes.Equal(actual, bytes.Repeat([]byte{'O'}, 256)) {
t.Fatal("refusal changed peer truncate")
}
return
}
want := bytes.Repeat([]byte{'O'}, 256)
if scenario == "peer-append" {
want = append(want, bytes.Repeat([]byte{'P'}, 256)...)
}
want = append(want, bytes.Repeat([]byte{'C'}, 256)...)
if code != fuse.OK || peer.entry.Attributes.FileMode != 0600 || peer.entry.Attributes.FileSize != uint64(len(want)) || !bytes.Equal(actual, want) {
t.Fatalf("unsafe merge: status=%v size=%d want-size=%d exact-bytes=%v; peer/local bytes must survive without a gap", code, len(actual), len(want), bytes.Equal(actual, want))
}
})
}
}
func TestMasterCommittedEOFAppendHasNoGap(t *testing.T) {
for _, appendOpen := range []bool{true, false} {
name := "append"
if !appendOpen {
name = "intentional-sparse-pwrite"
}
t.Run(name, func(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, h := g6AppendHandle(t, peer, base, 2100)
if !appendOpen {
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY}, out); code != fuse.OK {
t.Fatal(code)
}
h = out.Fh
}
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h, Offset: 512, Size: 256}, bytes.Repeat([]byte{'B'}, 256)); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h})
peer.mu.Lock()
defer peer.mu.Unlock()
actual := g6StoredBytes(t, peer)
if appendOpen {
if code != fuse.EIO || peer.entry.Attributes.FileSize != 256 || !bytes.Equal(actual, bytes.Repeat([]byte{'O'}, 256)) {
t.Fatalf("stale append frontier published a gap: status=%v size=%d interval[256,512)-zero=%v", code, len(actual), len(actual) >= 512 && bytes.Equal(actual[256:512], make([]byte, 256)))
}
} else {
want := append(bytes.Repeat([]byte{'O'}, 256), make([]byte, 256)...)
want = append(want, bytes.Repeat([]byte{'B'}, 256)...)
if code != fuse.OK || !bytes.Equal(actual, want) || peer.entry.Attributes.FileSize != 768 {
t.Fatal("intentional sparse pwrite must remain allowed", code, len(actual))
}
}
})
}
}
func TestMasterConflictThenAppendCannotPublishGap(t *testing.T) {
for _, mode := range []struct {
name string
writeback, coalesced bool
}{{"normal", false, false}, {"writeback", true, false}, {"writeback-coalesced", true, true}} {
t.Run(mode.name, func(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, h := g6AppendHandle(t, peer, base, 2200, mode.writeback)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h, Offset: 256, Size: 256}, bytes.Repeat([]byte{'A'}, 256)); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
// Model the native compactor's output, including independently uploaded
// bytes and the rewrite marker: a single chunk containing original O+A.
// No product implementation is supplied or changed by this fixture.
fh := w.GetHandle(FileHandleId(h))
if err := fh.dirtyPages.FlushData(); err != nil {
t.Fatal(err)
}
compacted := &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: 8000, Cookie: 1}, Size: 512, ModifiedTsNs: 100}
peer.mu.Lock()
peer.blobs[compacted.GetFileIdString()] = append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'A'}, 256)...)
peer.mu.Unlock()
fh.UpdateEntry(func(e *filer_pb.Entry) {
e.Chunks = []*filer_pb.FileChunk{proto.Clone(compacted).(*filer_pb.FileChunk)}
e.Attributes.FileSize = 512
})
fh.noteRewrite()
replacement := attributeAppendEntry(1)
replacement.Chunks[0].Fid.FileKey = 900
replacement.Attributes.FileMode, replacement.CommitTsNs = 0600, 20000
peer.mu.Lock()
peer.entry = proto.Clone(replacement).(*filer_pb.Entry)
peer.clock = 20000
peer.blobs[replacement.Chunks[0].GetFileIdString()] = bytes.Repeat([]byte{'P'}, 256)
peer.mu.Unlock()
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h}); code != fuse.EIO {
t.Fatal("setup: native real rewrite conflict must be refused", code)
}
w.Release(nil, &fuse.ReleaseIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h, ReleaseFlags: fuse.FUSE_RELEASE_FLOCK_UNLOCK})
if _, kept := w.fhMap.FindFileHandle(42); kept {
t.Fatal("failed synchronous close retained handle")
}
peer.mu.Lock()
unchanged := g6EntryEqual(peer.entry, replacement)
peer.mu.Unlock()
if !unchanged {
t.Fatal("refused close published over peer")
}
if err := w.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", proto.Clone(replacement).(*filer_pb.Entry)), 20000); err != nil {
t.Fatal(err)
}
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY | syscall.O_APPEND}, out); code != fuse.OK {
t.Fatal(code)
}
g6AppendUploader(t, w.GetHandle(FileHandleId(out.Fh)), 2300, peer)
offset := uint64(512)
payload := bytes.Repeat([]byte{'B'}, 256)
if mode.coalesced {
offset = 0
payload = append(bytes.Repeat([]byte{'P'}, 256), make([]byte, 256)...)
payload = append(payload, bytes.Repeat([]byte{'B'}, 256)...)
}
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh, Offset: offset, Size: uint32(len(payload))}, payload); n != uint32(len(payload)) || code != fuse.OK {
t.Fatal(n, code)
}
code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh})
peer.mu.Lock()
defer peer.mu.Unlock()
actual := g6StoredBytes(t, peer)
if code != fuse.EIO || peer.entry.Attributes.FileSize != 256 || !bytes.Equal(actual, bytes.Repeat([]byte{'P'}, 256)) {
t.Fatalf("terminal conflict followed by false success/gap: status=%v size=%d interval[256,512)-zero=%v", code, len(actual), len(actual) >= 512 && bytes.Equal(actual[256:512], make([]byte, 256)))
}
})
}
}
func TestContentGapPeerMerge(t *testing.T) { masterAssertPeerMergeOutcome(t, false) }
func TestContentGapPeriodicPeerMerge(t *testing.T) { masterAssertPeerMergeOutcome(t, true) }
+600
View File
@@ -0,0 +1,600 @@
//go:build !freebsd
package mount
import (
"bytes"
"context"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"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"
"io"
"os"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
func g6AppendHandle(t *testing.T, peer *g6ConditionalPeer, base *filer_pb.Entry, key uint64, writeback ...bool) (*WFS, uint64) {
t.Helper()
w := newInvalidateTestWFS(t)
w.option.CacheDirForWrite = t.TempDir()
w.option.DefaultPermissions = true
w.option.WritebackCache = len(writeback) > 0 && writeback[0]
w.posixLocks = NewPosixLockTable()
w.option.ConcurrentWriters = 1
w.concurrentWriters = util.NewLimitedConcurrentExecutor(1)
startFakeFiler(t, w, peer)
w.inodeToPath.Lookup("/dir", 1, true, false, 0, false)
w.inodeToPath.Lookup("/dir/file", 1, false, false, 42, true)
w.inodeToPath.MarkChildrenCached("/dir")
if err := w.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", proto.Clone(base).(*filer_pb.Entry)), 1); err != nil {
t.Fatal(err)
}
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY | syscall.O_APPEND}, out); code != fuse.OK {
t.Fatal(code)
}
fh := w.GetHandle(FileHandleId(out.Fh))
peer.mu.Lock()
if peer.blobs == nil {
peer.blobs = make(map[string][]byte)
}
peer.blobs[base.Chunks[0].GetFileIdString()] = bytes.Repeat([]byte{'O'}, 256)
peer.mu.Unlock()
g6AppendUploader(t, fh, key, peer)
return w, out.Fh
}
func g6AppendUploader(t *testing.T, fh *FileHandle, key uint64, peer *g6ConditionalPeer) {
t.Helper()
t.Cleanup(fh.dirtyPages.Destroy)
fh.dirtyPages.randomWriter.(*ChunkedDirtyPages).saveDataAsChunk = func(util.FullPath) filer.SaveDataAsChunkFunctionType {
return func(r io.Reader, _ string, offset, ts int64, _ uint64) (*filer_pb.FileChunk, error) {
body, err := io.ReadAll(r)
if err != nil {
return nil, err
}
key++
chunk := &filer_pb.FileChunk{Fid: &filer_pb.FileId{VolumeId: 1, FileKey: key, Cookie: 1}, Offset: offset, Size: uint64(len(body)), ModifiedTsNs: ts}
peer.mu.Lock()
peer.blobs[chunk.GetFileIdString()] = append([]byte(nil), body...)
peer.mu.Unlock()
return chunk, nil
}
}
}
func TestAppendAfterRefusedFlushHasNoHole(t *testing.T) {
base := attributeAppendEntry(1)
base.CommitTsNs = 100
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, handle := g6AppendHandle(t, peer, base, 100)
write := func(w *WFS, handle, offset uint64, value byte) {
t.Helper()
data := bytes.Repeat([]byte{value}, 256)
n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, Offset: offset, Size: 256}, data)
if n != 256 || code != fuse.OK {
t.Fatalf("write: %d %v", n, code)
}
}
write(w, handle, 256, 'A')
// A peer chmod changes only attributes after this handle observed its base.
peer.mu.Lock()
peer.entry.Attributes.FileMode = 0600
peer.entry.CommitTsNs++
peer.mu.Unlock()
first := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle})
t.Logf("first fsync=%v", first)
w.Release(nil, &fuse.ReleaseIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, ReleaseFlags: fuse.FUSE_RELEASE_FLOCK_UNLOCK})
peer.mu.Lock()
latest := proto.Clone(peer.entry).(*filer_pb.Entry)
peer.mu.Unlock()
if err := w.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", latest), 3000); err != nil {
t.Fatal(err)
}
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY | syscall.O_APPEND}, out); code != fuse.OK {
t.Fatal(code)
}
next, nextHandle := w, out.Fh
g6AppendUploader(t, w.GetHandle(FileHandleId(nextHandle)), 200, peer)
// FUSE WRITE offsets are supplied by the kernel; its cached EOF advanced
// on the first successful WRITE even when that handle's fsync failed.
write(next, nextHandle, 512, 'B')
if code := next.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: nextHandle}); code != fuse.OK {
t.Fatal(code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'A'}, 256)...)
want = append(want, bytes.Repeat([]byte{'B'}, 256)...)
if peer.entry.Attributes.FileSize != 768 || peer.entry.Attributes.FileMode != 0600 || !bytes.Equal(g6StoredBytes(t, peer), want) {
t.Fatal("original/A/B bytes, size or peer mode changed")
}
for _, offset := range []int64{0, 256, 512} {
covered := false
for _, c := range peer.entry.Chunks {
if c.Offset <= offset && c.Offset+int64(c.Size) >= offset+256 {
covered = true
}
}
if !covered {
t.Fatalf("fsync acknowledged a hole [%d,%d): first=%v chunks=%v", offset, offset+256, first, peer.entry.Chunks)
}
}
}
type g6ConditionalPeer struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.Mutex
entry *filer_pb.Entry
rejected int
refuse bool
blobs map[string][]byte
clock int64
beforeCreate func(context.Context)
}
func g6EntryEqual(current, expected *filer_pb.Entry) bool {
actual := filer.FromPbEntry("", proto.Clone(current).(*filer_pb.Entry)).ToProtoEntry()
want := proto.Clone(expected).(*filer_pb.Entry)
filer_pb.BeforeEntrySerialization(actual.Chunks)
filer_pb.BeforeEntrySerialization(want.Chunks)
return proto.Equal(actual, filer.FromPbEntry("", want).ToProtoEntry())
}
func g6StoredBytes(t *testing.T, s *g6ConditionalPeer) []byte {
t.Helper()
result := make([]byte, s.entry.Attributes.FileSize)
for _, c := range s.entry.Chunks {
body, ok := s.blobs[c.GetFileIdString()]
if !ok || uint64(len(body)) != c.Size || c.Offset < 0 || uint64(c.Offset)+c.Size > uint64(len(result)) {
t.Fatalf("invalid stored reference %v", c)
}
copy(result[c.Offset:], body)
}
return result
}
func (s *g6ConditionalPeer) LookupDirectoryEntry(context.Context, *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(s.entry).(*filer_pb.Entry)}, nil
}
func (s *g6ConditionalPeer) UpdateEntry(_ context.Context, r *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range r.GetCondition().GetClauses() {
if c.Kind != filer_pb.WriteCondition_IF_ENTRY_EQUAL || !g6EntryEqual(s.entry, c.ExpectedEntry) {
return nil, status.Error(codes.FailedPrecondition, "entry changed")
}
}
s.entry = proto.Clone(r.Entry).(*filer_pb.Entry)
s.clock = max(max(s.clock, s.entry.CommitTsNs), 10000) + 1
s.entry.CommitTsNs = s.clock
return &filer_pb.UpdateEntryResponse{LogTsNs: s.clock, MetadataEvent: &filer_pb.SubscribeMetadataResponse{Directory: r.Directory, TsNs: s.clock, EventNotification: &filer_pb.EventNotification{NewEntry: proto.Clone(s.entry).(*filer_pb.Entry)}}}, nil
}
func (s *g6ConditionalPeer) CreateEntry(ctx context.Context, r *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
if s.beforeCreate != nil {
s.beforeCreate(ctx)
}
s.mu.Lock()
defer s.mu.Unlock()
if s.refuse {
s.rejected++
return &filer_pb.CreateEntryResponse{Error: "precondition failed", ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
}
for _, c := range r.GetCondition().GetClauses() {
match := c.Kind == filer_pb.WriteCondition_IF_NOT_EXISTS && s.entry == nil || c.Kind == filer_pb.WriteCondition_IF_ENTRY_EQUAL && s.entry != nil && c.ExpectedEntry != nil && g6EntryEqual(s.entry, c.ExpectedEntry)
if !match {
s.rejected++
return &filer_pb.CreateEntryResponse{Error: "precondition failed", ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
}
}
s.entry = proto.Clone(r.Entry).(*filer_pb.Entry)
s.clock = max(max(s.clock, s.entry.CommitTsNs), 10000) + 1
s.entry.CommitTsNs = s.clock
return &filer_pb.CreateEntryResponse{LogTsNs: s.entry.CommitTsNs, MetadataEvent: &filer_pb.SubscribeMetadataResponse{Directory: r.Directory, TsNs: s.entry.CommitTsNs, EventNotification: &filer_pb.EventNotification{NewEntry: proto.Clone(s.entry).(*filer_pb.Entry)}}}, nil
}
func TestG6AppendFsyncAfterPeerAttribute(t *testing.T) {
for _, op := range []string{"none", "chmod", "chown", "utimes", "setxattr"} {
t.Run(op, func(t *testing.T) {
writer := newInvalidateTestWFS(t)
attr := newInvalidateTestWFS(t)
attr.atimeMap = make(map[uint64]time.Time)
base := attributeAppendEntry(1)
base.CommitTsNs = 100
s := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
s.blobs = map[string][]byte{base.Chunks[0].GetFileIdString(): bytes.Repeat([]byte{'O'}, 256), attributeAppendEntry(2).Chunks[1].GetFileIdString(): bytes.Repeat([]byte{'A'}, 256)}
startFakeFiler(t, writer, s)
startFakeFiler(t, attr, s)
path := util.FullPath("/dir/file")
local := proto.Clone(base).(*filer_pb.Entry)
writer.mapPbIdFromFilerToLocal(local)
fh := newFileHandle(writer, 1, 42, local)
fh.RememberPath(path)
t.Cleanup(fh.dirtyPages.Destroy)
writer.fhMap.inode2fh[42], writer.fhMap.fh2inode[1] = fh, 42
pending := attributeAppendEntry(2).Chunks[1]
fh.UpdateEntry(func(e *filer_pb.Entry) { e.Chunks = append(e.Chunks, pending); e.Attributes.FileSize = 512 })
fh.dirtyMetadata = true
attr.inodeToPath.Lookup("/dir", 1, true, false, 0, false)
attr.inodeToPath.Lookup(path, 1, false, false, 42, true)
attr.inodeToPath.MarkChildrenCached("/dir")
if err := attr.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/dir", base), 1); err != nil {
t.Fatal(err)
}
code := fuse.OK
if op == "setxattr" {
code = attr.SetXAttr(nil, &fuse.SetXAttrIn{InHeader: fuse.InHeader{NodeId: 42}}, "user.test", []byte("peer acknowledged"))
} else if op != "none" {
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
}
code = attr.SetAttr(nil, in, &fuse.AttrOut{})
}
if code != fuse.OK {
t.Fatalf("peer attribute: %v", code)
}
s.mu.Lock()
wantChunk, gotChunk := proto.Clone(base.Chunks[0]).(*filer_pb.FileChunk), proto.Clone(s.entry.Chunks[0]).(*filer_pb.FileChunk)
filer_pb.BeforeEntrySerialization([]*filer_pb.FileChunk{wantChunk, gotChunk})
contentUnchanged := proto.Equal(wantChunk, gotChunk) && len(s.entry.Chunks) == 1
ackedPeer := proto.Clone(s.entry).(*filer_pb.Entry)
s.mu.Unlock()
if !contentUnchanged {
t.Fatal("peer attribute changed content before append publication")
}
if code = writer.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: 1}); code != fuse.OK {
t.Fatalf("append after acknowledged peer %s returned %v; pending=%d base=%d rejected=%d", op, code, len(fh.entry.Chunks), len(fh.baseEntry.Load().Chunks), s.rejected)
}
s.mu.Lock()
defer s.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'A'}, 256)...)
if s.entry.Attributes.FileSize != 512 || !bytes.Equal(g6StoredBytes(t, s), want) {
t.Fatal("append lost original/pending bytes or size")
}
a, b := s.entry.Attributes, ackedPeer.Attributes
if op == "chmod" && a.FileMode != b.FileMode || op == "chown" && (a.Uid != b.Uid || a.Gid != b.Gid) || op == "utimes" && (a.Mtime != b.Mtime || a.MtimeNs != b.MtimeNs) || op == "setxattr" && !bytes.Equal(s.entry.Extended[XATTR_PREFIX+"user.test"], ackedPeer.Extended[XATTR_PREFIX+"user.test"]) {
t.Fatal("append rolled back acknowledged peer attributes")
}
})
}
}
func TestAppendCompatibleCommittedPrefix(t *testing.T) {
w := newInvalidateTestWFS(t)
base, current, pending := attributeAppendEntry(1), attributeAppendEntry(2), attributeAppendEntry(3)
current.Attributes.FileMode = 0600
peer := &g6ConditionalPeer{entry: current, blobs: make(map[string][]byte)}
for i, c := range pending.Chunks {
peer.blobs[c.GetFileIdString()] = bytes.Repeat([]byte{[]byte{'O', 'A', 'B'}[i]}, 256)
}
startFakeFiler(t, w, peer)
w.mapPbIdFromFilerToLocal(base)
w.mapPbIdFromFilerToLocal(pending)
fh := newFileHandle(w, 1, 42, pending)
t.Cleanup(fh.dirtyPages.Destroy)
fh.RememberPath("/dir/file")
fh.baseEntry.Store(base)
fh.dirtyMetadata = true
if code := w.doFlush(context.Background(), fh, 1000, 20, false, 0); code != fuse.OK {
t.Fatal(code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'A'}, 256)...)
want = append(want, bytes.Repeat([]byte{'B'}, 256)...)
if peer.rejected != 1 || peer.entry.Attributes.FileMode != 0600 || peer.entry.Attributes.FileSize != 768 || !bytes.Equal(g6StoredBytes(t, peer), want) {
t.Fatal("prefix rebase lost bytes, size or peer attributes")
}
}
func TestAsyncRefusalAfterForgetDoesNotPoisonRelookup(t *testing.T) {
testAsyncRefusalAfterForget(t, false)
}
func TestAsyncPublicationBlockedBeforeCaptureDoesNotPoisonRelookup(t *testing.T) {
testAsyncRefusalAfterForget(t, true)
}
func testAsyncRefusalAfterForget(t *testing.T, publicationBlocked bool) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry), refuse: true}
entered, resume := make(chan struct{}), make(chan struct{})
var once, resumeOnce sync.Once
release := func() { resumeOnce.Do(func() { close(resume) }) }
peer.beforeCreate = func(ctx context.Context) {
once.Do(func() {
close(entered)
select {
case <-resume:
case <-ctx.Done():
}
})
}
w, handle := g6AppendHandle(t, peer, base, 1000, true)
w.pendingAsyncFlush = make(map[uint64]chan struct{})
w.startAsyncFlushWorkers(1)
t.Cleanup(w.WaitForAsyncFlush)
t.Cleanup(release)
unlockPublication := func() {}
if publicationBlocked {
unlock := w.lockMetadataPublication("/dir/file")
var unlockOnce sync.Once
unlockPublication = func() { unlockOnce.Do(unlock) }
t.Cleanup(unlockPublication)
}
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, Offset: 256, Size: 256}, bytes.Repeat([]byte{'A'}, 256)); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
w.Release(nil, &fuse.ReleaseIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle})
w.pendingAsyncFlushMu.Lock()
done := w.pendingAsyncFlush[42]
w.pendingAsyncFlushMu.Unlock()
if done == nil {
t.Fatal("Release did not queue an async publication")
}
waitEntered := func() {
t.Helper()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("async publication did not reach the filer")
}
}
if publicationBlocked {
if blocked, _ := waitFlushPublication(t, make(chan fuse.Status)); !blocked {
t.Fatal("worker was not blocked before publication capture")
}
} else {
waitEntered()
}
w.Forget(42, 1)
w.inodeToPath.Lookup("/dir/file", 1, false, false, 42, true)
if publicationBlocked {
unlockPublication()
waitEntered()
}
release()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("async refusal did not finish")
}
peer.mu.Lock()
if peer.rejected != 4*(maxContentFlushMerges+1) {
t.Errorf("refusals = %d, want sixteen (four outer attempts, four merge attempts each)", peer.rejected)
}
peer.refuse = false
peer.mu.Unlock()
if _, found := w.fhMap.FindFileHandle(42); found {
t.Fatal("async close retained a handle")
}
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY | syscall.O_APPEND}, out); code != fuse.OK {
t.Fatal(code)
}
g6AppendUploader(t, w.GetHandle(FileHandleId(out.Fh)), 1100, peer)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh, Offset: 256, Size: 256}, bytes.Repeat([]byte{'B'}, 256)); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh}); code != fuse.OK {
t.Fatalf("new inode lifetime inherited stale EIO: %v", code)
}
w.Release(nil, &fuse.ReleaseIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh, ReleaseFlags: fuse.FUSE_RELEASE_FLOCK_UNLOCK})
peer.mu.Lock()
defer peer.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'B'}, 256)...)
if peer.entry.Attributes.FileSize != 512 || !bytes.Equal(g6StoredBytes(t, peer), want) {
t.Fatal("relookup publication changed original/new bytes or size")
}
}
func TestAppendAfterPendingPositionalExtensionPublishes(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, appendHandle := g6AppendHandle(t, peer, base, 1000)
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY}, out); code != fuse.OK {
t.Fatal(code)
}
write := func(h, offset uint64, value byte) {
t.Helper()
n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: h, Offset: offset, Size: 256}, bytes.Repeat([]byte{value}, 256))
if n != 256 || code != fuse.OK {
t.Fatal("write", n, code)
}
}
write(out.Fh, 256, 'A')
write(appendHandle, 512, 'B') // the kernel appends at its EOF, which includes A
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: appendHandle}); code != fuse.OK {
t.Fatal("append over a pending extension", code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), bytes.Repeat([]byte{'A'}, 256)...)
want = append(want, bytes.Repeat([]byte{'B'}, 256)...)
if !bytes.Equal(g6StoredBytes(t, peer), want) {
t.Fatal("pending extension or append bytes changed")
}
}
func TestPositionalSparseWriteStillPublishes(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, _ := g6AppendHandle(t, peer, base, 900)
out := &fuse.OpenOut{}
if code := w.Open(nil, &fuse.OpenIn{InHeader: fuse.InHeader{NodeId: 42}, Flags: syscall.O_WRONLY}, out); code != fuse.OK {
t.Fatal(code)
}
n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh, Offset: 512, Size: 256}, bytes.Repeat([]byte{'S'}, 256))
if n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code = w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh}); code != fuse.OK {
t.Fatal("positional sparse write", code)
}
assertSparseWrite(t, peer)
}
func TestSparseWriteAfterAppendFlagClearedPublishes(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, handle := g6AppendHandle(t, peer, base, 950)
n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, Offset: 512, Size: 256, Flags: syscall.O_WRONLY}, bytes.Repeat([]byte{'S'}, 256))
if n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code = w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle}); code != fuse.OK {
t.Fatal("sparse write after clearing O_APPEND", code)
}
assertSparseWrite(t, peer)
}
func assertSparseWrite(t *testing.T, peer *g6ConditionalPeer) {
t.Helper()
peer.mu.Lock()
defer peer.mu.Unlock()
want := append(bytes.Repeat([]byte{'O'}, 256), make([]byte, 256)...)
want = append(want, bytes.Repeat([]byte{'S'}, 256)...)
if !bytes.Equal(g6StoredBytes(t, peer), want) {
t.Fatal("sparse positional data changed")
}
}
func TestTruncatingOpenDiscardsRefusedAppend(t *testing.T) {
base := attributeAppendEntry(1)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, handle := g6AppendHandle(t, peer, base, 1700)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, Offset: 512, Size: 256}, bytes.Repeat([]byte{'A'}, 256)); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle}); code == fuse.OK {
t.Fatal("append past the committed EOF published")
}
dir, _ := w.inodeToPath.GetInode("/dir")
out := &fuse.CreateOut{}
if code := w.Create(nil, &fuse.CreateIn{InHeader: fuse.InHeader{NodeId: dir}, Flags: syscall.O_WRONLY | syscall.O_CREAT | syscall.O_TRUNC, Mode: 0644}, "file", out); code != fuse.OK {
t.Fatal("O_TRUNC create", code)
}
g6AppendUploader(t, w.GetHandle(FileHandleId(out.Fh)), 1800, peer)
payload := bytes.Repeat([]byte{'Z'}, 256)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh, Size: 256}, payload); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: out.Fh}); code != fuse.OK {
t.Fatal("rewrite after an O_TRUNC open", code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
if !bytes.Equal(g6StoredBytes(t, peer), payload) {
t.Fatal("truncate and rewrite did not publish the new bytes")
}
}
type g6TtlRuleFiler struct{ *g6ConditionalPeer }
func (s *g6TtlRuleFiler) CreateEntry(ctx context.Context, r *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
if r.Entry.IsDirectory {
return &filer_pb.CreateEntryResponse{}, nil
}
r = proto.Clone(r).(*filer_pb.CreateEntryRequest)
if r.Entry.Attributes.TtlSec == 0 {
r.Entry.Attributes.TtlSec = 3600
}
return s.g6ConditionalPeer.CreateEntry(ctx, r)
}
func (s *g6TtlRuleFiler) UpdateEntry(ctx context.Context, r *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
if r.Entry.IsDirectory {
return &filer_pb.UpdateEntryResponse{}, nil
}
return s.g6ConditionalPeer.UpdateEntry(ctx, r)
}
func TestEagerCreateWriteKeepsFilerDefaults(t *testing.T) {
peer := &g6ConditionalPeer{blobs: make(map[string][]byte)}
w := newInvalidateTestWFS(t)
w.option.EagerFilerCreate = true
w.option.ConcurrentWriters = 1
w.concurrentWriters = util.NewLimitedConcurrentExecutor(1)
startFakeFiler(t, w, &g6TtlRuleFiler{peer})
dir := w.inodeToPath.Lookup("/dir", 1, true, false, 0, false)
w.inodeToPath.MarkChildrenCached("/")
w.inodeToPath.MarkChildrenCached("/dir")
dirEntry := &filer_pb.Entry{Name: "dir", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0777), Inode: dir}}
if err := w.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", dirEntry), 1); err != nil {
t.Fatal(err)
}
out := &fuse.CreateOut{}
if code := w.Create(nil, &fuse.CreateIn{InHeader: fuse.InHeader{NodeId: dir, Caller: fuse.Caller{Owner: fuse.Owner{Uid: 1000, Gid: 20}}}, Flags: syscall.O_WRONLY, Mode: 0644}, "file", out); code != fuse.OK {
t.Fatal("create", code)
}
g6AppendUploader(t, w.GetHandle(FileHandleId(out.Fh)), 2100, peer)
payload := bytes.Repeat([]byte{'E'}, 256)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: out.NodeId}, Fh: out.Fh, Size: 256}, payload); n != 256 || code != fuse.OK {
t.Fatal(n, code)
}
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: out.NodeId}, Fh: out.Fh}); code != fuse.OK {
t.Fatal("write after an eager create", code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
if peer.entry.Attributes.TtlSec != 3600 || !bytes.Equal(g6StoredBytes(t, peer), payload) {
t.Fatal("eager create lost the stored defaults or the written bytes")
}
}
type g6LostAnswerFiler struct {
*g6ConditionalPeer
lost atomic.Bool
}
func (s *g6LostAnswerFiler) CreateEntry(ctx context.Context, r *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
resp, err := s.g6ConditionalPeer.CreateEntry(ctx, r)
if err == nil && resp.Error == "" && s.lost.CompareAndSwap(false, true) {
return nil, status.Error(codes.Unavailable, "answer lost after commit")
}
return resp, err
}
func TestLostAnswerToOwnShrinkingFlushIsNotAConflict(t *testing.T) {
base := attributeAppendEntry(2)
peer := &g6ConditionalPeer{entry: proto.Clone(base).(*filer_pb.Entry)}
w, handle := g6AppendHandle(t, peer, base, 2200)
fake := &g6LostAnswerFiler{g6ConditionalPeer: peer}
startFakeFiler(t, w, fake)
if code := w.SetAttr(nil, &fuse.SetAttrIn{SetAttrInCommon: fuse.SetAttrInCommon{InHeader: fuse.InHeader{NodeId: 42}, Valid: fuse.FATTR_SIZE | fuse.FATTR_FH, Fh: handle}}, &fuse.AttrOut{}); code != fuse.OK {
t.Fatal("ftruncate", code)
}
payload := bytes.Repeat([]byte{'N'}, 128)
if n, code := w.Write(nil, &fuse.WriteIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle, Size: 128}, payload); n != 128 || code != fuse.OK {
t.Fatal(n, code)
}
if code := w.Fsync(nil, &fuse.FsyncIn{InHeader: fuse.InHeader{NodeId: 42}, Fh: handle}); code != fuse.OK {
t.Fatal("own committed overwrite reported as a conflict", code)
}
peer.mu.Lock()
defer peer.mu.Unlock()
if !fake.lost.Load() || !bytes.Equal(g6StoredBytes(t, peer), payload) {
t.Fatal("lost answer was not exercised or the overwrite did not land")
}
}
+15 -11
View File
@@ -23,7 +23,7 @@ var IsDebugFileReadWrite = false
type FileHandle struct {
fh FileHandleId
counter int64
fhs map[FileHandleId]struct{} // live fh values, one per open; guarded by fhMap's lock
fhs map[FileHandleId]bool // live fh values, one per open; guarded by fhMap's lock
entry *LockedEntry
// Nested handle lock order: wfs.fhLockTable[fh.fh] -> entryLock ->
// LockedEntry.RWMutex. Paths may use a subset, but must never acquire an
@@ -34,15 +34,18 @@ type FileHandle struct {
wfs *WFS
// cache file has been written to
dirtyMetadata bool
dirtyPages *PageWriter
reader *filer.ChunkReadAt
contentType string
asyncFlushPending bool // set in writebackCache mode to defer flush to Release
asyncFlushUid uint32 // saved uid for deferred metadata flush
asyncFlushGid uint32 // saved gid for deferred metadata flush
savedDir string // last known parent path if inode-to-path state is forgotten
savedName string // last known file name if inode-to-path state is forgotten
pendingAppend bool
pendingAppendOffset uint64
writebackContentError *atomic.Bool
dirtyMetadata bool
dirtyPages *PageWriter
reader *filer.ChunkReadAt
contentType string
asyncFlushPending bool // set in writebackCache mode to defer flush to Release
asyncFlushUid uint32 // saved uid for deferred metadata flush
asyncFlushGid uint32 // saved gid for deferred metadata flush
savedDir string // last known parent path if inode-to-path state is forgotten
savedName string // last known file name if inode-to-path state is forgotten
isDeleted bool
// deleteEpoch counts the times isDeleted was raised, all of them under the
@@ -96,7 +99,7 @@ func newFileHandle(wfs *WFS, handleId FileHandleId, inode uint64, entry *filer_p
fh := &FileHandle{
fh: handleId,
counter: 1,
fhs: map[FileHandleId]struct{}{handleId: {}},
fhs: map[FileHandleId]bool{handleId: false},
inode: inode,
wfs: wfs,
}
@@ -337,6 +340,7 @@ func (fh *FileHandle) ResetDirtyPages() {
fh.dirtyPages.Destroy()
fh.dirtyPages = newPageWriter(fh, fh.wfs.option.ChunkSizeLimit)
fh.dirtyMetadata = false
fh.pendingAppend = false
fh.contentType = ""
}
+26 -2
View File
@@ -2,6 +2,7 @@ package mount
import (
"sync"
"sync/atomic"
"github.com/seaweedfs/seaweedfs/weed/util"
@@ -65,12 +66,26 @@ func (i *FileHandleToInode) MarkInodeRenamed(inode uint64) {
// open — reads on it return ENOENT, which the kernel reports to read() as EIO.
// Callers must treat a draining report as "wait for the pending flush, then
// retry the whole acquire", not as success.
func (i *FileHandleToInode) AcquireFileHandle(wfs *WFS, inode uint64, entry *filer_pb.Entry, versionTsNs int64, signature int32) (*FileHandle, FileHandleId, bool, bool) {
func (i *FileHandleToInode) AcquireFileHandle(wfs *WFS, inode uint64, entry *filer_pb.Entry, versionTsNs int64, signature int32, appendMode ...bool) (*FileHandle, FileHandleId, bool, bool) {
// Bind before publishing the handle or queuing any async close. Capture
// under the inode-table lock, but release it before taking the fhMap lock.
var writebackError *atomic.Bool
if wfs.option.WritebackCache {
wfs.inodeToPath.RLock()
state, found := wfs.writebackContentErrors.Load(inode)
if !found {
state, _ = wfs.writebackContentErrors.LoadOrStore(inode, new(atomic.Bool))
}
writebackError = state.(*atomic.Bool)
wfs.inodeToPath.RUnlock()
}
i.Lock()
defer i.Unlock()
fh, found := i.inode2fh[inode]
if !found {
fh = newFileHandle(wfs, FileHandleId(util.RandomUint64()), inode, entry)
fh.writebackContentError = writebackError
fh.fhs[fh.fh] = len(appendMode) > 0 && appendMode[0]
fh.entryVersionTsNs.Store(versionTsNs)
fh.entryVersionSignature.Store(signature)
i.inode2fh[inode] = fh
@@ -82,11 +97,20 @@ func (i *FileHandleToInode) AcquireFileHandle(wfs *WFS, inode uint64, entry *fil
}
fh.counter++
openFh := FileHandleId(util.RandomUint64())
fh.fhs[openFh] = struct{}{}
fh.fhs[openFh] = len(appendMode) > 0 && appendMode[0]
i.fh2inode[openFh] = inode
return fh, openFh, true, false
}
func (i *FileHandleToInode) isAppendHandle(openFh FileHandleId) bool {
i.RLock()
defer i.RUnlock()
if fh := i.inode2fh[i.fh2inode[openFh]]; fh != nil {
return fh.fhs[openFh]
}
return false
}
func (i *FileHandleToInode) ReleaseByHandle(fh FileHandleId) *FileHandle {
i.Lock()
defer i.Unlock()
+2
View File
@@ -133,6 +133,8 @@ type Option struct {
}
type WFS struct {
// Metadata-only refusal token, detached at final Forget; no live resources retained.
writebackContentErrors sync.Map
// https://dl.acm.org/doi/fullHtml/10.1145/3310148
// follow https://github.com/hanwen/go-fuse/blob/master/fuse/api.go
fuse.RawFileSystem
+4 -1
View File
@@ -61,6 +61,7 @@ func (wfs *WFS) completeAsyncFlush(fh *FileHandle) {
// is a permanent one and the chunk data has been freed.
if err := fh.dirtyPages.FlushData(); err != nil {
glog.Errorf("completeAsyncFlush inode %d: data flush failed: %v", fh.inode, err)
(&contentPublication{failure: fh.writebackContentError}).abandon()
// Data is lost at this point. Proceed to cleanup to avoid resource leaks and unmount hangs.
} else if fh.dirtyMetadata {
// Phase 2: Flush metadata unless the file was explicitly unlinked.
@@ -139,10 +140,11 @@ func isFilerUnreachable(err error) bool {
// nothing else holds these references: like the writeback chunk uploads, it
// keeps retrying for as long as the filer is unreachable.
func (wfs *WFS) flushMetadataWithRetry(fh *FileHandle, dir, name string, fileFullPath util.FullPath) error {
publication := &contentPublication{failure: fh.writebackContentError}
attempts := 0
err := retryMetadataFlushUntil(context.Background(), func() error {
attempts++
return wfs.flushMetadataToFiler(context.Background(), fh, dir, name, fh.asyncFlushUid, fh.asyncFlushGid, nil)
return wfs.flushMetadataToFiler(context.Background(), fh, dir, name, fh.asyncFlushUid, fh.asyncFlushGid, nil, publication)
}, isFilerUnreachable, func(nextAttempt, totalAttempts int, backoff time.Duration, err error) {
if totalAttempts == 0 {
glog.Warningf("completeAsyncFlush %s: filer unreachable, retrying metadata flush (attempt %d) after %v: %v",
@@ -153,6 +155,7 @@ func (wfs *WFS) flushMetadataWithRetry(fh *FileHandle, dir, name string, fileFul
fileFullPath, nextAttempt, totalAttempts, backoff, err)
})
if err != nil {
publication.abandon()
glog.Errorf("completeAsyncFlush %s: metadata flush failed after %d attempts: %v - "+
"chunks are uploaded but NOT referenced in filer metadata; "+
"they will appear as orphans in volume.fsck",
+88
View File
@@ -80,6 +80,94 @@ func TestFlushMetadataWithRetryStopsOnFilerRefusal(t *testing.T) {
}
}
// Under writeback the kernel already moved EOF past the stored entry, so a
// drain that gives up must fence the inode whatever the filer answered.
func TestAbandonedWritebackFlushFencesInode(t *testing.T) {
originalSleep := metadataFlushSleep
t.Cleanup(func() {
metadataFlushSleep = originalSleep
})
metadataFlushSleep = func(ctx context.Context, d time.Duration) {}
const name = "abandoned-writeback.log"
wfs, _ := newCreateTestWFS(t)
fake := &rejectingCreateFiler{rejections: 1 << 20, refuse: func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
return nil, status.Error(codes.PermissionDenied, "denied")
}}
startFakeFiler(t, wfs, fake)
out := &fuse.CreateOut{}
if code := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{NodeId: 1},
Flags: syscall.O_WRONLY | syscall.O_CREAT,
Mode: 0o644,
}, name, out); code != fuse.OK {
t.Fatalf("Create status = %v, want OK", code)
}
fh := wfs.GetHandle(FileHandleId(out.Fh))
if fh == nil {
t.Fatal("Create did not register an open file handle")
}
wfs.option.WritebackCache = true
fh.writebackContentError = new(atomic.Bool)
fh.dirtyMetadata = true
if err := wfs.flushMetadataWithRetry(fh, "/", name, util.FullPath("/").Child(name)); err == nil {
t.Fatal("flush against a refusing filer succeeded")
}
if !fh.writebackContentError.Load() {
t.Fatal("abandoned writeback flush left the inode unfenced")
}
if err := wfs.checkContentPublication(fh, nil); !errors.Is(err, filer_pb.ErrPreconditionFailed) {
t.Fatalf("checkContentPublication after abandonment = %v, want a precondition refusal", err)
}
}
func TestFailedFlockReleaseFencesInode(t *testing.T) {
originalSleep := metadataFlushSleep
t.Cleanup(func() {
metadataFlushSleep = originalSleep
})
metadataFlushSleep = func(ctx context.Context, d time.Duration) {}
const name = "flock-release.log"
wfs, _ := newCreateTestWFS(t)
wfs.posixLocks = NewPosixLockTable()
fake := &rejectingCreateFiler{rejections: 1 << 20, refuse: func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
return nil, status.Error(codes.PermissionDenied, "denied")
}}
startFakeFiler(t, wfs, fake)
out := &fuse.CreateOut{}
if code := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{NodeId: 1},
Flags: syscall.O_WRONLY | syscall.O_CREAT,
Mode: 0o644,
}, name, out); code != fuse.OK {
t.Fatalf("Create status = %v, want OK", code)
}
fh := wfs.GetHandle(FileHandleId(out.Fh))
if fh == nil {
t.Fatal("Create did not register an open file handle")
}
wfs.option.WritebackCache = true
fenced := new(atomic.Bool)
fh.writebackContentError = fenced
fh.dirtyMetadata = true
wfs.Release(make(chan struct{}), &fuse.ReleaseIn{
InHeader: fuse.InHeader{NodeId: out.NodeId},
Fh: out.Fh,
ReleaseFlags: fuse.FUSE_RELEASE_FLOCK_UNLOCK,
})
if fake.calls.Load() == 0 {
t.Fatal("synchronous release did not attempt the flush")
}
if !fenced.Load() {
t.Fatal("release dropped uncommitted writeback content without fencing the inode")
}
}
func TestIsFilerUnreachable(t *testing.T) {
path := "/unavailable/transport.log"
cases := []struct {
+1 -1
View File
@@ -102,7 +102,7 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
var openFh FileHandleId
var existed, draining bool
for {
fileHandle, openFh, existed, draining = wfs.fhMap.AcquireFileHandle(wfs, inode, newEntry, 0, 0)
fileHandle, openFh, existed, draining = wfs.fhMap.AcquireFileHandle(wfs, inode, newEntry, 0, 0, in.Flags&syscall.O_APPEND != 0)
if !draining {
break
}
+21 -4
View File
@@ -222,14 +222,16 @@ func (wfs *WFS) doFlush(ctx context.Context, fh *FileHandle, uid, gid uint32, al
commit = &commitBudget{left: metadataTimeout}
}
publication := &contentPublication{failure: fh.writebackContentError}
if err := retryMetadataFlushIf(ctx, func() error {
return wfs.flushMetadataToFiler(ctx, fh, dir, name, uid, gid, commit)
return wfs.flushMetadataToFiler(ctx, fh, dir, name, uid, gid, commit, publication)
}, func(err error) bool {
return !commit.spent() && isRetryableFilerError(err)
}, func(nextAttempt, totalAttempts int, backoff time.Duration, err error) {
glog.Warningf("%v fh %d flush: retrying metadata flush (attempt %d/%d) after %v: %v",
fileFullPath, fh.fh, nextAttempt, totalAttempts, backoff, err)
}); err != nil {
publication.retire(err)
glog.Errorf("%v fh %d flush: %v", fileFullPath, fh.fh, err)
return grpcErrorToFuseStatus(err)
}
@@ -250,7 +252,11 @@ func (wfs *WFS) doFlush(ctx context.Context, fh *FileHandle, uid, gid uint32, al
//
// commit bounds the filer calls made here, over all attempts of one flush; nil
// leaves them to the per-call filer timeouts.
func (wfs *WFS) flushMetadataToFiler(ctx context.Context, fh *FileHandle, dir, name string, uid, gid uint32, commit *commitBudget) error {
func (wfs *WFS) flushMetadataToFiler(ctx context.Context, fh *FileHandle, dir, name string, uid, gid uint32, commit *commitBudget, publications ...*contentPublication) error {
publication := &contentPublication{}
if len(publications) > 0 {
publication = publications[0]
}
fileFullPath, dir, name := wfs.flushTarget(fh, dir, name)
var unlock func()
var fhActiveLock *util.ActiveLock
@@ -355,7 +361,7 @@ func (wfs *WFS) flushMetadataToFiler(ctx context.Context, fh *FileHandle, dir, n
// The chunk merge and the manifest above read and upload chunks, which can
// take longer than the whole commit budget. It is charged only from here.
commitCtx, end := commit.begin(ctx)
resp, merged, err := wfs.createContentEntry(commitCtx, fh, request, baseSnapshot, false)
resp, merged, err := wfs.createContentEntry(commitCtx, fh, request, baseSnapshot, false, publication)
end()
if err != nil {
glog.Errorf("fh flush create %s: %v", fileFullPath, err)
@@ -384,6 +390,7 @@ func (wfs *WFS) flushMetadataToFiler(ctx context.Context, fh *FileHandle, dir, n
}
if err == nil {
fh.pendingAppend = false
fh.dirtyMetadata = false
}
@@ -413,7 +420,7 @@ func (wfs *WFS) flushTarget(fh *FileHandle, dir, name string) (util.FullPath, st
// replayed onto it and published against it, so a peer's acknowledged chunks
// survive. merged is that entry in local ids; nil when the request went as is.
// With writes still buffered, the merge must keep room for them.
func (wfs *WFS) createContentEntry(ctx context.Context, fh *FileHandle, request *filer_pb.CreateEntryRequest, local *filer_pb.Entry, buffered bool) (resp *filer_pb.CreateEntryResponse, merged *filer_pb.Entry, err error) {
func (wfs *WFS) createContentEntry(ctx context.Context, fh *FileHandle, request *filer_pb.CreateEntryRequest, local *filer_pb.Entry, buffered bool, publication *contentPublication) (resp *filer_pb.CreateEntryResponse, merged *filer_pb.Entry, err error) {
base := fh.baseEntry.Load()
guard := &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}
if base != nil {
@@ -423,10 +430,16 @@ func (wfs *WFS) createContentEntry(ctx context.Context, fh *FileHandle, request
}
sent := request.Entry
for attempt := 0; ; attempt++ {
if guardErr := wfs.checkContentPublication(fh, guard.ExpectedEntry); guardErr != nil {
return nil, nil, guardErr
}
request.Condition = &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{guard}}
err = wfs.withFilerClient(ctx, false, wfs.filerRPCWait(), func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
var callErr error
resp, callErr = filer_pb.CreateEntryWithResponse(ctx, client, request)
if errors.Is(callErr, filer_pb.ErrPreconditionFailed) {
publication.refused = true
}
return callErr
})
if err == nil && request.Entry != sent {
@@ -583,6 +596,10 @@ func mergeContentEntry(stored, base, local, sent *filer_pb.Entry, truncatedTo in
size = int64(localSize)
}
merged.Attributes.FileSize = uint64(size)
// A peer truncate must not turn local pending data into a successful hole.
if filer.FileSize(stored) < filer.FileSize(base) && contentGapAfter(merged, filer.FileSize(stored)) {
return nil, false
}
return merged, true
}
+15
View File
@@ -10,6 +10,14 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
// isAppendWrite honors current F_SETFL flags; adapters without flags use the open.
func (wfs *WFS) isAppendWrite(in *fuse.WriteIn) bool {
if in.Flags != 0 {
return in.Flags&syscall.O_APPEND != 0
}
return wfs.fhMap.isAppendHandle(FileHandleId(in.Fh))
}
/**
* Write data
*
@@ -60,6 +68,13 @@ func (wfs *WFS) Write(cancel <-chan struct{}, in *fuse.WriteIn, data []byte) (wr
return 0, fuse.OK
}
// Only an append past all locally owned bytes needs the stored EOF guard.
if in.Offset > entry.Attributes.FileSize && wfs.isAppendWrite(in) {
if !fh.pendingAppend || in.Offset < fh.pendingAppendOffset {
fh.pendingAppendOffset = in.Offset
}
fh.pendingAppend = true
}
entry.Content = nil
offset := int64(in.Offset)
oldFileSize := int64(entry.Attributes.FileSize)
+4 -1
View File
@@ -70,7 +70,7 @@ func (wfs *WFS) AcquireHandle(ctx context.Context, inode uint64, flags, uid, gid
}
// need to AcquireFileHandle again to ensure correct handle counter
var existed, draining bool
fileHandle, openFh, existed, draining = wfs.fhMap.AcquireFileHandle(wfs, inode, entry, entryVersionTsNs.tsNs, entryVersionTsNs.signature)
fileHandle, openFh, existed, draining = wfs.fhMap.AcquireFileHandle(wfs, inode, entry, entryVersionTsNs.tsNs, entryVersionTsNs.signature, flags&syscall.O_APPEND != 0)
if draining {
// A RELEASE slipped between waitForPendingAsyncFlush and this
// attach: the handle's last open closed and its deferred flush
@@ -183,6 +183,9 @@ func (wfs *WFS) ReleaseHandle(handleId FileHandleId) {
wfs.pendingAsyncFlushMu.Unlock()
if fhToRelease != nil {
if fhToRelease.dirtyMetadata && !fhToRelease.isDeleted {
(&contentPublication{failure: fhToRelease.writebackContentError}).abandon()
}
fhToRelease.ReleaseHandle()
}
}
+4 -1
View File
@@ -68,7 +68,10 @@ func (wfs *WFS) Forget(nodeid, nlookup uint64) {
wfs.inodeToPath.Forget(nodeid, nlookup,
// Runs at the release, under the table's lock, so a lookup that
// rebuilds the same inode number afterwards keeps the times it sets.
wfs.forgetInMemoryTimes,
func(inode uint64) {
wfs.forgetInMemoryTimes(inode)
wfs.writebackContentErrors.Delete(inode)
},
func(dir util.FullPath) {
// Runs after Forget releases its lock; a concurrent lookup+rebuild can
// re-cache the directory in that window, so purge through the apply loop
+1 -1
View File
@@ -188,7 +188,7 @@ func (wfs *WFS) flushFileMetadata(fh *FileHandle) error {
baseSnapshot := proto.Clone(requestEntry).(*filer_pb.Entry)
wfs.mapPbIdFromLocalToFiler(request.Entry)
resp, merged, err := wfs.createContentEntry(context.Background(), fh, request, baseSnapshot, true)
resp, merged, err := wfs.createContentEntry(context.Background(), fh, request, baseSnapshot, true, &contentPublication{})
if err != nil {
return err
}