Files
seaweedfs/weed/mount/weedfs_file_sync.go
T
Chris LuandGitHub 214d3599d3 windows mount: cache file data, resolved paths and attributes (#10703)
* benchmark tool for mounted filesystems

* ci: on-demand mount benchmark, native WinFsp vs rclone plus a Linux reference

* windows mount: let the Windows cache manager cache file data

WinFsp only turns the cache manager on for a file when FileInfoTimeout
is infinite; at any finite value every application read and write is a
synchronous trip into the mount process at whatever size the application
issued. Metadata events already reach FspFileSystemNotify, which purges
a changed file's cached pages and attributes, so an infinite timeout
stays coherent. The dir listing, volume info and EA timeouts are pinned
to one second so they do not silently inherit the infinity.

* windows mount: cache resolved paths and attributes in the adapter

WinFsp addresses every operation by path and has no FORGET, so the
adapter walked the whole path through Lookup on each one, and in a
directory the filer has not listed yet every walk was a filer round
trip; nothing played the part of the kernel's dentry and attribute
caches. The path cache owns one lookup reference per entry the way the
kernel holds one until FORGET, serves attribute reads for files without
an open handle, and is purged by the mount's own mutations and by
metadata events, with the timeout as backstop.

* windows mount: keep a closed file's attributes cached

Open steals the path's cache entry for its handle and Release returned
the reference with a purge, so the stat that follows every copied file
walked to the filer again. Reading the handle's final attributes before
it goes away and moving the reference back into the cache serves that
stat locally, the way the kernel's attribute cache does after a close.

Only if the path still names that inode, though: WinFsp reports the
path the handle opened with, and after a delete-on-close or a rename
caching it would resurrect an entry that is gone.

* windows mount: persist entries at create, and let the flush stay at close

WinFsp posts the cleanup and close that carry the flush after
CloseHandle has returned, so deferring the filer entry to the flush let
everything that reads through the filer race an unflushed close: a
listing missed just-written files, and a directory rename moved a
directory on the filer before its newest child existed there, leaving
the straggler flush to recreate the child under the dead path.

Flush-at-cleanup is not the answer either: it makes every handle's
cleanup flush, and those flushes race the unlinks of delete-on-close,
re-inserting the entry the unlink just removed. Persisting the entry at
create takes the ordering question away.

* mount: flush written pages before a truncate shrinks past them

The shrink trims chunks, but written pages that have not become chunks
yet are invisible to it, so the next flush wrote them back and the file
grew again, resurrecting the truncated bytes. Windows hits this on
every write-then-shrink because its flush runs after CloseHandle, but
the gap is platform-neutral.

* mount: order a file's unlink against its in-flight flush

Unlink set the handle's deleted flag bare, so a flush already past its
own check of that flag wrote the entry back right after the delete
removed it, and a delete-on-close file outlived its last handle. The
flag is now set under the handle's flush lock and re-checked under it,
so a flush either completes before the delete or sees the flag and
skips. An eagerly created handle also starts clean: the dirty mark
existed to make the deferred filer create happen at flush, and eager
creates have nothing to flush.
2026-08-10 18:46:18 -07:00

377 lines
14 KiB
Go

package mount
import (
"bytes"
"context"
"fmt"
"io"
"syscall"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// metadataFlushTimeout bounds a close()/fsync metadata flush so an overwhelmed
// filer cannot wedge the calling process forever. It is deliberately generous:
// a healthy CreateEntry completes in well under a second, so this only fires on
// a genuinely stuck filer, never on a normal flush.
const metadataFlushTimeout = 30 * time.Second
/**
* Flush method
*
* This is called on each close() of the opened file.
*
* Since file descriptors can be duplicated (dup, dup2, fork), for
* one open call there may be many flush calls.
*
* Filesystems shouldn't assume that flush will always be called
* after some writes, or that if will be called at all.
*
* fi->fh will contain the value set by the open method, or will
* be undefined if the open method didn't set any value.
*
* NOTE: the name of the method is misleading, since (unlike
* fsync) the filesystem is not forced to flush pending writes.
* One reason to flush data is if the filesystem wants to return
* write errors during close. However, such use is non-portable
* because POSIX does not require [close] to wait for delayed I/O to
* complete.
*
* If the filesystem supports file locking operations (setlk,
* getlk) it should remove all locks belonging to 'fi->owner'.
*
* If this request is answered with an error code of ENOSYS,
* this is treated as success and future calls to flush() will
* succeed automatically without being send to the filesystem
* process.
*
* Valid replies:
* fuse_reply_err
*
* @param req request handle
* @param ino the inode number
* @param fi file information
*
* [close]: http://pubs.opengroup.org/onlinepubs/9699919799/functions/close.html
*/
func (wfs *WFS) Flush(cancel <-chan struct{}, in *fuse.FlushIn) fuse.Status {
fh := wfs.GetHandle(FileHandleId(in.Fh))
if fh == nil {
// If handle is not found, it might have been already released
// This is not an error condition for FLUSH
if in.LockOwner != 0 {
wfs.releasePosixOwner(in.NodeId, in.LockOwner)
}
return fuse.OK
}
// FlushIn.LockOwner is populated by some FUSE kernels even when the process
// did not hold byte-range locks. Only force the synchronous close path when
// this owner actually has POSIX locks to release; otherwise writebackCache
// would silently degrade to a blocking flush for ordinary close().
hasPosixLocks := wfs.hasPosixOwner(in.NodeId, in.LockOwner)
allowAsync := !hasPosixLocks
// Bound the flush with a deadline instead of tying it to the FUSE cancel
// channel. A FUSE interrupt is not a process kill: Go's async preemption
// (SIGURG) makes a close() under load emit an interrupt on nearly every
// flush (see go-fuse RawFileSystem docs), so cancelling the in-flight
// metadata CreateEntry on that interrupt turned healthy concurrent close()s
// into EIO. The deadline still keeps close() from hanging forever against an
// overwhelmed filer without failing benign flushes.
ctx, cancelFunc := context.WithTimeout(context.Background(), metadataFlushTimeout)
defer cancelFunc()
status := wfs.doFlush(ctx, fh, in.Uid, in.Gid, allowAsync)
if in.LockOwner != 0 {
wfs.releasePosixOwner(in.NodeId, in.LockOwner)
}
return status
}
/**
* Synchronize file contents
*
* If the datasync parameter is non-zero, then only the user data
* should be flushed, not the meta data.
*
* If this request is answered with an error code of ENOSYS,
* this is treated as success and future calls to fsync() will
* succeed automatically without being send to the filesystem
* process.
*
* Valid replies:
* fuse_reply_err
*
* @param req request handle
* @param ino the inode number
* @param datasync flag indicating if only data should be flushed
* @param fi file information
*/
func (wfs *WFS) Fsync(cancel <-chan struct{}, in *fuse.FsyncIn) (code fuse.Status) {
fh := wfs.GetHandle(FileHandleId(in.Fh))
if fh == nil {
return fuse.ENOENT
}
ctx, cancelFunc := context.WithTimeout(context.Background(), metadataFlushTimeout)
defer cancelFunc()
// Fsync is an explicit sync request — always flush synchronously
return wfs.doFlush(ctx, fh, in.Uid, in.Gid, false)
}
func (wfs *WFS) doFlush(ctx context.Context, fh *FileHandle, uid, gid uint32, allowAsync bool) fuse.Status {
// flush works at fh level
fileFullPath := fh.FullPath()
fh.RememberPath(fileFullPath)
dir, name := fileFullPath.DirAndName()
// send the data to the OS
glog.V(4).Infof("doFlush %s fh %d", fileFullPath, fh.fh)
// When writebackCache is enabled and this is a close()-triggered Flush (not fsync),
// defer the expensive data upload + metadata flush to a background goroutine.
// This allows the calling process (e.g., rsync) to proceed to the next file immediately.
// POSIX does not require close() to wait for delayed I/O to complete.
if allowAsync && wfs.option.WritebackCache && fh.dirtyMetadata {
if wfs.IsOverQuotaWithUncommitted() {
return fuse.Status(syscall.ENOSPC)
}
fh.asyncFlushPending = true
fh.asyncFlushUid = uid
fh.asyncFlushGid = gid
glog.V(3).Infof("doFlush async deferred %s fh %d", fileFullPath, fh.fh)
return fuse.OK
}
// Synchronous flush path (normal mode, fsync, or no dirty data)
fh.asyncFlushPending = false
// Check quota including uncommitted writes for real-time enforcement
isOverQuota := wfs.IsOverQuotaWithUncommitted()
if !isOverQuota {
if err := fh.dirtyPages.FlushData(); err != nil {
glog.Errorf("%v doFlush: %v", fileFullPath, err)
return writeErrorToFuseStatus(err)
}
}
if !fh.dirtyMetadata {
return fuse.OK
}
// Skip metadata flush if the file was unlinked while open.
// The filer entry is already gone; flushing would recreate it.
if fh.isDeleted {
glog.V(3).Infof("doFlush %s fh %d: file was unlinked, skipping metadata flush", fileFullPath, fh.fh)
return fuse.OK
}
if isOverQuota {
return fuse.Status(syscall.ENOSPC)
}
if err := retryMetadataFlush(ctx, func() error {
return wfs.flushMetadataToFiler(ctx, fh, dir, name, uid, gid)
}, 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 {
glog.Errorf("%v fh %d flush: %v", fileFullPath, fh.fh, err)
return grpcErrorToFuseStatus(err)
}
if IsDebugFileReadWrite {
fh.mirrorFile.Sync()
}
return fuse.OK
}
// flushMetadataToFiler sends the file's chunk references and attributes to the filer.
// This is shared between the synchronous doFlush path and the async flush completion.
//
// When -dlm is enabled, the distributed lock is already held by the FileHandle
// from open-for-write through close, so no additional distributed lock is
// needed here. The local fhLockTable lock below serializes within this mount.
func (wfs *WFS) flushMetadataToFiler(ctx context.Context, fh *FileHandle, dir, name string, uid, gid uint32) error {
fileFullPath := fh.FullPath()
glog.V(4).Infof("flushMetadataToFiler %s/%s inode %d fh %d", dir, name, fh.inode, fh.fh)
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("doFlush", fh.fh, util.ExclusiveLock)
defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
// Re-check under the lock: Unlink sets the flag under it, so a flush that
// was already past the earlier check cannot write the entry back after
// the delete removed it.
if fh.isDeleted {
glog.V(3).Infof("flushMetadataToFiler %s fh %d: file was unlinked, skipping", fileFullPath, fh.fh)
return nil
}
entry := fh.GetEntry()
entry.Name = name // this flush may be just after a rename operation
if entry.Attributes != nil {
entry.Attributes.Mime = fh.contentType
if entry.Attributes.Uid == 0 {
entry.Attributes.Uid = uid
}
if entry.Attributes.Gid == 0 {
entry.Attributes.Gid = gid
}
// Do not stamp mtime/ctime here. Write/SetAttr already maintain
// them on the entry; overwriting at flush time clobbered user-set
// mtime (utimes/touch -m -d) once the deferred flush ran.
}
glog.V(4).Infof("%s set chunks: %v", fileFullPath, len(entry.GetChunks()))
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(entry.GetChunks())
chunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
if mergedChunks, mergeErr := wfs.maybeMergeChunks(fileFullPath, chunks, manifestChunks); mergeErr != nil {
glog.V(0).Infof("maybeMergeChunks %s: %v", fileFullPath, mergeErr)
} else if mergedChunks != nil {
chunks = mergedChunks
manifestChunks = nil
}
chunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), chunks)
if manifestErr != nil {
// not good, but should be ok
glog.V(0).Infof("MaybeManifestize: %v", manifestErr)
}
entry.Chunks = append(chunks, manifestChunks...)
// Clone the proto entry for the filer request so that mapPbIdFromLocalToFiler
// does not mutate the file handle's live entry. Without the clone, a concurrent
// Lookup can observe filer-side uid/gid on the file handle entry and return it
// to the kernel, which caches it and then rejects opens by the local user.
requestEntry := proto.Clone(entry.GetEntry()).(*filer_pb.Entry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: requestEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
// Snapshot with local ids before the request mapping mutates the clone:
// on ack this becomes the handle's base, judged against future events.
baseSnapshot := proto.Clone(requestEntry).(*filer_pb.Entry)
wfs.mapPbIdFromLocalToFiler(request.Entry)
resp, err := wfs.streamCreateEntry(ctx, request)
if err != nil {
glog.Errorf("fh flush create %s: %v", fileFullPath, err)
return fmt.Errorf("fh flush create %s: %v", fileFullPath, err)
}
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(string(dir), request.Entry)
if event != nil {
event.TsNs = ackVersionTsNs(resp)
}
}
// The filer acknowledged this state at the event's log position (or, for
// a no-op create, at the response's log position); older queued
// subscription events must not roll the handle back.
fh.setAuthoritativeBase(baseSnapshot)
fh.advanceEntryVersion(ackVersionTsNs(resp), resp.GetLogSignature())
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("flush %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
}
if err == nil {
fh.dirtyMetadata = false
}
return err
}
// shouldMergeChunks reports whether the non-manifest chunks are bloated
// enough to justify re-reading and re-uploading the file. The condition
// is: sum of compacted chunk sizes > 2 * logical file size.
func shouldMergeChunks(compactedChunks []*filer_pb.FileChunk, manifestChunks []*filer_pb.FileChunk) (totalChunkSize, fileSize uint64, merge bool) {
for _, chunk := range compactedChunks {
totalChunkSize += chunk.Size
}
// Count manifest coverage toward stored total. Each manifest holds
// sub-chunks on volume servers that cover approximately Size bytes.
// Without this, overlapping manifests accumulate undetected because
// the merge condition only saw the (small) non-manifest chunk total.
for _, chunk := range manifestChunks {
totalChunkSize += chunk.Size
}
allChunks := make([]*filer_pb.FileChunk, 0, len(compactedChunks)+len(manifestChunks))
allChunks = append(allChunks, compactedChunks...)
allChunks = append(allChunks, manifestChunks...)
fileSize = filer.TotalSize(allChunks)
merge = fileSize > 0 && totalChunkSize > 2*fileSize
return
}
// maybeMergeChunks re-reads and re-uploads file data as properly sized chunks
// when the total stored chunk data significantly exceeds the logical file size,
// which happens after many random writes create partially-overlapping small chunks.
func (wfs *WFS) maybeMergeChunks(fileFullPath util.FullPath, compactedChunks []*filer_pb.FileChunk, manifestChunks []*filer_pb.FileChunk) ([]*filer_pb.FileChunk, error) {
totalChunkSize, fileSize, merge := shouldMergeChunks(compactedChunks, manifestChunks)
if !merge {
return nil, nil
}
glog.V(0).Infof("%.1fx chunk bloat detected on %s (%d chunks, %d stored vs %d content), merging",
float64(totalChunkSize)/float64(fileSize), fileFullPath, len(compactedChunks), totalChunkSize, fileSize)
ctx := context.Background()
allChunks := make([]*filer_pb.FileChunk, 0, len(compactedChunks)+len(manifestChunks))
allChunks = append(allChunks, compactedChunks...)
allChunks = append(allChunks, manifestChunks...)
reader := filer.NewChunkStreamReaderFromLookup(ctx, wfs.LookupFn(), allChunks)
defer reader.Close()
saveFunc := wfs.saveDataAsChunk(fileFullPath)
chunkSize := wfs.option.ChunkSizeLimit
if int64(fileSize) < chunkSize {
chunkSize = int64(fileSize)
}
var newChunks []*filer_pb.FileChunk
var offset int64
buf := make([]byte, chunkSize)
for {
n, readErr := io.ReadFull(reader, buf)
if n > 0 {
chunk, uploadErr := saveFunc(bytes.NewReader(buf[:n]), "", offset, 0, uint64(n))
if uploadErr != nil {
return nil, uploadErr
}
newChunks = append(newChunks, chunk)
offset += int64(n)
}
if readErr != nil {
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
break
}
return nil, readErr
}
}
glog.V(0).Infof("merged %s: %d chunks -> %d chunks", fileFullPath, len(compactedChunks)+len(manifestChunks), len(newChunks))
return newChunks, nil
}