Files
seaweedfs/weed/mount/weedfs_async_flush_test.go
T
Chris LuandClaude Opus 5.5 e0f8438284 mount: retry a writeback metadata flush until the filer answers (#12578)
* mount: keep the filer status in the flush create error

flushMetadataToFiler formatted the CreateEntry error with %v, so callers
classifying it saw only the message, which includes the file path. Wrap
it so the gRPC status the filer sent stays reachable.

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

* mount: retry a writeback metadata flush until the filer answers

With -writebackCache, close() returns before the chunk references reach
the filer. The deferred flush gave up after four attempts, about a minute
once each attempt's own filer retries are counted, then released the
handle: a longer filer outage dropped the file even though write() and
close() had succeeded, and its uploaded chunks became orphans.

Keep retrying for as long as the filer is unreachable, as the writeback
chunk uploads already do. A filer that answers with an error still ends
the flush after the bounded attempts, and an unlink of the draining file
still ends it on the next attempt.

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

* mount: retry only an unanswered filer call without a bound

The unbounded phase used util.IsTransientError, which falls back to
matching the error text. A refusal the filer sends in its response
(read-only, name too long, a plain error string) has no gRPC status, and
the flush error carries the file path, so a file named like
"unavailable-transport.log" kept retrying against a filer that answered.

Retry without a bound only when the call failed with a gRPC Unavailable
status: the dial, the connection or the mount's call deadline gave out.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:57:42 -08:00

104 lines
3.8 KiB
Go

package mount
import (
"context"
"errors"
"fmt"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type rejectingCreateFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
rejections int32
refuse func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error)
calls atomic.Int32
}
func (s *rejectingCreateFiler) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
if s.calls.Add(1) <= s.rejections {
return s.refuse(req)
}
return &filer_pb.CreateEntryResponse{}, nil
}
// A filer that answers with a refusal is not an outage, even when the file's
// own path reads like one: the deferred flush gives up after its bounded
// attempts instead of retrying until the refusal stops.
func TestFlushMetadataWithRetryStopsOnFilerRefusal(t *testing.T) {
originalSleep := metadataFlushSleep
t.Cleanup(func() {
metadataFlushSleep = originalSleep
})
metadataFlushSleep = func(ctx context.Context, d time.Duration) {}
const name = "unavailable-transport.log"
refusals := map[string]func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error){
"grpc status": func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
return nil, status.Error(codes.PermissionDenied, "denied")
},
"response error": func(req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
return &filer_pb.CreateEntryResponse{Error: "store write refused"}, nil
},
}
for label, refuse := range refusals {
t.Run(label, func(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
fake := &rejectingCreateFiler{rejections: 20, refuse: refuse}
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.flushMetadataWithRetry(fh, "/", name, util.FullPath("/").Child(name))
if got := fake.calls.Load(); got != metadataFlushRetries+1 {
t.Fatalf("CreateEntry calls = %d, want the bounded %d", got, metadataFlushRetries+1)
}
if !fh.dirtyMetadata {
t.Fatal("refused flush cleared dirtyMetadata")
}
})
}
}
func TestIsFilerUnreachable(t *testing.T) {
path := "/unavailable/transport.log"
cases := []struct {
name string
err error
want bool
}{
{"dial refused", fmt.Errorf("fh flush create %s: %w", path, fmt.Errorf("CreateEntry: %w",
status.Error(codes.Unavailable, `connection error: desc = "transport: Error while dialing: dial tcp 127.0.0.1:18888: connect: connection refused"`))), true},
{"call timed out", fmt.Errorf("fh flush create %s: %w", path, status.Error(codes.Unavailable, "filer 127.0.0.1:18888: call timed out")), true},
{"read only", fmt.Errorf("fh flush create %s: %w", path, fmt.Errorf("CreateEntry %s: %w", path, filer_pb.ErrReadOnly)), false},
{"message only", fmt.Errorf("fh flush create %s: %w", path, fmt.Errorf("CreateEntry: %w", errors.New("existing "+path+" is a directory"))), false},
{"permission denied", fmt.Errorf("fh flush create %s: %w", path, status.Error(codes.PermissionDenied, "denied")), false},
{"message too large", fmt.Errorf("fh flush create %s: %w", path, status.Error(codes.ResourceExhausted, "grpc: received message larger than max")), false},
}
for _, c := range cases {
if got := isFilerUnreachable(c.err); got != c.want {
t.Errorf("%s: isFilerUnreachable(%v) = %v, want %v", c.name, c.err, got, c.want)
}
}
}