mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 13:34:26 +02:00
* filer: resume metadata subscriber from processed watermark on reconnect
* filer: take the reconnect position from GetResumeTsNs verbatim
The callback is the subscriber's durable resume point; falling back to
StartTsNs when it returns zero can resume from a cursor the log-chunk
reader advanced past still-pending work.
* filer: advance the stream cursor once a retried event recovers
RetryForeverOnError resolves the failure inside handleErr, so returning
without moving StartTsNs replays work the event already did when the
stream reconnects before the next one arrives.
* filer: let filtered-progress markers move the processed watermark
A marker means the source examined everything up to its timestamp and
skipped what did not match the subscription. With a resume callback the
marker now reaches the consumer, and AddSyncJob advances the watermark
to it once every earlier job finished and no failure pins the offset.
Idle filtered stretches no longer rescan on every reconnect, while the
guards keep the watermark behind pending or failed work.
* filer: unpin the watermark once a failed event completes
oldestFailedTsNs was only ever set, so a failure that a replay later
fixed still held the resume offset, and every reconnect re-read the
same backlog. Track outstanding failures in a set and recompute the
pin when the failed event's job finally succeeds.
* filer.remote.gateway: resume bucket sync from the processed watermark
The bucket-sync subscriber runs the same MetadataProcessor queue as
filer.remote.sync; give it the same GetResumeTsNs callback so a
reconnect resumes from durably processed work, not the last seen event.
* util: treat a peer-sent gRPC Canceled as transient
A peer tearing down its end of the transport reports codes.Canceled
("the client connection is closing"), which IsTransientError used to
reject: the sync job then failed on the first try and held the offset
until a restart. Caller's own cancels are still excluded up front by
errors.Is(err, context.Canceled), so only teardown-style statuses take
the new branch.
* fix: preserve filtered progress and distinguish caller cancellation
* filer: bound the failed-event ledger past a persistent outage
A destination rejecting every event grew failedTs by one entry per source
event for the life of the processor. Past maxFailedSyncEvents the set now
collapses to a sticky pin at the smallest failure seen, so the watermark
still replays from the oldest failure while memory stays bounded; a
restart re-derives the exact set.
Also keep a resume-callback consumer's chunk-ref replay filter at the
subscribe-time position instead of option.StartTsNs, so a resubscribe does
not filter out events whose async processing is still pending.
* filer: key the failed-event ledger by event, not just timestamp
A success for one event cleared the pin recorded for a different event
that shared its TsNs, letting the watermark pass an unresolved failure.
The ledger now keys on the event's path identity, so recovery unblocks
only the event that actually failed.
---------
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package pb
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
// fakeSubscribeStream plays back a scripted sequence of responses, one per Recv.
|
|
type fakeSubscribeStream struct {
|
|
grpc.ClientStream
|
|
responses []*filer_pb.SubscribeMetadataResponse
|
|
delay time.Duration
|
|
idx int
|
|
}
|
|
|
|
func (s *fakeSubscribeStream) Recv() (*filer_pb.SubscribeMetadataResponse, error) {
|
|
if s.idx >= len(s.responses) {
|
|
return nil, io.EOF
|
|
}
|
|
if s.idx > 0 && s.delay > 0 {
|
|
// advance the wall clock so AddOffsetFunc's interval gate opens
|
|
time.Sleep(s.delay)
|
|
}
|
|
r := s.responses[s.idx]
|
|
s.idx++
|
|
return r, nil
|
|
}
|
|
|
|
// fakeFilerClient only needs SubscribeMetadata; the embedded nil interface
|
|
// covers the rest, which makeSubscribeMetadataFunc never calls.
|
|
type fakeFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
stream *fakeSubscribeStream
|
|
}
|
|
|
|
func (c *fakeFilerClient) SubscribeMetadata(ctx context.Context, in *filer_pb.SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.SubscribeMetadataResponse], error) {
|
|
return c.stream, nil
|
|
}
|
|
|
|
// TestFilerSyncOffsetStaysFreshOnFilteredMarker: on a read-only watched path with
|
|
// a busy source, the MaxUnsyncedEvents marker (empty EventNotification, fresh
|
|
// timestamp) must be treated as a freshness signal, not fed to the offset path
|
|
// where it would pin the gauge to the stale watermark. Wiring mirrors filer.sync.
|
|
func TestFilerSyncOffsetStaysFreshOnFilteredMarker(t *testing.T) {
|
|
const oldEventTs = int64(1_000_000_000) // t0: last real synced event (stale)
|
|
nowTs := time.Now().UnixNano() // current source time
|
|
markerTs := nowTs + int64(time.Second)
|
|
|
|
var watermark = oldEventTs // MetadataProcessor.processedTsWatermark
|
|
|
|
type gaugeWrite struct {
|
|
src string
|
|
ts int64
|
|
}
|
|
var timeline []gaugeWrite
|
|
var heartbeatCalls, markerToProcessFn int
|
|
|
|
// This consumer sets no resume callback, so markers keep moving StartTsNs
|
|
// and never reach processEventFn; the callback path is checked in
|
|
// TestFilerSyncMarkerReachesCallbackConsumer.
|
|
realProcessFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if filer_pb.IsEmpty(resp) {
|
|
markerToProcessFn++
|
|
return nil
|
|
}
|
|
watermark = resp.TsNs
|
|
return nil
|
|
}
|
|
// offsetFunc publishes the watermark to the gauge (filer_sync.go).
|
|
processEventFn := AddOffsetFunc(realProcessFn, 0, func(counter, lastTsNs int64) error {
|
|
timeline = append(timeline, gaugeWrite{"offset", watermark})
|
|
return nil
|
|
})
|
|
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: oldEventTs,
|
|
EventErrorType: DontLogError,
|
|
OnIdleHeartbeat: func(tsNs int64) {
|
|
heartbeatCalls++
|
|
timeline = append(timeline, gaugeWrite{"heartbeat", tsNs})
|
|
},
|
|
}
|
|
|
|
stream := &fakeSubscribeStream{
|
|
delay: 2 * time.Millisecond,
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
// real create on the watched path, long ago -> watermark = t0
|
|
{Directory: "/watched", TsNs: oldEventTs, EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{Name: "file"},
|
|
}},
|
|
// genuine idle heartbeat
|
|
{TsNs: nowTs},
|
|
// MaxUnsyncedEvents marker: empty EventNotification, fresh timestamp
|
|
{TsNs: markerTs, EventNotification: &filer_pb.EventNotification{}},
|
|
},
|
|
}
|
|
|
|
if err := makeSubscribeMetadataFunc(option, processEventFn)(&fakeFilerClient{stream: stream}); err != nil {
|
|
t.Fatalf("follow: %v", err)
|
|
}
|
|
|
|
t.Logf("gauge timeline: %+v", timeline)
|
|
|
|
// the marker fires OnIdleHeartbeat instead of reaching processEventFn
|
|
if markerToProcessFn != 0 {
|
|
t.Errorf("empty marker must not reach processEventFn, got %d calls", markerToProcessFn)
|
|
}
|
|
if heartbeatCalls != 2 {
|
|
t.Errorf("expected OnIdleHeartbeat for both the heartbeat and the marker (2), got %d", heartbeatCalls)
|
|
}
|
|
if option.StartTsNs != markerTs {
|
|
t.Errorf("marker should advance StartTsNs to %d, got %d", markerTs, option.StartTsNs)
|
|
}
|
|
|
|
// gauge stays fresh: last write is the marker's timestamp, not the stale watermark
|
|
last := timeline[len(timeline)-1]
|
|
if last.src != "heartbeat" || last.ts != markerTs {
|
|
t.Fatalf("expected final gauge write fresh at %d, got %+v (spike is back if stale %d)", markerTs, last, oldEventTs)
|
|
}
|
|
}
|
|
|
|
// TestFilerSyncBatchedFreshnessSignalDoesNotCrash: while catching up after a
|
|
// peer outage the server folds a backlog into one batched response: the first
|
|
// event in the top-level fields, the rest in resp.Events. The drain can pull an
|
|
// idle heartbeat (nil EventNotification) into that tail. The batched tail must
|
|
// get the same freshness-signal handling as the envelope, else processEventFn
|
|
// (filer.sync's AddSyncJob) nil-derefs in IsEmpty. The processEventFn here
|
|
// mirrors that first call.
|
|
func TestFilerSyncBatchedFreshnessSignalDoesNotCrash(t *testing.T) {
|
|
const ts1 = int64(1_000_000_000) // envelope: real create
|
|
const ts2 = int64(1_000_000_001) // batched: real create
|
|
const markerTs = int64(1_000_000_002) // batched: MaxUnsyncedEvents marker (empty entry)
|
|
hbTs := time.Now().UnixNano() // batched: idle heartbeat (nil EventNotification), fresh
|
|
|
|
var realEvents []int64
|
|
var heartbeatTs []int64
|
|
|
|
// Mirrors AddSyncJob: IsEmpty nil-derefs on a nil EventNotification.
|
|
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if filer_pb.IsEmpty(resp) {
|
|
return nil
|
|
}
|
|
realEvents = append(realEvents, resp.TsNs)
|
|
return nil
|
|
}
|
|
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: ts1,
|
|
EventErrorType: DontLogError,
|
|
OnIdleHeartbeat: func(tsNs int64) {
|
|
heartbeatTs = append(heartbeatTs, tsNs)
|
|
},
|
|
}
|
|
|
|
// One batched response: envelope plus a tail mixing a real event, a marker,
|
|
// and a fresh heartbeat, as the server emits while draining a backlog. The
|
|
// heartbeat is last and carries the largest timestamp on purpose.
|
|
stream := &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{{
|
|
Directory: "/watched",
|
|
TsNs: ts1,
|
|
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "a"}},
|
|
Events: []*filer_pb.SubscribeMetadataResponse{
|
|
{Directory: "/watched", TsNs: ts2, EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "b"}}},
|
|
{TsNs: markerTs, EventNotification: &filer_pb.EventNotification{}}, // marker: empty entry
|
|
{TsNs: hbTs}, // idle heartbeat: nil EventNotification
|
|
},
|
|
}},
|
|
}
|
|
|
|
if err := makeSubscribeMetadataFunc(option, processEventFn)(&fakeFilerClient{stream: stream}); err != nil {
|
|
t.Fatalf("follow: %v", err)
|
|
}
|
|
|
|
// Both real events reached processEventFn; neither freshness signal did.
|
|
if len(realEvents) != 2 || realEvents[0] != ts1 || realEvents[1] != ts2 {
|
|
t.Errorf("expected real events [%d %d], got %v", ts1, ts2, realEvents)
|
|
}
|
|
// The batched marker and heartbeat both fired OnIdleHeartbeat.
|
|
if len(heartbeatTs) != 2 || heartbeatTs[0] != markerTs || heartbeatTs[1] != hbTs {
|
|
t.Errorf("expected heartbeats [%d %d], got %v", markerTs, hbTs, heartbeatTs)
|
|
}
|
|
// The marker advances the resume cursor; the heartbeat does not, even though
|
|
// it is last and carries the largest timestamp. StartTsNs ends at the marker.
|
|
if option.StartTsNs != markerTs {
|
|
t.Errorf("expected StartTsNs %d (marker), got %d (heartbeat must not advance the cursor)", markerTs, option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
// TestFilerSyncResumeFromProcessedWatermarkOnReconnect verifies that when GetResumeTsNs is
|
|
// configured, reconnection uses the processed watermark instead of skipping ahead to the
|
|
// latest received timestamp.
|
|
func TestFilerSyncResumeFromProcessedWatermarkOnReconnect(t *testing.T) {
|
|
const initialTs = int64(100)
|
|
const watermarkTs = int64(200)
|
|
const latestStreamTs = int64(500)
|
|
|
|
var capturedSinceNs int64
|
|
recordingClient := &recordingFilerClient{
|
|
onSubscribe: func(req *filer_pb.SubscribeMetadataRequest) {
|
|
capturedSinceNs = req.SinceNs
|
|
},
|
|
stream: &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{
|
|
Directory: "/watched",
|
|
TsNs: latestStreamTs,
|
|
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "file"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: initialTs,
|
|
GetResumeTsNs: func() int64 {
|
|
return watermarkTs
|
|
},
|
|
}
|
|
|
|
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
return nil
|
|
}
|
|
|
|
fn := makeSubscribeMetadataFunc(option, processFn)
|
|
if err := fn(recordingClient); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if capturedSinceNs != watermarkTs {
|
|
t.Fatalf("expected subscribe SinceNs to be watermark %d, got %d", watermarkTs, capturedSinceNs)
|
|
}
|
|
// StartTsNs must not be mutated when GetResumeTsNs is set
|
|
if option.StartTsNs != initialTs {
|
|
t.Fatalf("expected option.StartTsNs to remain %d, got %d", initialTs, option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
// TestFilerSyncDoesNotAdvanceStartTsNsOnProcessError verifies that a failing synchronous
|
|
// processEventFn does not advance option.StartTsNs past the failed event.
|
|
func TestFilerSyncDoesNotAdvanceStartTsNsOnProcessError(t *testing.T) {
|
|
const initialTs = int64(100)
|
|
const failedTs = int64(200)
|
|
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: initialTs,
|
|
EventErrorType: TrivialOnError,
|
|
}
|
|
|
|
stream := &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{
|
|
Directory: "/watched",
|
|
TsNs: failedTs,
|
|
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "bad"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
|
|
fn := makeSubscribeMetadataFunc(option, processFn)
|
|
_ = fn(&fakeFilerClient{stream: stream})
|
|
|
|
if option.StartTsNs != initialTs {
|
|
t.Fatalf("expected StartTsNs to stay at %d on error, got %d", initialTs, option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
type recordingFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
onSubscribe func(req *filer_pb.SubscribeMetadataRequest)
|
|
stream *fakeSubscribeStream
|
|
}
|
|
|
|
func (c *recordingFilerClient) SubscribeMetadata(ctx context.Context, in *filer_pb.SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.SubscribeMetadataResponse], error) {
|
|
if c.onSubscribe != nil {
|
|
c.onSubscribe(in)
|
|
}
|
|
return c.stream, nil
|
|
}
|
|
|
|
// RetryForeverOnError resolves a failure inside handleErr, so the cursor must
|
|
// still move past the recovered event instead of replaying it on reconnect.
|
|
func TestFilerSyncRecoveredEventAdvancesCursor(t *testing.T) {
|
|
var calls int
|
|
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
return nil
|
|
}
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: 100,
|
|
EventErrorType: RetryForeverOnError,
|
|
}
|
|
stream := &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{Name: "file"},
|
|
}},
|
|
},
|
|
}
|
|
if err := makeSubscribeMetadataFunc(option, processFn)(&fakeFilerClient{stream: stream}); err != nil {
|
|
t.Fatalf("follow: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("expected the event retried once (2 calls), got %d", calls)
|
|
}
|
|
if option.StartTsNs != 300 {
|
|
t.Fatalf("expected StartTsNs 300 after the retry succeeded, got %d", option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
// A consumer with a resume callback keeps its cursor in its processed
|
|
// watermark, so a filtered-progress marker is handed to processEventFn (which
|
|
// can count it as processed) instead of mutating StartTsNs.
|
|
func TestFilerSyncMarkerReachesCallbackConsumer(t *testing.T) {
|
|
var markers, events int
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: 100,
|
|
GetResumeTsNs: func() int64 {
|
|
return 100
|
|
},
|
|
}
|
|
stream := &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{Name: "file"},
|
|
}},
|
|
{TsNs: 500, EventNotification: &filer_pb.EventNotification{}},
|
|
},
|
|
}
|
|
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if filer_pb.IsEmpty(resp) {
|
|
markers++
|
|
} else {
|
|
events++
|
|
}
|
|
return nil
|
|
})
|
|
if err := fn(&fakeFilerClient{stream: stream}); err != nil {
|
|
t.Fatalf("follow: %v", err)
|
|
}
|
|
if markers != 1 || events != 1 {
|
|
t.Fatalf("expected 1 marker and 1 event at processEventFn, got %d and %d", markers, events)
|
|
}
|
|
if option.StartTsNs != 100 {
|
|
t.Fatalf("callback consumer must not mutate StartTsNs, got %d", option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
func TestFilerSyncMarkerCallbackRetries(t *testing.T) {
|
|
var calls int
|
|
option := &MetadataFollowOption{
|
|
StartTsNs: 100,
|
|
EventErrorType: RetryForeverOnError,
|
|
GetResumeTsNs: func() int64 { return 100 },
|
|
}
|
|
stream := &fakeSubscribeStream{responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{TsNs: 500, EventNotification: &filer_pb.EventNotification{}},
|
|
}}
|
|
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
return nil
|
|
})
|
|
if err := fn(&fakeFilerClient{stream: stream}); err != nil {
|
|
t.Fatalf("follow: %v", err)
|
|
}
|
|
if calls != 2 || option.StartTsNs != 100 {
|
|
t.Fatalf("calls = %d, cursor = %d; want 2 calls and unchanged cursor 100", calls, option.StartTsNs)
|
|
}
|
|
}
|
|
|
|
// Each subscribe call re-reads the callback, so a reconnect after the consumer
|
|
// made progress resumes from the newer watermark.
|
|
func TestFilerSyncReconnectReadsWatermarkEachSubscribe(t *testing.T) {
|
|
var watermark atomic.Int64
|
|
watermark.Store(100)
|
|
var sinceNs []int64
|
|
var mu sync.Mutex
|
|
stream := &fakeSubscribeStream{
|
|
responses: []*filer_pb.SubscribeMetadataResponse{
|
|
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{Name: "file"},
|
|
}},
|
|
},
|
|
}
|
|
client := &recordingFilerClient{
|
|
onSubscribe: func(req *filer_pb.SubscribeMetadataRequest) {
|
|
mu.Lock()
|
|
sinceNs = append(sinceNs, req.SinceNs)
|
|
mu.Unlock()
|
|
},
|
|
stream: stream,
|
|
}
|
|
option := &MetadataFollowOption{
|
|
ClientName: "syncFrom_A_To_B",
|
|
StartTsNs: 100,
|
|
GetResumeTsNs: func() int64 {
|
|
return watermark.Load()
|
|
},
|
|
}
|
|
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
watermark.Store(resp.TsNs)
|
|
return nil
|
|
})
|
|
if err := fn(client); err != nil {
|
|
t.Fatalf("first subscribe: %v", err)
|
|
}
|
|
client.stream = &fakeSubscribeStream{}
|
|
if err := fn(client); err != nil {
|
|
t.Fatalf("resubscribe: %v", err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(sinceNs) != 2 || sinceNs[0] != 100 || sinceNs[1] != 300 {
|
|
t.Fatalf("expected subscribes at 100 then 300, got %v", sinceNs)
|
|
}
|
|
}
|