package weed_server import ( "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(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(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(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(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(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(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) }