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 c2d925e4e feat(queue): add message + batch unit catalogs to
batch-write metrics (#1169)
c2d925e4e is described below
commit c2d925e4eae4d77edda94e1fd438243483960150
Author: Gao Hongtao <[email protected]>
AuthorDate: Thu Jun 11 13:15:36 2026 +0800
feat(queue): add message + batch unit catalogs to batch-write metrics
(#1169)
* feat(queue): add message + batch unit catalogs to batch-write metrics
The internal queue's batch-write traffic was counted in incomparable
units on the two ends: the publisher's banyandb_queue_pub_* counted per
message, while the subscriber's banyandb_queue_sub_* counted per
collected batch (one batch-mode wire stream). For the liaison tier-1
write routing this produced a ~950:1 Pub:Sub ratio that looked like loss
but was just a unit mismatch.
---
CHANGES.md | 1 +
banyand/observability/services/metrics_system.go | 65 +++--
banyand/queue/pub/batch.go | 41 +++-
banyand/queue/pub/metrics_test.go | 96 +++++++-
banyand/queue/pub/migration_metrics.go | 26 +-
banyand/queue/pub/migration_metrics_test.go | 64 ++++-
banyand/queue/pub/pub.go | 26 +-
banyand/queue/sub/batch_metrics_test.go | 298 +++++++++++++++++++++++
banyand/queue/sub/helpers.go | 15 +-
banyand/queue/sub/server.go | 30 ++-
banyand/queue/sub/server_metrics_test.go | 15 +-
banyand/queue/sub/sub.go | 8 +-
banyand/trace/metadata_test.go | 2 +-
banyand/trace/snapshot_test.go | 34 ++-
docs/operation/grafana-fodc-nodes.json | 4 +-
docs/operation/observability/metrics.md | 13 +-
pkg/meter/meter.go | 5 +
17 files changed, 652 insertions(+), 91 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index d76e09f5f..d0917c5a1 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,7 @@ Release Notes.
## 0.11.0
### Features
+- Add two catalogs to the queue batch-write metrics so traffic is comparable
on both ends: a per-batch-stream **batch catalog**
(`total_batch_started`/`total_batch_finished`/`total_batch_latency`, buckets to
~300s) on `queue_pub`/`queue_sub` and the `lifecycle_migration` mirror, and a
per-message **message catalog**
(`total_message_started`/`total_message_finished`) on `queue_sub` (the
publisher's existing `total_*` already counts per message). All existing
`total_*` series are unchanged [...]
- Redesign the queue (`queue_pub`/`queue_sub`) metrics around a uniform model:
keep only `total_started`, `total_finished`, `total_latency` (now a histogram)
and `total_err`, plus file-sync-only `sent_bytes` (pub) / `received_bytes`
(sub). Replace the `topic` label with `operation`
(`batch-write`/`file-sync`/`query`/`control`) and `group`, add an `error_type`
label on `total_err`, and add remote-endpoint labels
(`remote_node`/`remote_role`/`remote_tier`) so the liaison↔data (hot/warm/col
[...]
- Stamp the lifecycle's tier-migration publisher's identity onto the wire so
the receiving data node records a non-empty
`remote_node`/`remote_role`/`remote_tier` on its
`banyandb_queue_sub_total_finished` series. The lifecycle's `parseGroup`
resolves the lifecycle's self identity by matching its `--grpc-addr` (the
co-located data node's gRPC address) against the data-node registry —
`Metadata.Name` becomes `remote_node`, `Labels["type"]` becomes `remote_tier` —
and calls `SetSelfNode(se [...]
- Add `banyandb_lifecycle_last_run_timestamp_seconds` and
`banyandb_lifecycle_last_run_success` gauges to the lifecycle service for
at-a-glance health monitoring. `last_run_timestamp_seconds` records the
wall-clock epoch (in seconds) of the most recent migration cycle;
`last_run_success` is `1` on a nil error and `0` otherwise. Both are stamped by
a `defer` at the end of `action()` so every return path (success, error,
recovered panic) updates the pair atomically — dashboards can pin an [...]
diff --git a/banyand/observability/services/metrics_system.go
b/banyand/observability/services/metrics_system.go
index c9b23d9c7..3de2256bf 100644
--- a/banyand/observability/services/metrics_system.go
+++ b/banyand/observability/services/metrics_system.go
@@ -44,6 +44,11 @@ var (
startTime = time.Now()
)
+// gaugesMu protects the process-global gauge vars below. Multiple
metricService
+// instances can exist in the same process (e.g. distributed integration
tests),
+// so initMetrics() (writer) and collect*() (readers) must be synchronized.
+var gaugesMu sync.RWMutex
+
var (
cpuStateGauge meter.Gauge
cpuNumGauge meter.Gauge
@@ -85,6 +90,8 @@ func init() {
func (p *metricService) initMetrics() {
factory := p.With(observability.SystemScope)
+ gaugesMu.Lock()
+ defer gaugesMu.Unlock()
cpuStateGauge = factory.NewGauge("cpu_state", "kind")
cpuNumGauge = factory.NewGauge("cpu_num")
memoryStateGauge = factory.NewGauge("memory_state", "kind")
@@ -101,7 +108,6 @@ func collectCPU() {
cpuCount = c
}
})
- cpuNumGauge.Set(float64(cpuCount))
s, err := cpuTimesFunc(false)
if err != nil {
log.Error().Err(err).Msg("cannot get cpu stat")
@@ -114,6 +120,12 @@ func collectCPU() {
allStat := s[0]
total := allStat.User + allStat.System + allStat.Idle + allStat.Nice +
allStat.Iowait + allStat.Irq +
allStat.Softirq + allStat.Steal + allStat.Guest +
allStat.GuestNice
+ gaugesMu.RLock()
+ defer gaugesMu.RUnlock()
+ if cpuNumGauge == nil || cpuStateGauge == nil {
+ return
+ }
+ cpuNumGauge.Set(float64(cpuCount))
cpuStateGauge.Set(allStat.User/total, "user")
cpuStateGauge.Set(allStat.System/total, "system")
cpuStateGauge.Set(allStat.Idle/total, "idle")
@@ -125,30 +137,32 @@ func collectCPU() {
}
func collectMemory() {
- if memoryStateGauge == nil {
- log.Error().Msg("memoryStateGauge is not registered")
- return
- }
m, err := mem.VirtualMemory()
if err != nil {
log.Error().Err(err).Msg("cannot get memory stat")
return
}
+ gaugesMu.RLock()
+ defer gaugesMu.RUnlock()
+ if memoryStateGauge == nil {
+ return
+ }
memoryStateGauge.Set(m.UsedPercent/100, "used_percent")
memoryStateGauge.Set(float64(m.Used), "used")
memoryStateGauge.Set(float64(m.Total), "total")
}
func collectNet() {
- if netStateGauge == nil {
- log.Error().Msg("netStateGauge is not registered")
- return
- }
stats, err := getNetStat(context.Background())
if err != nil {
log.Error().Err(err).Msg("cannot get net stat")
return
}
+ gaugesMu.RLock()
+ defer gaugesMu.RUnlock()
+ if netStateGauge == nil {
+ return
+ }
for _, stat := range stats {
netStateGauge.Set(float64(stat.BytesRecv), "bytes_recv",
stat.Name)
netStateGauge.Set(float64(stat.BytesSent), "bytes_sent",
stat.Name)
@@ -188,23 +202,42 @@ func getNetStat(ctx context.Context)
([]net.IOCountersStat, error) {
}
func collectUpTime() {
+ gaugesMu.RLock()
+ defer gaugesMu.RUnlock()
+ if upTimeGauge == nil {
+ return
+ }
upTimeGauge.Set(time.Since(startTime).Seconds())
}
func collectDisk() {
- for _, path := range getPath() {
+ paths := getPath()
+ type usageStat struct {
+ usage *disk.UsageStat
+ path string
+ }
+ var stats []usageStat
+ for _, path := range paths {
usage, err := disk.Usage(path)
if err != nil {
- if _, err = os.Stat(path); err != nil {
- if !os.IsNotExist(err) {
- log.Error().Err(err).Msgf("failed to
get stat for path: %s", path)
+ if _, statErr := os.Stat(path); statErr != nil {
+ if !os.IsNotExist(statErr) {
+ log.Error().Err(statErr).Msgf("failed
to get stat for path: %s", path)
}
}
return
}
diskMap.Store(path, int(usage.UsedPercent))
- diskStateGauge.Set(usage.UsedPercent/100, path, "used_percent")
- diskStateGauge.Set(float64(usage.Used), path, "used")
- diskStateGauge.Set(float64(usage.Total), path, "total")
+ stats = append(stats, usageStat{path: path, usage: usage})
+ }
+ gaugesMu.RLock()
+ defer gaugesMu.RUnlock()
+ if diskStateGauge == nil {
+ return
+ }
+ for _, s := range stats {
+ diskStateGauge.Set(s.usage.UsedPercent/100, s.path,
"used_percent")
+ diskStateGauge.Set(float64(s.usage.Used), s.path, "used")
+ diskStateGauge.Set(float64(s.usage.Total), s.path, "total")
}
}
diff --git a/banyand/queue/pub/batch.go b/banyand/queue/pub/batch.go
index 0cdf12686..bcb227d10 100644
--- a/banyand/queue/pub/batch.go
+++ b/banyand/queue/pub/batch.go
@@ -59,6 +59,7 @@ const (
type writeStream struct {
client clusterv1.Service_SendClient
ctxDoneCh <-chan struct{}
+ batchStart time.Time
firstFrameSent bool // false until the first frame is sent; the first
frame carries the sender_* identity labels
}
@@ -192,9 +193,25 @@ func (bp *batchPublisher) Publish(ctx context.Context,
topic bus.Topic, messages
err = multierr.Append(err, fmt.Errorf("failed to get
stream for node %s: %w", node, errCreateStream))
continue
}
+ streamBatchStart := time.Now()
bp.streams[node] = writeStream{
- client: stream,
- ctxDoneCh: streamCtx.Done(),
+ client: stream,
+ ctxDoneCh: streamCtx.Done(),
+ batchStart: streamBatchStart,
+ }
+ var batchOp string
+ var batchInfo nodeInfo
+ if bp.topic != nil {
+ batchOp = apidata.OperationOf(*bp.topic)
+ }
+ if bp.pub != nil {
+ batchInfo = bp.pub.getNodeInfo(node)
+ }
+ if bp.hasMetrics() {
+ bp.pub.metrics.totalBatchStarted.Inc(1, batchOp, "",
node, batchInfo.role, batchInfo.tier)
+ }
+ if bp.hasMigrationMetrics() {
+ bp.pub.migrationMetrics.totalBatchStarted.Inc(1,
batchOp, "", node, batchInfo.role, batchInfo.tier)
}
bp.f.events = append(bp.f.events, make(chan batchEvent))
_ = sendData()
@@ -202,8 +219,9 @@ func (bp *batchPublisher) Publish(ctx context.Context,
topic bus.Topic, messages
recvStream := stream
recvDeferFn := deferFn
recvBC := bp.f.events[len(bp.f.events)-1]
+ recvBatchStart := streamBatchStart
run.Go(ctx, "batch-stream-recv", bp.pub.log, func(runCtx
context.Context) {
- bp.listenBatchResponse(runCtx, recvStream, recvDeferFn,
recvBC, nodeName)
+ bp.listenBatchResponse(runCtx, recvStream, recvDeferFn,
recvBC, nodeName, recvBatchStart)
})
}
return nil, err
@@ -218,11 +236,14 @@ func (bp *batchPublisher) hasMigrationMetrics() bool {
}
// listenBatchResponse receives the server response and records failover
events and end-to-end failure metrics.
-func (bp *batchPublisher) listenBatchResponse(ctx context.Context, s
clusterv1.Service_SendClient, deferFn func(), bc chan batchEvent, curNode
string) {
+func (bp *batchPublisher) listenBatchResponse(ctx context.Context, s
clusterv1.Service_SendClient, deferFn func(),
+ bc chan batchEvent, curNode string, batchStart time.Time,
+) {
defer func() {
close(bc)
deferFn()
}()
+ // ctx.Done() fires before any Recv; do NOT tick batch finished/latency
here.
select {
case <-ctx.Done():
return
@@ -258,13 +279,25 @@ func (bp *batchPublisher) listenBatchResponse(ctx
context.Context, s clusterv1.S
return
}
if resp == nil || resp.Error == "" || resp.Status ==
modelv1.Status_STATUS_SUCCEED {
+ if bp.hasMetrics() {
+ bp.pub.metrics.totalBatchFinished.Inc(1, operation, "",
curNode, info.role, info.tier)
+
bp.pub.metrics.totalBatchLatency.Observe(time.Since(batchStart).Seconds(),
operation, "", curNode, info.role, info.tier)
+ }
+ if bp.hasMigrationMetrics() {
+ bp.pub.migrationMetrics.totalBatchFinished.Inc(1,
operation, "", curNode, info.role, info.tier)
+
bp.pub.migrationMetrics.totalBatchLatency.Observe(time.Since(batchStart).Seconds(),
operation, "", curNode, info.role, info.tier)
+ }
return
}
if bp.hasMetrics() {
bp.pub.metrics.totalErr.Inc(1, operation, "", curNode,
info.role, info.tier, sendErrReasonServerRejected)
+ bp.pub.metrics.totalBatchFinished.Inc(1, operation, "",
curNode, info.role, info.tier)
+
bp.pub.metrics.totalBatchLatency.Observe(time.Since(batchStart).Seconds(),
operation, "", curNode, info.role, info.tier)
}
if bp.hasMigrationMetrics() {
bp.pub.migrationMetrics.totalErr.Inc(1, operation, "", curNode,
info.role, info.tier, sendErrReasonServerRejected)
+ bp.pub.migrationMetrics.totalBatchFinished.Inc(1, operation,
"", curNode, info.role, info.tier)
+
bp.pub.migrationMetrics.totalBatchLatency.Observe(time.Since(batchStart).Seconds(),
operation, "", curNode, info.role, info.tier)
}
ce := common.NewErrorWithStatus(resp.Status, resp.Error)
// Only failover statuses trigger circuit-breaker accounting; other
server-side
diff --git a/banyand/queue/pub/metrics_test.go
b/banyand/queue/pub/metrics_test.go
index c088628cb..f68e89663 100644
--- a/banyand/queue/pub/metrics_test.go
+++ b/banyand/queue/pub/metrics_test.go
@@ -102,11 +102,14 @@ func (*noopHistogram) Delete(_ ...string) bool {
return true }
func newPubMetricsWithErrCapture(totalErr *errReasonCapturerImpl) *pubMetrics
{ //nolint:exhaustruct
return &pubMetrics{
- totalStarted: &countingCounter{},
- totalFinished: &countingCounter{},
- totalLatency: &noopHistogram{},
- totalErr: totalErr,
- sentBytes: &countingCounter{},
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: totalErr,
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: &countingCounter{},
+ totalBatchLatency: &noopHistogram{},
}
}
@@ -264,7 +267,7 @@ func TestListenBatchResponseRecordsRecvError(t *testing.T) {
})
bc := make(chan batchEvent, 1)
- bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a")
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
require.Equal(t, float64(1), sendErrCap.sum(sendErrReasonRecvError))
}
@@ -285,7 +288,7 @@ func
TestListenBatchResponseRecvNonFailoverStillRecordsRecvError(t *testing.T) {
})
bc := make(chan batchEvent, 1)
- bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a")
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
require.Equal(t, float64(1), sendErrCap.sum(sendErrReasonRecvError))
}
@@ -309,7 +312,7 @@ func TestListenBatchResponseServerRejectedWithoutFailover(t
*testing.T) {
})
bc := make(chan batchEvent, 1)
- bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a")
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
require.Equal(t, float64(1),
sendErrCap.sum(sendErrReasonServerRejected))
require.Equal(t, float64(0), sendErrCap.sum(sendErrReasonRecvError))
@@ -345,7 +348,7 @@ func TestListenBatchResponseDiskFullSendsFailoverEvent(t
*testing.T) {
})
bc := make(chan batchEvent, 1)
- bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a")
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
require.Equal(t, float64(1),
sendErrCap.sum(sendErrReasonServerRejected))
@@ -399,3 +402,78 @@ func TestPublishRecordsStartedAndFinished(t *testing.T) {
require.Equal(t, float64(1), started.count, "totalStarted must be 1 on
success")
require.Equal(t, float64(1), finished.count, "totalFinished must be 1
on success")
}
+
+type countingHistogram struct {
+ count int
+}
+
+func (h *countingHistogram) Observe(_ float64, _ ...string) { h.count++ }
+func (*countingHistogram) Delete(_ ...string) bool { return true }
+
+// TestListenBatchResponseCtxDoneTicksNoBatchFinished verifies that the
ctx.Done() early-return
+// path in listenBatchResponse does NOT increment total_batch_finished or
total_batch_latency.
+func TestListenBatchResponseCtxDoneTicksNoBatchFinished(t *testing.T) {
+ batchFinished := &countingCounter{}
+ batchLatency := &countingHistogram{}
+ pm := &pubMetrics{ //nolint:exhaustruct
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: newErrReasonCapturer(),
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: batchFinished,
+ totalBatchLatency: batchLatency,
+ }
+ p := newPubWithConnMgrForMetrics(t, pm)
+ bp := &batchPublisher{
+ pub: p,
+ topic: topicPtr(data.TopicMeasureWrite),
+ }
+
+ // Cancel the context before calling listenBatchResponse — the
ctx.Done() select fires immediately.
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ mockStream := NewMockSendClient(context.Background())
+ bc := make(chan batchEvent, 1)
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
+
+ require.Equal(t, float64(0), batchFinished.count, "total_batch_finished
must NOT be ticked on ctx.Done() early-return")
+ require.Equal(t, 0, batchLatency.count, "total_batch_latency must NOT
be observed on ctx.Done() early-return")
+}
+
+// TestListenBatchResponseSuccessTicksBatchFinished verifies that a successful
single-response path
+// increments total_batch_finished exactly once and observes
total_batch_latency exactly once with group="".
+func TestListenBatchResponseSuccessTicksBatchFinished(t *testing.T) {
+ batchFinished := &countingCounter{}
+ batchLatency := &countingHistogram{}
+ pm := &pubMetrics{ //nolint:exhaustruct
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: newErrReasonCapturer(),
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: batchFinished,
+ totalBatchLatency: batchLatency,
+ }
+ p := newPubWithConnMgrForMetrics(t, pm)
+ bp := &batchPublisher{
+ pub: p,
+ topic: topicPtr(data.TopicMeasureWrite),
+ }
+
+ ctx := context.Background()
+ mockStream := NewMockSendClient(ctx)
+ // Return a successful response (nil error, empty Error field → success
path).
+ mockStream.SetRecvFunc(func() (*clusterv1.SendResponse, error) {
+ return &clusterv1.SendResponse{}, nil
+ })
+
+ bc := make(chan batchEvent, 1)
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
+
+ require.Equal(t, float64(1), batchFinished.count, "total_batch_finished
must be 1 on success")
+ require.Equal(t, 1, batchLatency.count, "total_batch_latency must be
observed once on success")
+}
diff --git a/banyand/queue/pub/migration_metrics.go
b/banyand/queue/pub/migration_metrics.go
index 0be8e4320..3054b0ad3 100644
--- a/banyand/queue/pub/migration_metrics.go
+++ b/banyand/queue/pub/migration_metrics.go
@@ -32,20 +32,26 @@ var lifecycleMigrationScope =
observability.RootScope.SubScope("lifecycle_migrat
// from an explicitly supplied MetricsRegistry instead, on its own scope so the
// migration layer can be queried independently of the write-pipeline pub
family.
type pubMigrationMetrics struct {
- totalStarted meter.Counter
- totalFinished meter.Counter
- totalLatency meter.Histogram
- totalErr meter.Counter
- sentBytes meter.Counter
+ totalStarted meter.Counter
+ totalFinished meter.Counter
+ totalLatency meter.Histogram
+ totalErr meter.Counter
+ sentBytes meter.Counter
+ totalBatchStarted meter.Counter
+ totalBatchFinished meter.Counter
+ totalBatchLatency meter.Histogram
}
func newPubMigrationMetrics(factory observability.Factory)
*pubMigrationMetrics {
labels := []string{"operation", "group", "remote_node", "remote_role",
"remote_tier"}
return &pubMigrationMetrics{
- totalStarted: factory.NewCounter("total_started", labels...),
- totalFinished: factory.NewCounter("total_finished", labels...),
- totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
- totalErr: factory.NewCounter("total_err", append(labels,
"error_type")...),
- sentBytes: factory.NewCounter("sent_bytes", labels...),
+ totalStarted: factory.NewCounter("total_started",
labels...),
+ totalFinished: factory.NewCounter("total_finished",
labels...),
+ totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
+ totalErr: factory.NewCounter("total_err",
append(labels, "error_type")...),
+ sentBytes: factory.NewCounter("sent_bytes", labels...),
+ totalBatchStarted: factory.NewCounter("total_batch_started",
labels...),
+ totalBatchFinished: factory.NewCounter("total_batch_finished",
labels...),
+ totalBatchLatency: factory.NewHistogram("total_batch_latency",
meter.BatchBuckets, labels...),
}
}
diff --git a/banyand/queue/pub/migration_metrics_test.go
b/banyand/queue/pub/migration_metrics_test.go
index 7c600bf81..4b18d02e0 100644
--- a/banyand/queue/pub/migration_metrics_test.go
+++ b/banyand/queue/pub/migration_metrics_test.go
@@ -34,11 +34,14 @@ import (
func newPubMigrationMetricsWithErrCapture(totalErr *errReasonCapturerImpl)
*pubMigrationMetrics { //nolint:exhaustruct
return &pubMigrationMetrics{
- totalStarted: &countingCounter{},
- totalFinished: &countingCounter{},
- totalLatency: &noopHistogram{},
- totalErr: totalErr,
- sentBytes: &countingCounter{},
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: totalErr,
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: &countingCounter{},
+ totalBatchLatency: &noopHistogram{},
}
}
@@ -122,3 +125,54 @@ func TestPublishMirrorsStartedFinishedToMigrationMetrics(t
*testing.T) {
require.Equal(t, float64(1), pubFinished.count)
require.Equal(t, float64(1), migFinished.count, "migration finished
must mirror pub finished")
}
+
+// TestMigrationMirrorBatchFinishedAndLatency verifies that the migration
mirror's
+// totalBatchFinished and totalBatchLatency are ticked from
listenBatchResponse on a
+// terminal (success) response path — the first latency observation in that
function for the mirror.
+func TestMigrationMirrorBatchFinishedAndLatency(t *testing.T) {
+ pubBatchFinished := &countingCounter{}
+ migBatchFinished := &countingCounter{}
+ pubBatchLatency := &countingHistogram{}
+ migBatchLatency := &countingHistogram{}
+
+ pm := &pubMetrics{ //nolint:exhaustruct
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: newErrReasonCapturer(),
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: pubBatchFinished,
+ totalBatchLatency: pubBatchLatency,
+ }
+ p := newPubWithConnMgrForMetrics(t, pm)
+ p.migrationMetrics = &pubMigrationMetrics{ //nolint:exhaustruct
+ totalStarted: &countingCounter{},
+ totalFinished: &countingCounter{},
+ totalLatency: &noopHistogram{},
+ totalErr: newErrReasonCapturer(),
+ sentBytes: &countingCounter{},
+ totalBatchStarted: &countingCounter{},
+ totalBatchFinished: migBatchFinished,
+ totalBatchLatency: migBatchLatency,
+ }
+
+ bp := &batchPublisher{
+ pub: p,
+ topic: topicPtr(data.TopicMeasureWrite),
+ }
+
+ ctx := context.Background()
+ mockStream := NewMockSendClient(ctx)
+ mockStream.SetRecvFunc(func() (*clusterv1.SendResponse, error) {
+ return &clusterv1.SendResponse{}, nil
+ })
+
+ bc := make(chan batchEvent, 1)
+ bp.listenBatchResponse(ctx, mockStream, func() {}, bc, "node-a",
time.Now())
+
+ require.Equal(t, float64(1), pubBatchFinished.count, "pub
total_batch_finished must be 1 on success")
+ require.Equal(t, float64(1), migBatchFinished.count, "migration
total_batch_finished must mirror pub on success")
+ require.Equal(t, 1, pubBatchLatency.count, "pub total_batch_latency
must be observed once on success")
+ require.Equal(t, 1, migBatchLatency.count, "migration
total_batch_latency must mirror pub on success")
+}
diff --git a/banyand/queue/pub/pub.go b/banyand/queue/pub/pub.go
index c0805d254..a51996888 100644
--- a/banyand/queue/pub/pub.go
+++ b/banyand/queue/pub/pub.go
@@ -101,21 +101,27 @@ type nodeInfo struct {
}
type pubMetrics struct {
- totalStarted meter.Counter
- totalFinished meter.Counter
- totalLatency meter.Histogram
- totalErr meter.Counter
- sentBytes meter.Counter
+ totalStarted meter.Counter
+ totalFinished meter.Counter
+ totalLatency meter.Histogram
+ totalErr meter.Counter
+ sentBytes meter.Counter
+ totalBatchStarted meter.Counter
+ totalBatchFinished meter.Counter
+ totalBatchLatency meter.Histogram
}
func newPubMetrics(factory observability.Factory) *pubMetrics {
labels := []string{"operation", "group", "remote_node", "remote_role",
"remote_tier"}
return &pubMetrics{
- totalStarted: factory.NewCounter("total_started", labels...),
- totalFinished: factory.NewCounter("total_finished", labels...),
- totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
- totalErr: factory.NewCounter("total_err", append(labels,
"error_type")...),
- sentBytes: factory.NewCounter("sent_bytes", labels...),
+ totalStarted: factory.NewCounter("total_started",
labels...),
+ totalFinished: factory.NewCounter("total_finished",
labels...),
+ totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
+ totalErr: factory.NewCounter("total_err",
append(labels, "error_type")...),
+ sentBytes: factory.NewCounter("sent_bytes", labels...),
+ totalBatchStarted: factory.NewCounter("total_batch_started",
labels...),
+ totalBatchFinished: factory.NewCounter("total_batch_finished",
labels...),
+ totalBatchLatency: factory.NewHistogram("total_batch_latency",
meter.BatchBuckets, labels...),
}
}
diff --git a/banyand/queue/sub/batch_metrics_test.go
b/banyand/queue/sub/batch_metrics_test.go
new file mode 100644
index 000000000..53e8e0ef8
--- /dev/null
+++ b/banyand/queue/sub/batch_metrics_test.go
@@ -0,0 +1,298 @@
+// 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 sub
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/apache/skywalking-banyandb/api/data"
+ clusterv1
"github.com/apache/skywalking-banyandb/api/proto/banyandb/cluster/v1"
+ "github.com/apache/skywalking-banyandb/pkg/bus"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+// mockSendServer implements clusterv1.Service_SendServer for sub batch tests.
+type mockSendServer struct {
+ ctx context.Context
+ sendErr error
+ sent []*clusterv1.SendResponse
+ mu sync.Mutex
+}
+
+func newMockSendServer() *mockSendServer {
+ return &mockSendServer{
+ ctx: metadata.NewIncomingContext(context.Background(),
metadata.MD{}),
+ }
+}
+
+func (m *mockSendServer) Send(resp *clusterv1.SendResponse) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.sent = append(m.sent, resp)
+ return m.sendErr
+}
+
+func (m *mockSendServer) Recv() (*clusterv1.SendRequest, error) { return nil,
nil }
+func (m *mockSendServer) Context() context.Context { return m.ctx
}
+func (m *mockSendServer) SetHeader(metadata.MD) error { return nil }
+func (m *mockSendServer) SendHeader(metadata.MD) error { return nil }
+func (m *mockSendServer) SetTrailer(metadata.MD) {}
+func (m *mockSendServer) SendMsg(_ any) error { return nil }
+func (m *mockSendServer) RecvMsg(_ any) error { return nil }
+
+// echoListener is a healthy MessageListener that echoes messages back.
+type echoListener struct {
+ bus.UnImplementedHealthyListener
+}
+
+func (*echoListener) Rev(_ context.Context, msg bus.Message) bus.Message {
return msg }
+
+func newBatchTestServer(m *metrics) *server { //nolint:exhaustruct
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ if logInitErr != nil {
+ panic(logInitErr)
+ }
+ return &server{
+ log: logger.GetLogger("batch-metrics-test"),
+ metrics: m,
+ listeners: make(map[bus.Topic][]bus.MessageListener),
+ listenersLock: sync.RWMutex{},
+ topicMap: make(map[string]bus.Topic),
+ }
+}
+
+func newBatchMetrics() (m *metrics, msgStarted, msgFinished, batchStarted,
batchFinished *fakeCounter, batchLatency *fakeHistogram) {
+ msgStarted = &fakeCounter{}
+ msgFinished = &fakeCounter{}
+ batchStarted = &fakeCounter{}
+ batchFinished = &fakeCounter{}
+ batchLatency = &fakeHistogram{}
+ m = &metrics{ //nolint:exhaustruct
+ totalStarted: &fakeCounter{},
+ totalFinished: &fakeCounter{},
+ totalLatency: &fakeHistogram{},
+ totalErr: &fakeCounter{},
+ receivedBytes: &fakeCounter{},
+ totalMessageStarted: msgStarted,
+ totalMessageFinished: msgFinished,
+ totalBatchStarted: batchStarted,
+ totalBatchFinished: batchFinished,
+ totalBatchLatency: batchLatency,
+ }
+ return m, msgStarted, msgFinished, batchStarted, batchFinished,
batchLatency
+}
+
+func makeBatchReq(group, senderNode string) *clusterv1.SendRequest {
//nolint:exhaustruct
+ return &clusterv1.SendRequest{
+ Topic: data.TopicMeasureWrite.String(),
+ Group: group,
+ SenderNode: senderNode,
+ SenderRole: "liaison",
+ SenderTier: "hot",
+ BatchMod: true,
+ Body: []byte("data"),
+ }
+}
+
+// TestBatchModNMessageMetrics asserts that for an N-message BatchMod batch:
+// total_message_started==N, total_message_finished==N,
total_batch_started==1, total_batch_finished==1,
+// and total_batch_latency is observed exactly once.
+func TestBatchModNMessageMetrics(t *testing.T) {
+ const N = 3
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ require.NoError(t, logInitErr)
+
+ m, msgStarted, msgFinished, batchStarted, batchFinished, batchLatency
:= newBatchMetrics()
+ s := newBatchTestServer(m)
+
+ topic := data.TopicMeasureWrite
+ require.NoError(t, s.Subscribe(topic, &echoListener{}))
+
+ stream := newMockSendServer()
+ req := makeBatchReq("g1", "liaison-0:17912")
+
+ identity := &streamIdentity{}
+ var dataCollection []any
+ start := time.Now()
+
+ // Mirror the production Send path: pin identity before the first
handleBatch call.
+ s.pinIdentity(identity, req, topic)
+
+ // Simulate N batch messages (handleBatch called N times with the same
identity).
+ for range N {
+ s.handleBatch(&dataCollection, req, &start, identity)
+ }
+
+ // Simulate EOF: handleEOF dispatches the collected batch.
+ s.handleEOF(stream, &topic, dataCollection, req, identity, start)
+
+ require.Equal(t, float64(N), msgStarted.total, "total_message_started
must equal N")
+ require.Equal(t, float64(N), msgFinished.total, "total_message_finished
must equal N")
+ require.Equal(t, float64(1), batchStarted.total, "total_batch_started
must equal 1")
+ require.Equal(t, float64(1), batchFinished.total, "total_batch_finished
must equal 1")
+ require.Equal(t, 1, batchLatency.count, "total_batch_latency must be
observed exactly once")
+}
+
+// TestBatchModSendFailureMetrics verifies that a Send failure on the response
does NOT affect
+// the four metric counts — they are pinned after listener.Rev, independent of
Send outcome.
+func TestBatchModSendFailureMetrics(t *testing.T) {
+ const N = 4
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ require.NoError(t, logInitErr)
+
+ m, msgStarted, msgFinished, batchStarted, batchFinished, batchLatency
:= newBatchMetrics()
+ s := newBatchTestServer(m)
+
+ topic := data.TopicMeasureWrite
+ require.NoError(t, s.Subscribe(topic, &echoListener{}))
+
+ // Wire a Send that always fails (simulates client disconnect before
reading EOF response).
+ stream := newMockSendServer()
+ stream.sendErr = errors.New("client disconnect")
+
+ req := makeBatchReq("g2", "liaison-1:17912")
+ identity := &streamIdentity{}
+ var dataCollection []any
+ start := time.Now()
+
+ // Mirror the production Send path: pin identity before the first
handleBatch call.
+ s.pinIdentity(identity, req, topic)
+
+ for range N {
+ s.handleBatch(&dataCollection, req, &start, identity)
+ }
+ s.handleEOF(stream, &topic, dataCollection, req, identity, start)
+
+ require.Equal(t, float64(N), msgStarted.total, "total_message_started
must equal N even when Send fails")
+ require.Equal(t, float64(N), msgFinished.total, "total_message_finished
must equal N even when Send fails")
+ require.Equal(t, float64(1), batchStarted.total, "total_batch_started
must equal 1 even when Send fails")
+ require.Equal(t, float64(1), batchFinished.total, "total_batch_finished
must equal 1 even when Send fails")
+ require.Equal(t, 1, batchLatency.count, "total_batch_latency must be
observed once even when Send fails")
+}
+
+// TestHandleEOFEmptyCollectionTicksNothing verifies that handleEOF with an
empty dataCollection
+// returns early without ticking any of the new instruments.
+func TestHandleEOFEmptyCollectionTicksNothing(t *testing.T) {
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ require.NoError(t, logInitErr)
+
+ m, msgStarted, msgFinished, batchStarted, batchFinished, batchLatency
:= newBatchMetrics()
+ s := newBatchTestServer(m)
+
+ topic := data.TopicMeasureWrite
+ require.NoError(t, s.Subscribe(topic, &echoListener{}))
+
+ stream := newMockSendServer()
+ identity := &streamIdentity{
+ senderNode: "liaison-0:17912",
+ senderRole: "liaison",
+ senderTier: "hot",
+ group: "g1",
+ operation: "batch-write",
+ pinned: true,
+ }
+
+ // Call handleEOF with an empty collection — must return immediately
without ticking.
+ s.handleEOF(stream, &topic, []any{}, nil, identity, time.Now())
+
+ require.Equal(t, float64(0), msgStarted.total, "no message_started on
empty collection")
+ require.Equal(t, float64(0), msgFinished.total, "no message_finished on
empty collection")
+ require.Equal(t, float64(0), batchStarted.total, "no batch_started on
empty collection")
+ require.Equal(t, float64(0), batchFinished.total, "no batch_finished on
empty collection")
+ require.Equal(t, 0, batchLatency.count, "no batch_latency on empty
collection")
+}
+
+// TestHandleEOFNoListenerTicksNothing verifies that handleEOF with no
registered listener
+// returns (via replyWithErrType) without ticking any of the new instruments.
+func TestHandleEOFNoListenerTicksNothing(t *testing.T) {
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ require.NoError(t, logInitErr)
+
+ m, msgStarted, msgFinished, batchStarted, batchFinished, batchLatency
:= newBatchMetrics()
+ s := newBatchTestServer(m)
+
+ // No listener registered for this topic.
+ topic := data.TopicMeasureWrite
+ stream := newMockSendServer()
+ req := makeBatchReq("g1", "liaison-0:17912")
+ identity := &streamIdentity{
+ senderNode: "liaison-0:17912",
+ senderRole: "liaison",
+ senderTier: "hot",
+ group: "g1",
+ operation: "batch-write",
+ pinned: true,
+ }
+
+ // Non-empty dataCollection but no listener — must not tick
finished/batch.
+ s.handleEOF(stream, &topic, []any{req.Body}, req, identity, time.Now())
+
+ require.Equal(t, float64(0), msgStarted.total, "no message_started when
no listener")
+ require.Equal(t, float64(0), msgFinished.total, "no message_finished
when no listener")
+ require.Equal(t, float64(0), batchStarted.total, "no batch_started when
no listener")
+ require.Equal(t, float64(0), batchFinished.total, "no batch_finished
when no listener")
+ require.Equal(t, 0, batchLatency.count, "no batch_latency when no
listener")
+}
+
+// TestDispatchMessageNonBatchMetrics verifies that a single dispatchMessage
call increments
+// total_message_started==1 and total_message_finished==1.
+func TestDispatchMessageNonBatchMetrics(t *testing.T) {
+ logInitErr := logger.Init(logger.Logging{Env: "dev", Level: "info"})
+ require.NoError(t, logInitErr)
+
+ m, msgStarted, msgFinished, batchStarted, batchFinished, batchLatency
:= newBatchMetrics()
+ s := newBatchTestServer(m)
+
+ topic := data.TopicMeasureWrite
+ require.NoError(t, s.Subscribe(topic, &echoListener{}))
+
+ stream := newMockSendServer()
+ req := &clusterv1.SendRequest{ //nolint:exhaustruct
+ Topic: data.TopicMeasureWrite.String(),
+ Group: "g1",
+ SenderNode: "liaison-0:17912",
+ SenderRole: "liaison",
+ SenderTier: "hot",
+ BatchMod: false,
+ Body: []byte("payload"),
+ }
+ identity := &streamIdentity{
+ senderNode: "liaison-0:17912",
+ senderRole: "liaison",
+ senderTier: "hot",
+ group: "g1",
+ operation: "batch-write",
+ pinned: true,
+ }
+ msg := bus.NewMessage(bus.MessageID(1), req.Body)
+ start := time.Now()
+ s.dispatchMessage(stream, req, topic, msg, identity, &start)
+
+ require.Equal(t, float64(1), msgStarted.total, "total_message_started
must be 1 on dispatchMessage")
+ require.Equal(t, float64(1), msgFinished.total, "total_message_finished
must be 1 on dispatchMessage")
+ require.Zero(t, batchStarted.total, "the non-batch path must not tick
the batch catalog")
+ require.Zero(t, batchFinished.total, "the non-batch path must not tick
the batch catalog")
+ require.Zero(t, batchLatency.count, "the non-batch path must not
observe batch latency")
+}
diff --git a/banyand/queue/sub/helpers.go b/banyand/queue/sub/helpers.go
index b8b77e93d..bd16f7f86 100644
--- a/banyand/queue/sub/helpers.go
+++ b/banyand/queue/sub/helpers.go
@@ -29,7 +29,9 @@ import (
"github.com/apache/skywalking-banyandb/pkg/logger"
)
-func (s *server) handleEOF(stream clusterv1.Service_SendServer, topic
*bus.Topic, dataCollection []any, writeEntity *clusterv1.SendRequest, identity
*streamIdentity) {
+func (s *server) handleEOF(stream clusterv1.Service_SendServer, topic
*bus.Topic, dataCollection []any,
+ writeEntity *clusterv1.SendRequest, identity *streamIdentity, start
time.Time,
+) {
if len(dataCollection) < 1 {
return
}
@@ -47,6 +49,13 @@ func (s *server) handleEOF(stream
clusterv1.Service_SendServer, topic *bus.Topic
return
}
message := listener.Rev(stream.Context(),
bus.NewMessage(bus.MessageID(0), dataCollection))
+ // Tick batch + message finished immediately after Rev returns,
independent of the response Send outcome.
+ // Pre-Rev early returns (len<1, no-listener, CheckHealth fail) are
excluded — they return before this point.
+ if s.metrics != nil {
+
s.metrics.totalMessageFinished.Inc(float64(len(dataCollection)),
identity.operation, identity.group, identity.senderNode, identity.senderRole,
identity.senderTier)
+ s.metrics.totalBatchFinished.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
+
s.metrics.totalBatchLatency.Observe(time.Since(start).Seconds(),
identity.operation, identity.group, identity.senderNode, identity.senderRole,
identity.senderTier)
+ }
// writeEntity is nil when the stream closed (Recv returned io.EOF), so
guard the ID access.
var msgID uint64
if writeEntity != nil {
@@ -77,8 +86,12 @@ func (s *server) handleBatch(dataCollection *[]any,
writeEntity *clusterv1.SendR
if len(*dataCollection) == 0 {
if s.metrics != nil {
s.metrics.totalStarted.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
+ s.metrics.totalBatchStarted.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
}
*start = time.Now()
}
+ if s.metrics != nil {
+ s.metrics.totalMessageStarted.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
+ }
*dataCollection = append(*dataCollection, writeEntity.Body)
}
diff --git a/banyand/queue/sub/server.go b/banyand/queue/sub/server.go
index 72ca46d71..f19236733 100644
--- a/banyand/queue/sub/server.go
+++ b/banyand/queue/sub/server.go
@@ -444,20 +444,30 @@ func (s *server) SetNodeSchemaStatusRepo(svc
metadata.Service) {
}
type metrics struct {
- totalStarted meter.Counter
- totalFinished meter.Counter
- totalLatency meter.Histogram
- totalErr meter.Counter
- receivedBytes meter.Counter
+ totalStarted meter.Counter
+ totalFinished meter.Counter
+ totalLatency meter.Histogram
+ totalErr meter.Counter
+ receivedBytes meter.Counter
+ totalBatchStarted meter.Counter
+ totalBatchFinished meter.Counter
+ totalBatchLatency meter.Histogram
+ totalMessageStarted meter.Counter
+ totalMessageFinished meter.Counter
}
func newMetrics(factory observability.Factory) *metrics {
labels := []string{"operation", "group", "remote_node", "remote_role",
"remote_tier"}
return &metrics{
- totalStarted: factory.NewCounter("total_started", labels...),
- totalFinished: factory.NewCounter("total_finished", labels...),
- totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
- totalErr: factory.NewCounter("total_err", append(labels,
"error_type")...),
- receivedBytes: factory.NewCounter("received_bytes", labels...),
+ totalStarted: factory.NewCounter("total_started",
labels...),
+ totalFinished: factory.NewCounter("total_finished",
labels...),
+ totalLatency: factory.NewHistogram("total_latency",
meter.DefBuckets, labels...),
+ totalErr: factory.NewCounter("total_err",
append(labels, "error_type")...),
+ receivedBytes: factory.NewCounter("received_bytes",
labels...),
+ totalBatchStarted: factory.NewCounter("total_batch_started",
labels...),
+ totalBatchFinished:
factory.NewCounter("total_batch_finished", labels...),
+ totalBatchLatency:
factory.NewHistogram("total_batch_latency", meter.BatchBuckets, labels...),
+ totalMessageStarted:
factory.NewCounter("total_message_started", labels...),
+ totalMessageFinished:
factory.NewCounter("total_message_finished", labels...),
}
}
diff --git a/banyand/queue/sub/server_metrics_test.go
b/banyand/queue/sub/server_metrics_test.go
index 91638af00..c47130a55 100644
--- a/banyand/queue/sub/server_metrics_test.go
+++ b/banyand/queue/sub/server_metrics_test.go
@@ -43,11 +43,16 @@ func newTestMetrics() *metrics { //nolint:exhaustruct
c := &noopCounter{}
h := &noopHistogram{}
return &metrics{
- totalStarted: c,
- totalFinished: c,
- totalLatency: h,
- totalErr: c,
- receivedBytes: c,
+ totalStarted: c,
+ totalFinished: c,
+ totalLatency: h,
+ totalErr: c,
+ receivedBytes: c,
+ totalMessageStarted: c,
+ totalMessageFinished: c,
+ totalBatchStarted: c,
+ totalBatchFinished: c,
+ totalBatchLatency: h,
}
}
diff --git a/banyand/queue/sub/sub.go b/banyand/queue/sub/sub.go
index 37307e327..f4e53e018 100644
--- a/banyand/queue/sub/sub.go
+++ b/banyand/queue/sub/sub.go
@@ -125,7 +125,7 @@ func (s *server) Send(stream clusterv1.Service_SendServer)
error {
}
writeEntity, err := stream.Recv()
if errors.Is(err, io.EOF) {
- s.handleEOF(stream, topic, dataCollection, writeEntity,
identity)
+ s.handleEOF(stream, topic, dataCollection, writeEntity,
identity, start)
return nil
}
if err != nil {
@@ -258,6 +258,7 @@ func (s *server) dispatchMessage(
) {
if s.metrics != nil {
s.metrics.totalStarted.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
+ s.metrics.totalMessageStarted.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
}
listeners := s.getListeners(topic)
if len(listeners) == 0 {
@@ -268,6 +269,11 @@ func (s *server) dispatchMessage(
logger.Panicf("multiple listeners found for topic %s", topic)
}
m = listeners[0].Rev(stream.Context(), m)
+ // The BatchMod fork in Send routes each SendRequest to exactly one of
+ // handleBatch→handleEOF (batch) or dispatchMessage (non-batch), so no
message is double-counted.
+ if s.metrics != nil {
+ s.metrics.totalMessageFinished.Inc(1, identity.operation,
identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
+ }
if m.Data() == nil {
if errSend := stream.Send(&clusterv1.SendResponse{MessageId:
writeEntity.MessageId}); errSend != nil {
s.log.Error().Stringer("request",
writeEntity).Err(errSend).Msg("failed to send empty response")
diff --git a/banyand/trace/metadata_test.go b/banyand/trace/metadata_test.go
index a6e76bc91..80faf468f 100644
--- a/banyand/trace/metadata_test.go
+++ b/banyand/trace/metadata_test.go
@@ -392,7 +392,7 @@ var _ = Describe("Metadata", func() {
// part count is stable (no further writes), so
slow polling never misses it.
Eventually(func() int64 {
return getTotalPartCount(svcs,
groupName)
- }, 3*flags.EventuallyTimeout,
500*time.Millisecond).Should(BeNumerically("<", partCountBeforeMerge))
+ }, 10*flags.EventuallyTimeout,
500*time.Millisecond).Should(BeNumerically("<", partCountBeforeMerge))
Eventually(func(innerGm Gomega) {
spans :=
querySchemaChangeTraceData(svcs, traceName, groupName,
diff --git a/banyand/trace/snapshot_test.go b/banyand/trace/snapshot_test.go
index f8909c9dd..f2787731b 100644
--- a/banyand/trace/snapshot_test.go
+++ b/banyand/trace/snapshot_test.go
@@ -528,6 +528,24 @@ func TestSnapshotRemove(t *testing.T) {
}
}
+// waitForPersistedSnapshot blocks until a flush has been introduced and its
+// ".snp" manifest persisted. Waiting for the part directory alone is racy: the
+// directory is created by the first line of memPart.mustFlush, before the
+// mem->file introduction reaches the in-memory snapshot and before the
manifest
+// is written, so the directory can exist while the snapshot still holds only
+// mem parts and no ".snp" has been persisted.
+func waitForPersistedSnapshot(t *testing.T, fileSystem fs.FileSystem, tabDir
string) {
+ t.Helper()
+ require.Eventually(t, func() bool {
+ for _, e := range fileSystem.ReadDir(tabDir) {
+ if !e.IsDir() && filepath.Ext(e.Name()) ==
snapshotSuffix {
+ return true
+ }
+ }
+ return false
+ }, flags.EventuallyTimeout, time.Millisecond, "wait for snapshot
manifest to be persisted")
+}
+
func TestSnapshotFunctionality(t *testing.T) {
fileSystem := fs.NewLocalFileSystem()
@@ -557,18 +575,10 @@ func TestSnapshotFunctionality(t *testing.T) {
tst.mustAddTraces(tsTS1, nil)
tst.mustAddTraces(tsTS2, nil)
- time.Sleep(100 * time.Millisecond) // allow time for flushing
-
- require.Eventually(t, func() bool {
- dd := fileSystem.ReadDir(tabDir)
- partNum := 0
- for _, d := range dd {
- if d.IsDir() {
- partNum++
- }
- }
- return partNum >= 1
- }, flags.EventuallyTimeout, time.Millisecond, "wait for file parts to
be created")
+ // Wait until the flush has been introduced and its ".snp" manifest
persisted,
+ // not merely until a part directory appears (which happens at the
start of
+ // mustFlush, before the mem->file introduction reaches the snapshot).
+ waitForPersistedSnapshot(t, fileSystem, tabDir)
snapshotPath := filepath.Join(tmpPath, "snapshot")
fileSystem.MkdirIfNotExist(snapshotPath, 0o755)
diff --git a/docs/operation/grafana-fodc-nodes.json
b/docs/operation/grafana-fodc-nodes.json
index a7145ffa3..7b4d0552f 100644
--- a/docs/operation/grafana-fodc-nodes.json
+++ b/docs/operation/grafana-fodc-nodes.json
@@ -659,7 +659,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
- "description": "Each row is one directed flow source→target (bare pod
names) per operation, with the PUBLISHER's view and the SUBSCRIBER's view of
the SAME traffic side by side — never sum the two. Request edges pair
banyandb_queue_pub_* (sender) with banyandb_queue_sub_* (receiver);
tier-migration edges pair banyandb_lifecycle_migration_* (lifecycle sidecar,
sharing its data pod's pod_name) with the receiver's queue_sub
remote_role=\"lifecycle\" series. The two sides are joined on [...]
+ "description": "Each row is one directed flow source→target (bare pod
names) per operation, with the PUBLISHER's view and the SUBSCRIBER's view of
the SAME traffic side by side — never sum the two. Request edges pair
banyandb_queue_pub_* (sender) with banyandb_queue_sub_* (receiver);
tier-migration edges pair banyandb_lifecycle_migration_* (lifecycle sidecar,
sharing its data pod's pod_name) with the receiver's queue_sub
remote_role=\"lifecycle\" series. The two sides are joined on [...]
"fieldConfig": {
"defaults": {
"color": {
@@ -909,7 +909,7 @@
},
"editorMode": "code",
"exemplar": false,
- "expr": "sum by (source, target, operation)
(label_replace(label_replace(rate(banyandb_queue_sub_total_started{job=~\"$job\",
container_name=~\"$role\", pod_name=~\"$pod\"}[$__rate_interval]) or
rate(banyandb_queue_sub_total_started{job=~\"$job\", remote_role=~\"$role\",
remote_node=~\"($pod)\\\\..*\"}[$__rate_interval]), \"source\", \"$1\",
\"remote_node\", \"([^.:]+).*\"), \"target\", \"$1\", \"pod_name\", \"(.*)\"))",
+ "expr": "sum by (source, target, operation)
(label_replace(label_replace(rate(banyandb_queue_sub_total_message_finished{job=~\"$job\",
container_name=~\"$role\", pod_name=~\"$pod\"}[$__rate_interval]) or
rate(banyandb_queue_sub_total_message_finished{job=~\"$job\",
remote_role=~\"$role\", remote_node=~\"($pod)\\\\..*\"}[$__rate_interval]),
\"source\", \"$1\", \"remote_node\", \"([^.:]+).*\"), \"target\", \"$1\",
\"pod_name\", \"(.*)\"))",
"format": "table",
"instant": true,
"legendFormat": "__auto",
diff --git a/docs/operation/observability/metrics.md
b/docs/operation/observability/metrics.md
index f05703931..b0048bf6d 100644
--- a/docs/operation/observability/metrics.md
+++ b/docs/operation/observability/metrics.md
@@ -92,12 +92,12 @@ sum by (source, target, operation)
(label_replace(label_replace(
"source", "$1", "pod_name", "(.*)"), "target", "$1", "remote_node",
"([^.:]+).*"))
```
-**Sub side (receiver view; inverted mapping — `total_started` is the
receiver's per-dispatch counter, the closest match to the publisher's
per-message count):**
+**Sub side (receiver view; inverted mapping — `total_message_finished` is the
receiver's per-message catalog, the unit-for-unit match to the publisher's
per-message `total_finished`):**
```promql
sum by (source, target, operation) (label_replace(label_replace(
- rate(banyandb_queue_sub_total_started{job=~"$job", container_name=~"$role",
pod_name=~"$pod"}[$__rate_interval])
- or rate(banyandb_queue_sub_total_started{job=~"$job",
remote_role=~"$role", remote_node=~"($pod)\\..*"}[$__rate_interval]),
+ rate(banyandb_queue_sub_total_message_finished{job=~"$job",
container_name=~"$role", pod_name=~"$pod"}[$__rate_interval])
+ or rate(banyandb_queue_sub_total_message_finished{job=~"$job",
remote_role=~"$role", remote_node=~"($pod)\\..*"}[$__rate_interval]),
"source", "$1", "remote_node", "([^.:]+).*"), "target", "$1", "pod_name",
"(.*)"))
```
@@ -105,7 +105,7 @@ The p99 columns apply the same relabeling inside
`histogram_quantile(0.99, sum b
Reading the table:
-- The two sides describe the **same traffic — never add them**. A healthy
steady-state edge shows roughly equal `Pub msg/s` and `Sub msg/s` — except for
**batch-mode** streams: the liaison→liaison **tier-1 write routing**, where
each write the SkyWalking OAP sends to its connected liaison is re-published
**round-robin to the owner liaison** (including itself, over real gRPC), one
wire stream per OAP write stream, and the receiver collects the whole stream
and dispatches it **once at stre [...]
+- The two sides describe the **same traffic — never add them**. Both `Pub
msg/s` (`queue_pub_total_finished`) and `Sub msg/s`
(`queue_sub_total_message_finished`) now count the **same per-message unit**,
so a healthy steady-state edge shows them ≈ equal on every edge — including the
liaison→liaison **tier-1 write routing**, where each write the SkyWalking OAP
sends to its connected liaison is re-published **round-robin to the owner
liaison** (including itself), one wire stream per OAP wr [...]
- A populated Pub cell with an **empty Sub cell** (or vice versa) is also
signal: an uninstrumented side (older servers record `query`/`control` only on
the receiver) or a one-sided counter (`sent_bytes` exists only on
pub/migration, `received_bytes` only on sub; both are file-sync-only).
- Empty `err` cells mean **no errors** (the counters are lazily registered).
- `$pod` / `$role` select flows **involving** a matching node on **either
end**. A naive `pod_name=~"$pod"` filter would hit the two sides asymmetrically
(pub rows are scraped from the sender, sub rows from the receiver) and split
the merged rows; instead every query unions two selectors — one matching the
scrape-target side (`pod_name` / `container_name`) and one matching the remote
side (`remote_node=~"($pod)\\..*"`, a prefix match on the full DNS node name,
and `remote_role=~"$role"`, [...]
@@ -386,6 +386,8 @@ Both namespaces share one model: the base metrics
`total_started`, `total_finish
| `total_latency` | Histogram | `operation`, `group`, `remote_node`,
`remote_role`, `remote_tier` | Handling latency (`_bucket` / `_sum` /
`_count`); use `histogram_quantile` for p50/p99. |
| `total_err` | Counter | …, `error_type` | Errors by type. Lazily registered,
so absent (not zero) on a healthy cluster. |
| `received_bytes` | Counter | `operation`, `group`, `remote_node`,
`remote_role`, `remote_tier` | Bytes received, **file-sync only**
(`operation="file-sync"`). |
+| `total_message_started`, `total_message_finished` | Counter | `operation`,
`group`, `remote_node`, `remote_role`, `remote_tier` | **Message catalog**
(`queue_sub` only) — one count per dispatched message on every path: per
message for ordinary traffic, per part for file-sync, and per message *within*
a batch-mode stream (where `total_started`/`total_finished` count the whole
batch once). This is the unit-for-unit match to the publisher's per-message
`total_finished`, so the two sides c [...]
+| `total_batch_started`, `total_batch_finished`, `total_batch_latency` |
Counter / Histogram | `operation`, `group`, `remote_node`, `remote_role`,
`remote_tier` | **Batch catalog** — one count per batch-mode wire stream:
started at stream open, finished + latency observed at EOF dispatch.
`total_batch_latency` buckets extend to ~300s for minutes-long migration/replay
streams. Also present on `queue_pub` and the `lifecycle_migration` mirror. |
**Troubleshooting:** a growing `total_started − total_finished` gap (or rising
`total_latency` p99) for a `group`/`operation` points at slow or stuck
consumers. `total_err` broken down by `error_type` distinguishes transport
issues (`stream_error`, `recv_error`, `checksum_mismatch`, `out_of_order`) from
completion issues (`finish_sync_err`, `part_failed`). For file-sync,
`received_bytes` together with `total_latency` separates partial completion
from healthy throughput. On data nodes, se [...]
@@ -397,6 +399,7 @@ Both namespaces share one model: the base metrics
`total_started`, `total_finish
| `total_latency` | Histogram | `operation`, `group`, `remote_node`,
`remote_role`, `remote_tier` | Send latency (`_bucket` / `_sum` / `_count`);
use `histogram_quantile` for p99. |
| `total_err` | Counter | …, `error_type` | Send errors by type. `error_type`
is one of `non_transient`, `canceled`, `stream_canceled`, `retry_exhausted`,
`recv_error`, `server_rejected`, `send_error`, `decode_error`, `invalid_topic`
(Send/publish path) or `stream_error`, `recv_error`, `checksum_mismatch`,
`out_of_order`, `session_not_found`, `completion_error` (file-sync). Lazily
registered. |
| `sent_bytes` | Counter | `operation`, `group`, `remote_node`, `remote_role`,
`remote_tier` | Bytes sent, **file-sync only**. |
+| `total_batch_started`, `total_batch_finished`, `total_batch_latency` |
Counter / Histogram | `operation`, `group`, `remote_node`, `remote_role`,
`remote_tier` | **Batch catalog** — one count per batch-mode wire stream:
started at stream open (`group=""`), finished + latency observed when the
stream's final response is received. Note `total_batch_finished` means the
final response arrived, **not** that every message was acked (per-message acks
await a future `cluster.v1.SendResponse.acc [...]
**Troubleshooting:** `total_err` by `error_type` separates transport failures
(`recv_error`) from application-level `SendResponse` errors (`server_rejected`)
and exhausted retries (`retry_exhausted`). A persistent `total_started −
total_finished` gap, or rising `total_latency` p99 for a given `remote_node` /
`remote_tier`, indicates slow or unavailable data nodes.
@@ -404,7 +407,7 @@ Metrics are only registered when `metadata` implements
`metadata.Service` and `M
### `lifecycle_migration` — the tier-migration mirror of `queue_pub`
-The **lifecycle** service migrates data hot→warm→cold through a queue client
built without a metadata service (`pub.NewWithoutMetadata`), so its traffic
does **not** appear under `banyandb_queue_pub_*`. Instead the same five
instruments — `total_started`, `total_finished`, `total_latency`, `total_err`,
`sent_bytes` — are registered under **`banyandb_lifecycle_migration_*`**, with
the same labels (`operation`, `group`, `remote_node`, `remote_role`,
`remote_tier`, plus `error_type` on `tot [...]
+The **lifecycle** service migrates data hot→warm→cold through a queue client
built without a metadata service (`pub.NewWithoutMetadata`), so its traffic
does **not** appear under `banyandb_queue_pub_*`. Instead the same instruments
— `total_started`, `total_finished`, `total_latency`, `total_err`,
`sent_bytes`, plus the batch catalog
`total_batch_started`/`total_batch_finished`/`total_batch_latency` — are
registered under **`banyandb_lifecycle_migration_*`**, with the same labels
(`opera [...]
The lifecycle sidecar serves these series (plus the run-health gauges below)
at `/metrics` on its own HTTP port (`--lifecycle-http-port`, default `17915`)
instead of the `2121` observability listener; the co-located FODC agent polls
that port, so the series arrive through the proxy scrape with
`container_name="lifecycle"` and the `pod_name` shared with the co-located data
node. On the **receiving** data node the same flows are mirrored as
`banyandb_queue_sub_*` series with `remote_role=" [...]
diff --git a/pkg/meter/meter.go b/pkg/meter/meter.go
index a52aef9de..66382302a 100644
--- a/pkg/meter/meter.go
+++ b/pkg/meter/meter.go
@@ -31,6 +31,11 @@ type (
// DefBuckets is the default buckets for histograms.
var DefBuckets = Buckets{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
+// BatchBuckets is the bucket set for batch-stream latency histograms.
Batch-mode
+// streams and lifecycle migration replays can run for minutes, so the upper
bound
+// extends to 300s instead of the 10s ceiling of DefBuckets.
+var BatchBuckets = Buckets{0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300}
+
// Merge merges the given label pairs with the current label pairs.
func (p LabelPairs) Merge(other LabelPairs) LabelPairs {
result := make(LabelPairs, len(p)+len(other))