mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-12 09:17:44 +02:00
* fix(filer): end metadata subscriptions before gRPC GracefulStop on shutdown GracefulStop waits for every open stream. The filer's own MetaAggregator subscription and, under -s3, the S3 gateway's IAM subscription live in the same process and only end when the filer shuts down, which happens after GracefulStop - so every shutdown sat out the full 15s timeout. StopSubscriptions ends them (and any started later) right after leaving the lock ring; the handlers derive their context from the stream and this signal. Measured on weed server -s3: stop 25.6s -> 10.7s, and 0.6s with -volume.preStopSeconds=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FTq6bDpgdQfqQagQsUUvdw * fix(filer): end stopped subscriptions with Unavailable, not cleanly A subscription that StopSubscriptions cut short returned nil. The client reads that as io.EOF, "caught up, done", and util.RetryUntil - which the S3 gateway and mount follow with - stops on nil. A separate S3 gateway therefore never resubscribed after its filer restarted (measured: 0 subscriptions in 25s after the restart; upstream master and this fix: it is back within a second). endOfSubscription now turns that clean end into codes.Unavailable, which is what a dropped connection looks like, so followers reconnect. Errors pass through, and so does the end of a stream the client closed. A subscriber that has stopped reading is still left to GracefulStop's timeout: its handler sits in stream.Send, which only the end of the stream releases, and grpc-go does not allow Send after the handler returns. Documented at StopSubscriptions. Shutdown under weed server -s3 is unchanged at 10.7s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FTq6bDpgdQfqQagQsUUvdw * fix(filer): interrupt in-flight subscription reads and end them Unavailable StopSubscriptions only reached the wait points: a handler replaying the ring or the persisted log kept streaming until the pass ended, and a pass cut short at a ctx.Err() boundary (the replay semaphore, chunk ref collection, ref sends) returned a wrapped context.Canceled, which reads as codes.Canceled on the wire - a code transient-error classifiers treat as non-retryable. eachLogEntryFn and sendRefsBatched now check the subscription context per entry/batch, and endOfSubscription reports a context.Canceled cut short by the stop signal as codes.Unavailable, same as a clean end it interrupted. * filer: honor the subscription context in the pipelined sender A subscriber that stops reading wedges sendLoop in stream.Send; Send and Close then blocked past StopSubscriptions, so GracefulStop still waited out its timeout. Both now return when the subscription context ends, letting the handler return and gRPC tear the stream down. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: stop sendLoop from starting sends after the subscription ends With Send and Close released by the subscription context, sendLoop could still issue a stream.Send after the handler returned. Gate every send on the context so at most the in-flight Send overlaps teardown - and the stream's own teardown is what unblocks it. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: assert sendLoop exits in the cancel test Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
547 lines
18 KiB
Go
547 lines
18 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
|
|
)
|
|
|
|
// slowStream simulates a gRPC stream with configurable per-Send latency.
|
|
// It counts individual events including those packed inside batches.
|
|
// Atomic counters use atomic.Int64 so they stay 8-byte aligned on 32-bit
|
|
// architectures (386, ARMv7, mips32) where a bare int64 struct field is
|
|
// only 4-byte aligned and panics under atomic.AddInt64.
|
|
type slowStream struct {
|
|
sends atomic.Int64 // number of stream.Send() calls
|
|
eventsSent atomic.Int64 // total events (1 + len(Events) per Send)
|
|
sendDelay time.Duration
|
|
}
|
|
|
|
func (s *slowStream) Send(msg *filer_pb.SubscribeMetadataResponse) error {
|
|
time.Sleep(s.sendDelay)
|
|
s.sends.Add(1)
|
|
s.eventsSent.Add(1 + int64(len(msg.Events)))
|
|
return nil
|
|
}
|
|
|
|
type collectingStream struct {
|
|
messages []*filer_pb.SubscribeMetadataResponse
|
|
err error
|
|
}
|
|
|
|
func (s *collectingStream) Send(msg *filer_pb.SubscribeMetadataResponse) error {
|
|
if s.err != nil {
|
|
return s.err
|
|
}
|
|
s.messages = append(s.messages, msg)
|
|
return nil
|
|
}
|
|
|
|
func makeEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
|
|
return &filer_pb.SubscribeMetadataResponse{
|
|
Directory: dir,
|
|
TsNs: tsNs,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// makeOldEvents creates events with timestamps far in the past (triggers batch mode).
|
|
func makeOldEvents(n int) []*filer_pb.SubscribeMetadataResponse {
|
|
baseTs := time.Now().Add(-time.Hour).UnixNano() // 1 hour ago → well past batchBehindThreshold
|
|
events := make([]*filer_pb.SubscribeMetadataResponse, n)
|
|
for i := range events {
|
|
events[i] = makeEvent("/bucket/dir", fmt.Sprintf("file%06d.txt", i), baseTs+int64(i))
|
|
}
|
|
return events
|
|
}
|
|
|
|
// makeRecentEvents creates events with timestamps close to now (sends one-by-one).
|
|
func makeRecentEvents(n int) []*filer_pb.SubscribeMetadataResponse {
|
|
baseTs := time.Now().UnixNano()
|
|
events := make([]*filer_pb.SubscribeMetadataResponse, n)
|
|
for i := range events {
|
|
events[i] = makeEvent("/bucket/dir", fmt.Sprintf("file%06d.txt", i), baseTs+int64(i))
|
|
}
|
|
return events
|
|
}
|
|
|
|
// TestPipelinedSenderThroughput compares direct (blocking) stream.Send with
|
|
// the pipelinedSender with adaptive batching.
|
|
//
|
|
// Simulates realistic backlog catch-up: the reader loads one log file at a time
|
|
// from a volume server (fileReadDelay per file), producing a burst of ~300
|
|
// events. The sender has per-Send gRPC overhead (sendDelay).
|
|
//
|
|
// - Direct: serial — each event: send one-by-one between file reads
|
|
// - Pipelined+batched: file I/O overlaps with batched sending
|
|
func TestPipelinedSenderThroughput(t *testing.T) {
|
|
const (
|
|
eventsPerFile = 300 // events in one minute-log file
|
|
numFiles = 7 // files to process
|
|
totalEvents = eventsPerFile * numFiles // 2100
|
|
fileReadDelay = 5 * time.Millisecond // volume server read per log file
|
|
sendDelay = 50 * time.Microsecond // gRPC round-trip per Send()
|
|
)
|
|
|
|
// Partition old events into file-sized bursts
|
|
files := make([][]*filer_pb.SubscribeMetadataResponse, numFiles)
|
|
baseTs := time.Now().Add(-time.Hour).UnixNano()
|
|
for f := 0; f < numFiles; f++ {
|
|
files[f] = make([]*filer_pb.SubscribeMetadataResponse, eventsPerFile)
|
|
for i := 0; i < eventsPerFile; i++ {
|
|
idx := f*eventsPerFile + i
|
|
files[f][i] = makeEvent("/bucket/dir", fmt.Sprintf("file%06d.txt", idx), baseTs+int64(idx))
|
|
}
|
|
}
|
|
|
|
// --- Direct (old behavior): read file, send events one-by-one, repeat ---
|
|
var directRate float64
|
|
t.Run("direct_send", func(t *testing.T) {
|
|
stream := &slowStream{sendDelay: sendDelay}
|
|
|
|
start := time.Now()
|
|
for _, file := range files {
|
|
time.Sleep(fileReadDelay) // read log file from volume server
|
|
for _, ev := range file {
|
|
if err := stream.Send(ev); err != nil {
|
|
t.Fatalf("send error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
elapsed := time.Since(start)
|
|
|
|
directRate = float64(stream.eventsSent.Load()) / elapsed.Seconds()
|
|
t.Logf("direct: %d events %4d sends %v %6.0f events/sec",
|
|
stream.eventsSent.Load(), stream.sends.Load(), elapsed.Round(time.Millisecond), directRate)
|
|
})
|
|
|
|
// --- Pipelined + batched (new behavior): file reads overlap with batched sends ---
|
|
var batchedRate float64
|
|
t.Run("pipelined_batched_send", func(t *testing.T) {
|
|
stream := &slowStream{sendDelay: sendDelay}
|
|
sender := newPipelinedSender(context.Background(), stream, 1024, true)
|
|
|
|
start := time.Now()
|
|
for _, file := range files {
|
|
time.Sleep(fileReadDelay) // read log file from volume server
|
|
for _, ev := range file {
|
|
if err := sender.Send(ev); err != nil {
|
|
t.Fatalf("send error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if err := sender.Close(); err != nil {
|
|
t.Fatalf("close error: %v", err)
|
|
}
|
|
elapsed := time.Since(start)
|
|
|
|
batchedRate = float64(stream.eventsSent.Load()) / elapsed.Seconds()
|
|
t.Logf("pipelined+batch: %d events %4d sends %v %6.0f events/sec",
|
|
stream.eventsSent.Load(), stream.sends.Load(), elapsed.Round(time.Millisecond), batchedRate)
|
|
})
|
|
|
|
if directRate > 0 {
|
|
t.Logf("Speedup: %.1fx (pipelined+batched vs direct)", batchedRate/directRate)
|
|
}
|
|
}
|
|
|
|
func TestEachEventNotificationFnMatchesRenameTargetsForAllWatchTypes(t *testing.T) {
|
|
fs := &FilerServer{
|
|
option: &FilerOption{Host: pb.ServerAddress("127.0.0.1:8888")},
|
|
filer: &filer.Filer{Signature: 123},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *filer_pb.SubscribeMetadataRequest
|
|
}{
|
|
{
|
|
name: "additional path prefix",
|
|
req: &filer_pb.SubscribeMetadataRequest{
|
|
ClientName: "test",
|
|
PathPrefix: "/data/",
|
|
PathPrefixes: []string{"/etc/remote"},
|
|
},
|
|
},
|
|
{
|
|
name: "directory watch",
|
|
req: &filer_pb.SubscribeMetadataRequest{
|
|
ClientName: "test",
|
|
PathPrefix: "/data/",
|
|
Directories: []string{"/etc/iam/identities"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
stream := &collectingStream{}
|
|
var unsyncedEvents int64
|
|
eachEventFn := fs.eachEventNotificationFn(tt.req, stream, "client", &unsyncedEvents)
|
|
|
|
newDir := "/etc/remote"
|
|
if len(tt.req.Directories) > 0 {
|
|
newDir = tt.req.Directories[0]
|
|
}
|
|
err := eachEventFn("/tmp", &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: "old"},
|
|
NewEntry: &filer_pb.Entry{Name: "new"},
|
|
NewParentPath: newDir,
|
|
}, time.Now().UnixNano())
|
|
if err != nil {
|
|
t.Fatalf("eachEventFn: %v", err)
|
|
}
|
|
if len(stream.messages) != 1 {
|
|
t.Fatalf("messages sent = %d, want 1", len(stream.messages))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchingAdaptive verifies the adaptive behavior: old events are batched,
|
|
// recent events are sent one-by-one.
|
|
func TestBatchingAdaptive(t *testing.T) {
|
|
const numEvents = 500
|
|
|
|
t.Run("old_events_are_batched", func(t *testing.T) {
|
|
stream := &slowStream{sendDelay: 10 * time.Microsecond}
|
|
sender := newPipelinedSender(context.Background(), stream, 1024, true)
|
|
|
|
// Push all events at once (no read delay) so the sender can batch aggressively
|
|
for _, ev := range makeOldEvents(numEvents) {
|
|
sender.Send(ev)
|
|
}
|
|
sender.Close()
|
|
|
|
sends := stream.sends.Load()
|
|
events := stream.eventsSent.Load()
|
|
t.Logf("old events: %d events in %d sends (avg batch size: %.1f)",
|
|
events, sends, float64(events)/float64(sends))
|
|
|
|
if sends >= int64(numEvents) {
|
|
t.Errorf("expected batching to reduce sends below %d, got %d", numEvents, sends)
|
|
}
|
|
})
|
|
|
|
t.Run("recent_events_sent_individually", func(t *testing.T) {
|
|
stream := &slowStream{sendDelay: 10 * time.Microsecond}
|
|
sender := newPipelinedSender(context.Background(), stream, 1024, true)
|
|
|
|
for _, ev := range makeRecentEvents(numEvents) {
|
|
sender.Send(ev)
|
|
}
|
|
sender.Close()
|
|
|
|
sends := stream.sends.Load()
|
|
events := stream.eventsSent.Load()
|
|
t.Logf("recent events: %d events in %d sends (avg batch size: %.1f)",
|
|
events, sends, float64(events)/float64(sends))
|
|
|
|
if sends != int64(numEvents) {
|
|
t.Errorf("expected 1:1 sends for recent events, got %d sends for %d events", sends, numEvents)
|
|
}
|
|
})
|
|
}
|
|
|
|
// errorStreamImpl is a metadataStreamSender that returns an error after N sends.
|
|
// count uses atomic.Int64 so it stays 8-byte aligned on 32-bit architectures
|
|
// (386, ARMv7, mips32) where a bare int64 struct field after smaller fields
|
|
// is only 4-byte aligned and panics under atomic.AddInt64.
|
|
type errorStreamImpl struct {
|
|
count atomic.Int64
|
|
failAfter int
|
|
err error
|
|
}
|
|
|
|
func (s *errorStreamImpl) Send(msg *filer_pb.SubscribeMetadataResponse) error {
|
|
n := s.count.Add(1)
|
|
if int(n) > s.failAfter {
|
|
return s.err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestPipelinedSenderErrorPropagation verifies that when stream.Send fails,
|
|
// the error propagates to pipelinedSender.Send callers and Close.
|
|
func TestPipelinedSenderErrorPropagation(t *testing.T) {
|
|
sendErr := fmt.Errorf("connection reset")
|
|
|
|
t.Run("send_returns_error", func(t *testing.T) {
|
|
// Stream fails after 5 successful sends
|
|
stream := &errorStreamImpl{failAfter: 5, err: sendErr}
|
|
sender := newPipelinedSender(context.Background(), stream, 4, true)
|
|
|
|
var lastErr error
|
|
for i := 0; i < 100; i++ {
|
|
ev := makeOldEvents(1)[0]
|
|
if err := sender.Send(ev); err != nil {
|
|
lastErr = err
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastErr == nil {
|
|
t.Fatal("expected Send to return an error after stream failure")
|
|
}
|
|
t.Logf("Send returned error after stream failure: %v", lastErr)
|
|
})
|
|
|
|
t.Run("close_returns_error_if_not_consumed", func(t *testing.T) {
|
|
// Stream fails on the very first send — error surfaces via Close
|
|
// since Send may have already returned before the sender goroutine
|
|
// processes the message.
|
|
stream := &errorStreamImpl{failAfter: 0, err: sendErr}
|
|
sender := newPipelinedSender(context.Background(), stream, 1024, true)
|
|
|
|
ev := makeOldEvents(1)[0]
|
|
sender.Send(ev)
|
|
|
|
closeErr := sender.Close()
|
|
if closeErr == nil {
|
|
t.Log("Close returned nil (error was consumed by Send)")
|
|
} else {
|
|
t.Logf("Close returned error: %v", closeErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPipelinedSingleVsParallelStreams shows 1 pipelined+batched stream vs
|
|
// N parallel pipelined+batched streams, using the realistic burst-read pattern.
|
|
func TestPipelinedSingleVsParallelStreams(t *testing.T) {
|
|
const (
|
|
numDirs = 10
|
|
filesPerDir = 7 // log files per directory
|
|
eventsPerFile = 300 // events per log file
|
|
totalEvents = numDirs * filesPerDir * eventsPerFile // 21000
|
|
fileReadDelay = 5 * time.Millisecond
|
|
sendDelay = 50 * time.Microsecond
|
|
)
|
|
|
|
// Generate partitioned OLD events grouped into file-sized bursts
|
|
baseTs := time.Now().Add(-time.Hour).UnixNano()
|
|
type logFile []*filer_pb.SubscribeMetadataResponse
|
|
// partitions[dir][file][event]
|
|
partitions := make([][]logFile, numDirs)
|
|
var allFiles []logFile
|
|
idx := 0
|
|
for d := 0; d < numDirs; d++ {
|
|
dir := fmt.Sprintf("/bucket/dir%03d", d)
|
|
for f := 0; f < filesPerDir; f++ {
|
|
file := make(logFile, eventsPerFile)
|
|
for i := 0; i < eventsPerFile; i++ {
|
|
file[i] = makeEvent(dir, fmt.Sprintf("file%06d.txt", idx), baseTs+int64(idx))
|
|
idx++
|
|
}
|
|
partitions[d] = append(partitions[d], file)
|
|
allFiles = append(allFiles, file)
|
|
}
|
|
}
|
|
|
|
// simulatePipeline: read files with I/O delay, push events, send via pipelinedSender
|
|
simulatePipeline := func(files []logFile) (eventsSent, sends int64, elapsed time.Duration, err error) {
|
|
stream := &slowStream{sendDelay: sendDelay}
|
|
sender := newPipelinedSender(context.Background(), stream, 1024, true)
|
|
|
|
start := time.Now()
|
|
outer:
|
|
for _, file := range files {
|
|
time.Sleep(fileReadDelay) // volume server read
|
|
for _, ev := range file {
|
|
if err = sender.Send(ev); err != nil {
|
|
break outer
|
|
}
|
|
}
|
|
}
|
|
if closeErr := sender.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
elapsed = time.Since(start)
|
|
eventsSent = stream.eventsSent.Load()
|
|
sends = stream.sends.Load()
|
|
return
|
|
}
|
|
|
|
var singleRate float64
|
|
t.Run("1_pipelined_stream", func(t *testing.T) {
|
|
eventsSent, sends, elapsed, err := simulatePipeline(allFiles)
|
|
if err != nil {
|
|
t.Fatalf("pipeline error: %v", err)
|
|
}
|
|
singleRate = float64(eventsSent) / elapsed.Seconds()
|
|
t.Logf("1 stream: %5d events %4d sends %v %7.0f events/sec",
|
|
eventsSent, sends, elapsed.Round(time.Millisecond), singleRate)
|
|
})
|
|
|
|
var parallelRate float64
|
|
t.Run("10_pipelined_streams", func(t *testing.T) {
|
|
// atomic.Int64 guarantees 8-byte alignment on 32-bit architectures where
|
|
// a local int64 variable's address is only 4-byte aligned and atomic
|
|
// 64-bit operations panic with "unaligned 64-bit atomic operation".
|
|
var totalEventsSent, totalSends atomic.Int64
|
|
var wg sync.WaitGroup
|
|
|
|
start := time.Now()
|
|
for d := 0; d < numDirs; d++ {
|
|
wg.Add(1)
|
|
go func(files []logFile) {
|
|
defer wg.Done()
|
|
eventsSent, sends, _, _ := simulatePipeline(files)
|
|
totalEventsSent.Add(eventsSent)
|
|
totalSends.Add(sends)
|
|
}(partitions[d])
|
|
}
|
|
wg.Wait()
|
|
elapsed := time.Since(start)
|
|
|
|
totalEvents := totalEventsSent.Load()
|
|
parallelRate = float64(totalEvents) / elapsed.Seconds()
|
|
t.Logf("%d streams: %5d events %4d sends %v %7.0f events/sec",
|
|
numDirs, totalEvents, totalSends.Load(), elapsed.Round(time.Millisecond), parallelRate)
|
|
})
|
|
|
|
if singleRate > 0 && parallelRate > 0 {
|
|
t.Logf("Speedup: %.1fx (%d parallel pipelined streams vs 1)", parallelRate/singleRate, numDirs)
|
|
}
|
|
}
|
|
|
|
func TestMaybeSendIdleHeartbeat(t *testing.T) {
|
|
lb := log_buffer.NewLogBuffer("test", time.Minute, nil, nil, nil)
|
|
defer lb.ShutdownLogBuffer()
|
|
|
|
fs := &FilerServer{}
|
|
const recentEvent = int64(1_000_000)
|
|
|
|
t.Run("not opted in", func(t *testing.T) {
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: false}
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, recentEvent, recentEvent, 0)
|
|
if got != 0 || len(s.messages) != 0 {
|
|
t.Fatalf("expected no heartbeat, got lastHeartbeat=%d msgs=%d", got, len(s.messages))
|
|
}
|
|
})
|
|
|
|
t.Run("behind buffer head", func(t *testing.T) {
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: true}
|
|
// startTs and lastSeen both below the buffer head: still replaying.
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, 0, recentEvent-1, 0)
|
|
if got != 0 || len(s.messages) != 0 {
|
|
t.Fatalf("expected no heartbeat while behind, got lastHeartbeat=%d msgs=%d", got, len(s.messages))
|
|
}
|
|
})
|
|
|
|
t.Run("caught up via lastSeen", func(t *testing.T) {
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: true}
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, 0, recentEvent, 0)
|
|
if len(s.messages) != 1 {
|
|
t.Fatalf("expected one heartbeat, got %d", len(s.messages))
|
|
}
|
|
hb := s.messages[0]
|
|
if hb.EventNotification != nil || len(hb.Events) != 0 || hb.TsNs <= 0 {
|
|
t.Fatalf("heartbeat should be an empty timestamped response, got %+v", hb)
|
|
}
|
|
if got != hb.TsNs {
|
|
t.Fatalf("expected returned lastHeartbeat %d to equal sent ts %d", got, hb.TsNs)
|
|
}
|
|
})
|
|
|
|
t.Run("caught up via read position floor", func(t *testing.T) {
|
|
// The read cursor has advanced past the buffer head while lastSeen stayed
|
|
// 0. This is the idle-source case (subscribed from "now", read nothing) and
|
|
// also metadata-chunks mode, where persisted entries replay as log file
|
|
// refs and never reach eachLogEntryFn.
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: true}
|
|
readPosition := time.Now().UnixNano()
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, readPosition, 0, 0)
|
|
if len(s.messages) != 1 || got <= 0 {
|
|
t.Fatalf("expected heartbeat for caught-up subscriber, got msgs=%d lastHeartbeat=%d", len(s.messages), got)
|
|
}
|
|
})
|
|
|
|
t.Run("throttled within interval", func(t *testing.T) {
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: true}
|
|
justSent := time.Now().UnixNano()
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, 0, recentEvent, justSent)
|
|
if got != justSent || len(s.messages) != 0 {
|
|
t.Fatalf("expected throttled (no send), got lastHeartbeat=%d msgs=%d", got, len(s.messages))
|
|
}
|
|
})
|
|
|
|
t.Run("send error keeps prior heartbeat time", func(t *testing.T) {
|
|
lb.LastTsNs.Store(recentEvent)
|
|
s := &collectingStream{err: fmt.Errorf("broken stream")}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientSupportsIdleHeartbeat: true}
|
|
got := fs.maybeSendIdleHeartbeat(req, s, lb, 0, recentEvent, 0)
|
|
if got != 0 {
|
|
t.Fatalf("expected lastHeartbeat unchanged on send error, got %d", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFilteredEventsEmitMaxUnsyncedMarker pins the source-side shape the client
|
|
// keys off: after MaxUnsyncedEvents filtered events, eachEventNotificationFn
|
|
// emits a marker with a fresh timestamp and a non-nil but empty EventNotification.
|
|
// Consumed by TestFilerSyncOffsetStaysFreshOnFilteredMarker.
|
|
func TestFilteredEventsEmitMaxUnsyncedMarker(t *testing.T) {
|
|
fs := &FilerServer{
|
|
option: &FilerOption{Host: pb.ServerAddress("127.0.0.1:8888")},
|
|
filer: &filer.Filer{Signature: 123},
|
|
}
|
|
req := &filer_pb.SubscribeMetadataRequest{ClientName: "syncFrom_A_To_B", PathPrefix: "/watched/"}
|
|
|
|
stream := &collectingStream{}
|
|
var unsyncedEvents int64
|
|
eachEventFn := fs.eachEventNotificationFn(req, stream, "client", &unsyncedEvents)
|
|
|
|
base := time.Now().UnixNano()
|
|
var lastTsNs int64
|
|
// Feed MaxUnsyncedEvents+1 events on a NON-watched path so every one is filtered.
|
|
total := int(MaxUnsyncedEvents) + 1
|
|
for i := 0; i < total; i++ {
|
|
lastTsNs = base + int64(i)
|
|
err := eachEventFn("/other/dir", &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{Name: fmt.Sprintf("file%d", i)},
|
|
}, lastTsNs)
|
|
if err != nil {
|
|
t.Fatalf("eachEventFn: %v", err)
|
|
}
|
|
}
|
|
|
|
if len(stream.messages) != 1 {
|
|
t.Fatalf("expected exactly 1 MaxUnsyncedEvents marker, got %d", len(stream.messages))
|
|
}
|
|
marker := stream.messages[0]
|
|
if !filer_pb.IsEmpty(marker) {
|
|
t.Errorf("marker should have empty EventNotification (IsEmpty), got %+v", marker.EventNotification)
|
|
}
|
|
if marker.EventNotification == nil {
|
|
t.Error("marker EventNotification should be non-nil but empty (the shape the client keys off)")
|
|
}
|
|
if marker.TsNs != lastTsNs {
|
|
t.Errorf("marker TsNs = %d, want fresh source ts %d", marker.TsNs, lastTsNs)
|
|
}
|
|
t.Logf("source emits marker{EventNotification:&{}, TsNs:%d} after %d filtered events", marker.TsNs, total)
|
|
}
|