Browse Source
filer.sync: pipelined subscription with adaptive batching for faster catch-up (#8791)
filer.sync: pipelined subscription with adaptive batching for faster catch-up (#8791)
* filer.sync: pipelined subscription with adaptive batching for faster catch-up The SubscribeMetadata pipeline was fully serial: reading a log entry from a volume server, unmarshaling, filtering, and calling stream.Send() all happened one-at-a-time. stream.Send() blocked the entire pipeline until the client acknowledged each event, limiting throughput to ~80 events/sec regardless of the -concurrency setting. Three server-side optimizations that stack: 1. Pipelined sender: decouple stream.Send() from the read loop via a buffered channel (1024 messages). A dedicated goroutine handles gRPC delivery while the reader continues processing the next events. 2. Adaptive batching: when event timestamps are >2min behind wall clock (backlog catch-up), drain multiple events from the channel and pack them into a single stream.Send() using a new `repeated events` field on SubscribeMetadataResponse. When events are recent (real-time), send one-by-one for low latency. Old clients ignore the new field (backward compatible). 3. Persisted log readahead: run the OrderedLogVisitor in a background goroutine so volume server I/O for the next log file overlaps with event processing and gRPC delivery. 4. Event-driven aggregated subscription: replace time.Sleep(1127ms) polling in SubscribeMetadata with notification-driven wake-up using the MetaLogBuffer subscriber mechanism, reducing real-time latency from ~1127ms to sub-millisecond. Combined, these create a 3-stage pipeline: [Volume I/O → readahead buffer] → [Filter → send buffer] → [gRPC Send] Test results (simulated backlog with 50µs gRPC latency per Send): direct (old): 2100 events 2100 sends 168ms 12,512 events/sec pipelined+batched: 2100 events 14 sends 40ms 52,856 events/sec Speedup: 4.2x single-stream throughput Ref: #8771 * filer.sync: require client opt-in for batch event delivery Add ClientSupportsBatching field to SubscribeMetadataRequest. The server only packs events into the Events batch field when the client explicitly sets this flag to true. Old clients (Java SDK, third-party) that don't set the flag get one-event-per-Send, preserving backward compatibility. All Go callers (FollowMetadata, MetaAggregator) set the flag to true since their recv loops already unpack batched events. * filer.sync: clear batch Events field after Send to release references Prevents the envelope message from holding references to the rest of the batch after gRPC serialization, allowing the GC to collect them sooner. * filer.sync: fix Send deadlock, add error propagation test, event-driven local subscribe - pipelinedSender.Send: add case <-s.done to unblock when sender goroutine exits (fixes deadlock when errCh was already consumed by a prior Send). - pipelinedSender.reportErr: remove for-range drain on sendCh that could block indefinitely. Send() now detects exit via s.done instead. - SubscribeLocalMetadata: replace remaining time.Sleep(1127ms) in the gap-detected-no-memory-data path with event-driven listenersCond.Wait(), consistent with the rest of the subscription paths. - Add TestPipelinedSenderErrorPropagation: verifies error surfaces via Send and Close when the underlying stream fails. - Replace goto with labeled break in test simulatePipeline. * filer.sync: check error returns in test code - direct_send: check slowStream.Send error return - pipelined_batched_send: check sender.Close error return - simulatePipeline: return error from sender.Close, propagate to callers --------- Co-authored-by: Copilot <copilot@github.com>pull/6224/merge
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1039 additions and 147 deletions
-
2other/java/client/src/main/proto/filer.proto
-
327weed/command/filer_sync_subscription_test.go
-
50weed/filer/filer_notify.go
-
35weed/filer/meta_aggregator.go
-
2weed/pb/filer.proto
-
201weed/pb/filer_pb/filer.pb.go
-
66weed/pb/filer_pb_tail.go
-
169weed/server/filer_grpc_server_sub_meta.go
-
334weed/server/filer_grpc_server_sub_meta_test.go
@ -0,0 +1,327 @@ |
|||
package command |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
"sync" |
|||
"sync/atomic" |
|||
"testing" |
|||
"time" |
|||
|
|||
"google.golang.org/protobuf/proto" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer" |
|||
) |
|||
|
|||
// createFileEvent creates a SubscribeMetadataResponse for a file creation.
|
|||
func createFileEvent(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, |
|||
}, |
|||
}, |
|||
} |
|||
} |
|||
|
|||
// partitionedEvents creates file creation events spread across numDirs directories.
|
|||
func partitionedEvents(numDirs, filesPerDir int) (partitions [][]*filer_pb.SubscribeMetadataResponse, all []*filer_pb.SubscribeMetadataResponse) { |
|||
baseTs := time.Now().UnixNano() |
|||
partitions = make([][]*filer_pb.SubscribeMetadataResponse, numDirs) |
|||
for d := 0; d < numDirs; d++ { |
|||
dir := fmt.Sprintf("/bucket/dir%03d", d) |
|||
for f := 0; f < filesPerDir; f++ { |
|||
tsNs := baseTs + int64(d*filesPerDir+f) + 1 |
|||
event := createFileEvent(dir, fmt.Sprintf("file%06d.txt", f), tsNs) |
|||
partitions[d] = append(partitions[d], event) |
|||
all = append(all, event) |
|||
} |
|||
} |
|||
return |
|||
} |
|||
|
|||
// runSingleStream feeds all events through one MetadataProcessor with a per-event
|
|||
// stream delivery delay (simulating a single gRPC SubscribeMetadata stream).
|
|||
func runSingleStream(events []*filer_pb.SubscribeMetadataResponse, concurrency int, streamDelay, processDelay time.Duration) (processed int64, elapsed time.Duration) { |
|||
var wg sync.WaitGroup |
|||
|
|||
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error { |
|||
defer wg.Done() |
|||
time.Sleep(processDelay) |
|||
atomic.AddInt64(&processed, 1) |
|||
return nil |
|||
} |
|||
|
|||
processor := NewMetadataProcessor(processFn, concurrency, 0) |
|||
|
|||
start := time.Now() |
|||
for _, event := range events { |
|||
if streamDelay > 0 { |
|||
time.Sleep(streamDelay) |
|||
} |
|||
wg.Add(1) |
|||
processor.AddSyncJob(event) |
|||
} |
|||
wg.Wait() |
|||
elapsed = time.Since(start) |
|||
return |
|||
} |
|||
|
|||
// runParallelStreams feeds partitioned events through separate MetadataProcessors,
|
|||
// each in its own goroutine (simulating parallel per-directory gRPC streams).
|
|||
func runParallelStreams(partitions [][]*filer_pb.SubscribeMetadataResponse, concurrency int, streamDelay, processDelay time.Duration) (processed int64, elapsed time.Duration) { |
|||
var outerWg sync.WaitGroup |
|||
|
|||
start := time.Now() |
|||
for _, dirEvents := range partitions { |
|||
outerWg.Add(1) |
|||
go func(events []*filer_pb.SubscribeMetadataResponse) { |
|||
defer outerWg.Done() |
|||
|
|||
var wg sync.WaitGroup |
|||
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error { |
|||
defer wg.Done() |
|||
time.Sleep(processDelay) |
|||
atomic.AddInt64(&processed, 1) |
|||
return nil |
|||
} |
|||
|
|||
processor := NewMetadataProcessor(processFn, concurrency, 0) |
|||
for _, event := range events { |
|||
if streamDelay > 0 { |
|||
time.Sleep(streamDelay) |
|||
} |
|||
wg.Add(1) |
|||
processor.AddSyncJob(event) |
|||
} |
|||
wg.Wait() |
|||
}(dirEvents) |
|||
} |
|||
outerWg.Wait() |
|||
elapsed = time.Since(start) |
|||
return |
|||
} |
|||
|
|||
// TestStreamDeliveryBottleneck demonstrates that a single serial event stream
|
|||
// is the primary throughput bottleneck, and N parallel streams achieve N× throughput.
|
|||
//
|
|||
// Reproduces discussion #8771: single filer.sync "/" achieves ~80 events/sec,
|
|||
// while N parallel processes for individual directories achieve N × ~80 events/sec.
|
|||
//
|
|||
// The bottleneck is the serial gRPC metadata stream, NOT conflict detection or
|
|||
// processing concurrency.
|
|||
func TestStreamDeliveryBottleneck(t *testing.T) { |
|||
const ( |
|||
numDirs = 10 |
|||
filesPerDir = 200 |
|||
// Per-event stream delivery overhead (server-side log read + gRPC round-trip).
|
|||
// Production: ~10-12ms giving ~80-100 events/sec. Scaled down for test speed.
|
|||
streamDelay = 50 * time.Microsecond |
|||
processDelay = 200 * time.Microsecond |
|||
) |
|||
|
|||
partitions, allEvents := partitionedEvents(numDirs, filesPerDir) |
|||
|
|||
singleCount, singleElapsed := runSingleStream(allEvents, 256, streamDelay, processDelay) |
|||
singleRate := float64(singleCount) / singleElapsed.Seconds() |
|||
t.Logf("1 stream: %4d events %v %6.0f events/sec", |
|||
singleCount, singleElapsed.Round(time.Millisecond), singleRate) |
|||
|
|||
parallelCount, parallelElapsed := runParallelStreams(partitions, 256, streamDelay, processDelay) |
|||
parallelRate := float64(parallelCount) / parallelElapsed.Seconds() |
|||
t.Logf("%d streams: %4d events %v %6.0f events/sec", |
|||
numDirs, parallelCount, parallelElapsed.Round(time.Millisecond), parallelRate) |
|||
|
|||
speedup := parallelRate / singleRate |
|||
t.Logf("Speedup: %.1fx (%d parallel streams vs 1 stream)", speedup, numDirs) |
|||
|
|||
if singleCount != int64(numDirs*filesPerDir) { |
|||
t.Errorf("single: expected %d events, got %d", numDirs*filesPerDir, singleCount) |
|||
} |
|||
if parallelCount != int64(numDirs*filesPerDir) { |
|||
t.Errorf("parallel: expected %d events, got %d", numDirs*filesPerDir, parallelCount) |
|||
} |
|||
// Parallel should be significantly faster
|
|||
if speedup < float64(numDirs)*0.4 { |
|||
t.Errorf("expected at least %.1fx speedup, got %.1fx", float64(numDirs)*0.4, speedup) |
|||
} |
|||
} |
|||
|
|||
// TestConcurrencyIneffectiveOnStreamBottleneck shows that increasing the
|
|||
// -concurrency flag has no effect when the stream delivery rate is the bottleneck.
|
|||
//
|
|||
// Matches the user observation: "-concurrency=256 is little better than default
|
|||
// but increasing it to 1024 doesn't do anything."
|
|||
func TestConcurrencyIneffectiveOnStreamBottleneck(t *testing.T) { |
|||
const ( |
|||
numDirs = 10 |
|||
filesPerDir = 100 |
|||
streamDelay = 50 * time.Microsecond |
|||
processDelay = 200 * time.Microsecond |
|||
) |
|||
|
|||
_, allEvents := partitionedEvents(numDirs, filesPerDir) |
|||
|
|||
var rates []float64 |
|||
for _, concurrency := range []int{32, 128, 512} { |
|||
count, elapsed := runSingleStream(allEvents, concurrency, streamDelay, processDelay) |
|||
rate := float64(count) / elapsed.Seconds() |
|||
rates = append(rates, rate) |
|||
t.Logf("concurrency=%3d: %d events %v %.0f events/sec", |
|||
concurrency, count, elapsed.Round(time.Millisecond), rate) |
|||
} |
|||
|
|||
if len(rates) >= 2 { |
|||
ratio := rates[len(rates)-1] / rates[0] |
|||
t.Logf("concurrency 512 vs 32: %.2fx (expected ~1.0x when stream-limited)", ratio) |
|||
// Should be within 50% — concurrency doesn't help a stream bottleneck
|
|||
if ratio > 1.5 || ratio < 0.5 { |
|||
t.Errorf("unexpected ratio %.2f: concurrency should not affect stream-limited throughput", ratio) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TestLogBufferSubscriptionThroughput uses the real LogBuffer and LoopProcessLogData
|
|||
// to demonstrate that a single subscriber's callback is called serially (blocking
|
|||
// the event loop), while N parallel subscribers process events concurrently.
|
|||
//
|
|||
// This directly reproduces the server-side pipeline: SubscribeMetadata reads events
|
|||
// from the LogBuffer via LoopProcessLogData, and for each event calls stream.Send()
|
|||
// which blocks until the client acknowledges. A slow client stalls the entire
|
|||
// event loop for that subscriber.
|
|||
func TestLogBufferSubscriptionThroughput(t *testing.T) { |
|||
const ( |
|||
numDirs = 10 |
|||
filesPerDir = 200 |
|||
totalEvents = numDirs * filesPerDir |
|||
processDelay = 200 * time.Microsecond |
|||
) |
|||
|
|||
lb := log_buffer.NewLogBuffer("test-subscription", time.Hour, nil, nil, func() {}) |
|||
|
|||
// Populate buffer with events across directories
|
|||
baseTs := time.Now().UnixNano() |
|||
var firstTsNs, lastTsNs int64 |
|||
for d := 0; d < numDirs; d++ { |
|||
dir := fmt.Sprintf("/data/dir%03d", d) |
|||
for f := 0; f < filesPerDir; f++ { |
|||
tsNs := baseTs + int64(d*filesPerDir+f) + 1 |
|||
if firstTsNs == 0 { |
|||
firstTsNs = tsNs |
|||
} |
|||
lastTsNs = tsNs |
|||
event := createFileEvent(dir, fmt.Sprintf("file%06d.txt", f), tsNs) |
|||
data, err := proto.Marshal(event) |
|||
if err != nil { |
|||
t.Fatalf("marshal: %v", err) |
|||
} |
|||
if err := lb.AddDataToBuffer([]byte(dir), data, tsNs); err != nil { |
|||
t.Fatalf("add to buffer: %v", err) |
|||
} |
|||
} |
|||
} |
|||
|
|||
startPos := log_buffer.NewMessagePosition(firstTsNs-1, -2) |
|||
|
|||
// --- Single subscriber: all events go through one callback serially ---
|
|||
var singleProcessed int64 |
|||
var singleRate float64 |
|||
t.Run("single_subscriber_root", func(t *testing.T) { |
|||
done := make(chan struct{}) |
|||
start := time.Now() |
|||
go func() { |
|||
defer close(done) |
|||
lb.LoopProcessLogData("single-root", startPos, lastTsNs, |
|||
func() bool { return true }, |
|||
func(logEntry *filer_pb.LogEntry) (bool, error) { |
|||
event := &filer_pb.SubscribeMetadataResponse{} |
|||
if err := proto.Unmarshal(logEntry.Data, event); err != nil { |
|||
return false, err |
|||
} |
|||
// All events match "/" — process all
|
|||
time.Sleep(processDelay) |
|||
atomic.AddInt64(&singleProcessed, 1) |
|||
return false, nil |
|||
}) |
|||
}() |
|||
|
|||
select { |
|||
case <-done: |
|||
case <-time.After(30 * time.Second): |
|||
t.Fatal("timed out") |
|||
} |
|||
elapsed := time.Since(start) |
|||
singleRate = float64(singleProcessed) / elapsed.Seconds() |
|||
t.Logf("1 subscriber (/): %4d events %v %6.0f events/sec", |
|||
singleProcessed, elapsed.Round(time.Millisecond), singleRate) |
|||
|
|||
if singleProcessed != int64(totalEvents) { |
|||
t.Errorf("expected %d events, got %d", totalEvents, singleProcessed) |
|||
} |
|||
}) |
|||
|
|||
// --- N parallel subscribers, each filtering for one directory ---
|
|||
var parallelProcessed int64 |
|||
var parallelRate float64 |
|||
t.Run("parallel_subscribers_per_dir", func(t *testing.T) { |
|||
var wg sync.WaitGroup |
|||
|
|||
start := time.Now() |
|||
for d := 0; d < numDirs; d++ { |
|||
wg.Add(1) |
|||
prefix := fmt.Sprintf("/data/dir%03d/", d) |
|||
name := fmt.Sprintf("parallel-dir%03d", d) |
|||
|
|||
go func(pfx, readerName string) { |
|||
defer wg.Done() |
|||
lb.LoopProcessLogData(readerName, startPos, lastTsNs, |
|||
func() bool { return true }, |
|||
func(logEntry *filer_pb.LogEntry) (bool, error) { |
|||
event := &filer_pb.SubscribeMetadataResponse{} |
|||
if err := proto.Unmarshal(logEntry.Data, event); err != nil { |
|||
return false, err |
|||
} |
|||
fullpath := event.Directory |
|||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil { |
|||
fullpath += "/" + event.EventNotification.NewEntry.Name |
|||
} |
|||
if !strings.HasPrefix(fullpath, pfx) { |
|||
return false, nil // skip non-matching — no delay
|
|||
} |
|||
time.Sleep(processDelay) |
|||
atomic.AddInt64(¶llelProcessed, 1) |
|||
return false, nil |
|||
}) |
|||
}(prefix, name) |
|||
} |
|||
|
|||
done := make(chan struct{}) |
|||
go func() { |
|||
wg.Wait() |
|||
close(done) |
|||
}() |
|||
|
|||
select { |
|||
case <-done: |
|||
case <-time.After(30 * time.Second): |
|||
t.Fatal("timed out") |
|||
} |
|||
elapsed := time.Since(start) |
|||
parallelRate = float64(parallelProcessed) / elapsed.Seconds() |
|||
t.Logf("%d subscribers: %4d events %v %6.0f events/sec", |
|||
numDirs, parallelProcessed, elapsed.Round(time.Millisecond), parallelRate) |
|||
|
|||
if parallelProcessed != int64(totalEvents) { |
|||
t.Errorf("expected %d events, got %d", totalEvents, parallelProcessed) |
|||
} |
|||
}) |
|||
|
|||
if singleRate > 0 && parallelRate > 0 { |
|||
speedup := parallelRate / singleRate |
|||
t.Logf("LogBuffer speedup: %.1fx (%d parallel subscribers vs 1)", speedup, numDirs) |
|||
} |
|||
} |
|||
@ -0,0 +1,334 @@ |
|||
package weed_server |
|||
|
|||
import ( |
|||
"fmt" |
|||
"sync" |
|||
"sync/atomic" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
) |
|||
|
|||
// slowStream simulates a gRPC stream with configurable per-Send latency.
|
|||
// It counts individual events including those packed inside batches.
|
|||
type slowStream struct { |
|||
sendDelay time.Duration |
|||
sends int64 // number of stream.Send() calls
|
|||
eventsSent int64 // total events (1 + len(Events) per Send)
|
|||
} |
|||
|
|||
func (s *slowStream) Send(msg *filer_pb.SubscribeMetadataResponse) error { |
|||
time.Sleep(s.sendDelay) |
|||
atomic.AddInt64(&s.sends, 1) |
|||
atomic.AddInt64(&s.eventsSent, 1+int64(len(msg.Events))) |
|||
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) / elapsed.Seconds() |
|||
t.Logf("direct: %d events %4d sends %v %6.0f events/sec", |
|||
stream.eventsSent, stream.sends, 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) / elapsed.Seconds() |
|||
t.Logf("pipelined+batch: %d events %4d sends %v %6.0f events/sec", |
|||
stream.eventsSent, stream.sends, elapsed.Round(time.Millisecond), batchedRate) |
|||
}) |
|||
|
|||
if directRate > 0 { |
|||
t.Logf("Speedup: %.1fx (pipelined+batched vs direct)", batchedRate/directRate) |
|||
} |
|||
} |
|||
|
|||
// 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() |
|||
|
|||
t.Logf("old events: %d events in %d sends (avg batch size: %.1f)", |
|||
stream.eventsSent, stream.sends, float64(stream.eventsSent)/float64(stream.sends)) |
|||
|
|||
if stream.sends >= int64(numEvents) { |
|||
t.Errorf("expected batching to reduce sends below %d, got %d", numEvents, stream.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() |
|||
|
|||
t.Logf("recent events: %d events in %d sends (avg batch size: %.1f)", |
|||
stream.eventsSent, stream.sends, float64(stream.eventsSent)/float64(stream.sends)) |
|||
|
|||
if stream.sends != int64(numEvents) { |
|||
t.Errorf("expected 1:1 sends for recent events, got %d sends for %d events", stream.sends, numEvents) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// errorStreamImpl is a metadataStreamSender that returns an error after N sends.
|
|||
type errorStreamImpl struct { |
|||
failAfter int |
|||
err error |
|||
count int64 |
|||
} |
|||
|
|||
func (s *errorStreamImpl) Send(msg *filer_pb.SubscribeMetadataResponse) error { |
|||
n := atomic.AddInt64(&s.count, 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 = atomic.LoadInt64(&stream.eventsSent) |
|||
sends = atomic.LoadInt64(&stream.sends) |
|||
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) { |
|||
var totalEventsSent, totalSends 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) |
|||
atomic.AddInt64(&totalEventsSent, eventsSent) |
|||
atomic.AddInt64(&totalSends, sends) |
|||
}(partitions[d]) |
|||
} |
|||
wg.Wait() |
|||
elapsed := time.Since(start) |
|||
|
|||
parallelRate = float64(totalEventsSent) / elapsed.Seconds() |
|||
t.Logf("%d streams: %5d events %4d sends %v %7.0f events/sec", |
|||
numDirs, totalEventsSent, totalSends, elapsed.Round(time.Millisecond), parallelRate) |
|||
}) |
|||
|
|||
if singleRate > 0 && parallelRate > 0 { |
|||
t.Logf("Speedup: %.1fx (%d parallel pipelined streams vs 1)", parallelRate/singleRate, numDirs) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue