This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new add78bb6a Fix goroutine link when sync part between liaison and data
nodes (#1217)
add78bb6a is described below
commit add78bb6a61c6baea1a74aec26c0a4caaa3dd593
Author: mrproliu <[email protected]>
AuthorDate: Mon Jul 13 18:44:10 2026 +0800
Fix goroutine link when sync part between liaison and data nodes (#1217)
---
CHANGES.md | 1 +
banyand/queue/pub/chunked_sync.go | 15 ++-
banyand/queue/pub/chunked_sync_leak_test.go | 195 ++++++++++++++++++++++++++++
3 files changed, 210 insertions(+), 1 deletion(-)
diff --git a/CHANGES.md b/CHANGES.md
index 1ae9f7a1b..0de319786 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -132,6 +132,7 @@ Release Notes.
- Fix backup container OOM from overlapping scheduled runs; serialize runs and
upload small snapshot files concurrently.
- Fix block metadata reset before unmarshal for stream and trace.
- Lifecycle only handles stream/measure/trace snapshots, skipping other
catalogs.
+- Fix liaison goroutine leak from uncanceled gRPC SyncPart stream context in
write-queue part sync.
### Document
diff --git a/banyand/queue/pub/chunked_sync.go
b/banyand/queue/pub/chunked_sync.go
index cc77e2a68..c7a7aa387 100644
--- a/banyand/queue/pub/chunked_sync.go
+++ b/banyand/queue/pub/chunked_sync.go
@@ -120,11 +120,24 @@ func (c *chunkedSyncClient) SyncStreamingParts(ctx
context.Context, parts []queu
chunkedClient := clusterv1.NewChunkedSyncServiceClient(c.conn)
- stream, err := chunkedClient.SyncPart(ctx)
+ // Bind the stream to a cancellable context scoped to this call. gRPC
spawns a
+ // per-stream cleanup goroutine (newClientStreamWithParams) that only
exits when
+ // this context is canceled or the ClientConn closes — CloseSend()
alone does
+ // NOT release it. Callers pass a process-lifetime context (the
write-queue
+ // syncer's loopCloser.Ctx()), so without this cancel every sync would
leak one
+ // reaper goroutine forever.
+ streamCtx, cancel := context.WithCancel(ctx)
+
+ stream, err := chunkedClient.SyncPart(streamCtx)
if err != nil {
+ cancel()
return nil, fmt.Errorf("failed to create sync stream: %w", err)
}
defer func() {
+ // Cancel before CloseSend so the reaper goroutine is released
immediately
+ // on every return path (early return, error, or SyncResult
break before
+ // io.EOF), even if CloseSend() blocks during transport
teardown.
+ cancel()
if closeErr := stream.CloseSend(); closeErr != nil {
c.log.Error().Err(closeErr).Msg("failed to close send
stream")
}
diff --git a/banyand/queue/pub/chunked_sync_leak_test.go
b/banyand/queue/pub/chunked_sync_leak_test.go
new file mode 100644
index 000000000..f813f81c3
--- /dev/null
+++ b/banyand/queue/pub/chunked_sync_leak_test.go
@@ -0,0 +1,195 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package pub
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "github.com/apache/skywalking-banyandb/banyand/queue"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+// reaperFrame is the gRPC client-side stream cleanup goroutine that
+// (*clientStream) spawns per streaming RPC in newClientStreamWithParams. It
+// blocks on `select { <-cc.ctx.Done(); <-ctx.Done() }` and only exits when the
+// per-call context is canceled or the ClientConn is closed — NOT when the
+// stream is drained or CloseSend() is called (see grpc stream.go). SyncPart
is a
+// streaming RPC, so each call spawns one; if its context is never canceled the
+// goroutine leaks forever.
+const reaperFrame = "newClientStreamWithParams"
+
+// dumpAllGoroutines returns the full goroutine stack dump, growing the buffer
+// until runtime.Stack reports it fit (it truncates to the buffer size
otherwise,
+// which would undercount goroutines in a busy CI environment).
+func dumpAllGoroutines() string {
+ size := 1 << 20
+ for {
+ buf := make([]byte, size)
+ n := runtime.Stack(buf, true)
+ if n < len(buf) {
+ return string(buf[:n])
+ }
+ size *= 2
+ }
+}
+
+// countReaperGoroutines returns the number of live goroutines currently parked
+// in the gRPC client-stream reaper. It counts whole goroutine blocks (not raw
+// substring hits) so it is robust to grpc's internal closure numbering and to
+// unrelated goroutine noise.
+func countReaperGoroutines() int {
+ count := 0
+ for _, g := range strings.Split(dumpAllGoroutines(), "\n\ngoroutine ") {
+ if strings.Contains(g, reaperFrame) {
+ count++
+ }
+ }
+ return count
+}
+
+// stableReaperCount polls countReaperGoroutines until two consecutive readings
+// agree, so reapers that are mid-teardown (the fixed path cancels them
+// asynchronously) settle before we assert, keeping the test non-flaky.
+func stableReaperCount() int {
+ prev := -1
+ for i := 0; i < 100; i++ {
+ time.Sleep(20 * time.Millisecond)
+ cur := countReaperGoroutines()
+ if cur == prev {
+ return cur
+ }
+ prev = cur
+ }
+ return prev
+}
+
+// firstReaperStack returns one reaper goroutine's stack, surfaced in the
failure
+// message so a regression is immediately actionable.
+func firstReaperStack() string {
+ for _, g := range strings.Split(dumpAllGoroutines(), "\n\ngoroutine ") {
+ if strings.Contains(g, reaperFrame) {
+ return "goroutine " + strings.TrimSpace(g)
+ }
+ }
+ return "<none>"
+}
+
+func newLeakTestClient(t *testing.T) *chunkedSyncClient {
+ t.Helper()
+ address := getAddress()
+ _, cleanup := setupChunkedSyncServer(address, 0) // 0 mismatches ->
success path
+ t.Cleanup(cleanup)
+
+ conn, err := grpc.NewClient(address,
grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatalf("failed to dial mock server: %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ return &chunkedSyncClient{
+ conn: conn,
+ log: logger.GetLogger("test-chunked-sync-leak"),
+ chunkSize: 1024,
+ }
+}
+
+func leakTestParts() []queue.StreamingPartData {
+ return []queue.StreamingPartData{
+ {
+ ID: 1,
+ Group: "test-group",
+ ShardID: 1,
+ Topic: "stream_write",
+ Files: []queue.FileInfo{
+ createFileInfo("leak-test-file.dat",
[]byte("payload for chunked sync leak test")),
+ },
+ },
+ }
+}
+
+// TestSyncStreamingPartsDoesNotLeakReaperGoroutines is a regression guard for
the
+// production goroutine leak where a liaison accumulated 347k goroutines
parked in
+// grpc's newClientStreamWithParams reaper, all from SyncPart streams whose
context
+// was never canceled.
+//
+// SyncStreamingParts opens a SyncPart client stream and only CloseSend()s it;
the
+// reaper exits solely on context cancellation / ClientConn close. In
production
+// the caller passes the write-queue syncer's process-lifetime loopCloser.Ctx()
+// (context.WithCancel(context.Background()), canceled only at shutdown), and
the
+// client breaks out of the Recv loop on the SyncResult frame before reading
the
+// trailing io.EOF — so the stream is never finished and the reaper leaks.
+//
+// This test reproduces exactly that condition: a single never-canceled context
+// shared across many syncs. It passes only because SyncStreamingParts derives
a
+// per-call cancellable context and defer-cancels it (chunked_sync.go).
Reverting
+// that fix makes reapers accumulate ~1 per sync and this test fails.
+func TestSyncStreamingPartsDoesNotLeakReaperGoroutines(t *testing.T) {
+ const iterations = 30
+
+ client := newLeakTestClient(t)
+
+ // Warm up the lazy ClientConn/transport so its long-lived background
+ // goroutines exist before the baseline (they are not reapers, but this
keeps
+ // the environment steady).
+ warmCtx, warmCancel := context.WithTimeout(context.Background(),
10*time.Second)
+ if _, err := client.SyncStreamingParts(warmCtx, leakTestParts()); err
!= nil {
+ warmCancel()
+ t.Fatalf("warm-up sync failed: %v", err)
+ }
+ warmCancel()
+
+ baseline := stableReaperCount()
+
+ // Reproduce the production trigger: the caller's context is NEVER
canceled
+ // while the syncs run (mirrors loopCloser.Ctx()).
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ for i := 0; i < iterations; i++ {
+ result, err := client.SyncStreamingParts(ctx, leakTestParts())
+ if err != nil {
+ t.Fatalf("sync %d failed: %v", i, err)
+ }
+ // Asserting success guarantees the SyncPart stream path
actually ran, so a
+ // clean reaper count reflects proper cleanup rather than a
vacuous no-op.
+ if !result.Success {
+ t.Fatalf("sync %d not successful: %+v", i, result)
+ }
+ }
+
+ after := stableReaperCount()
+ leaked := after - baseline
+ t.Logf("reaper goroutines: baseline=%d after=%d leaked=%d over %d syncs
(never-canceled context)",
+ baseline, after, leaked, iterations)
+
+ // Each leaked sync would park exactly one reaper; allow a tiny slack
for
+ // scheduling. A number near `iterations` means the SyncPart stream
context is
+ // no longer being canceled — the leak has regressed.
+ if leaked > 2 {
+ t.Fatalf("SyncStreamingParts leaked %d gRPC reaper goroutines
over %d syncs with a never-canceled "+
+ "context — SyncPart's stream context must be canceled
on every return path (chunked_sync.go). "+
+ "Sample leaked stack:\n%s", leaked, iterations,
firstReaperStack())
+ }
+}