Skip to content

Commit b9973df

Browse files
committed
batch: reserve 10KiB gRPC framing overhead in split and add batch_splits_total metric
1 parent 11a4f36 commit b9973df

2 files changed

Lines changed: 101 additions & 10 deletions

File tree

pkg/chipingress/batch/client.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type batchClientMetrics struct {
5757
requestSizeBytes otelmetric.Int64Histogram
5858
requestLatencyMS otelmetric.Float64Histogram
5959
configInfo otelmetric.Int64Gauge
60+
batchSplitsTotal otelmetric.Int64Counter
6061
batchSizeAttr otelmetric.MeasurementOption
6162
maxGRPCReqSizeAttr otelmetric.MeasurementOption
6263
successStatusAttr otelmetric.MeasurementOption
@@ -260,7 +261,11 @@ func (b *Client) sendBatch(ctx context.Context, messages []*messageWithCallback)
260261
go func() {
261262
defer func() { <-b.maxConcurrentSends }()
262263

263-
for _, batchMessages := range splitMessagesByRequestSize(messages, b.maxGRPCRequestSize) {
264+
splitBatches := splitMessagesByRequestSize(messages, b.maxGRPCRequestSize)
265+
if len(splitBatches) > 1 {
266+
b.metrics.batchSplitsTotal.Add(ctx, 1)
267+
}
268+
for _, batchMessages := range splitBatches {
264269
batchReq, batchBytes := newBatchRequest(batchMessages)
265270
if b.maxGRPCRequestSize > 0 && batchBytes > b.maxGRPCRequestSize {
266271
err := fmt.Errorf("publish batch serialized size %d exceeds max gRPC request size %d", batchBytes, b.maxGRPCRequestSize)
@@ -298,6 +303,15 @@ func (b *Client) completeBatchCallbacks(messages []*messageWithCallback, err err
298303
})
299304
}
300305

306+
// grpcFramingOverhead accounts for gRPC framing, HTTP/2 headers, auth tokens,
307+
// tracing metadata, and other per-request overhead not captured by proto.Size.
308+
const grpcFramingOverhead = 10 * 1024 // 10 KiB
309+
310+
// minMaxGRPCRequestSize is the minimum allowed value for maxGRPCRequestSize.
311+
// Values below this threshold are clamped to ensure the framing overhead
312+
// reservation remains meaningful.
313+
const minMaxGRPCRequestSize = 1024 * 1024 // 1 MiB
314+
301315
func splitMessagesByRequestSize(messages []*messageWithCallback, maxRequestSize int) [][]*messageWithCallback {
302316
if len(messages) == 0 {
303317
return nil
@@ -306,12 +320,18 @@ func splitMessagesByRequestSize(messages []*messageWithCallback, maxRequestSize
306320
return [][]*messageWithCallback{messages}
307321
}
308322

323+
effectiveMax := maxRequestSize - grpcFramingOverhead
324+
if effectiveMax <= 0 {
325+
// Should not happen given minMaxGRPCRequestSize, but guard defensively.
326+
effectiveMax = maxRequestSize
327+
}
328+
309329
var batches [][]*messageWithCallback
310330
current := make([]*messageWithCallback, 0, len(messages))
311331
for _, msg := range messages {
312332
candidate := append(current, msg)
313333
_, candidateBytes := newBatchRequest(candidate)
314-
if len(current) > 0 && candidateBytes > maxRequestSize {
334+
if len(current) > 0 && candidateBytes > effectiveMax {
315335
batches = append(batches, current)
316336
current = []*messageWithCallback{msg}
317337
continue
@@ -340,9 +360,14 @@ func WithBatchSize(batchSize int) Opt {
340360
}
341361
}
342362

343-
// WithMaxGRPCRequestSize sets the max gRPC request size in bytes used for metric comparison attributes.
363+
// WithMaxGRPCRequestSize sets the max gRPC request size in bytes used for splitting batches.
364+
// Values below minMaxGRPCRequestSize (1 MiB) are clamped up to ensure the framing overhead
365+
// reservation remains meaningful.
344366
func WithMaxGRPCRequestSize(maxReqSize int) Opt {
345367
return func(c *Client) {
368+
if maxReqSize > 0 && maxReqSize < minMaxGRPCRequestSize {
369+
maxReqSize = minMaxGRPCRequestSize
370+
}
346371
c.maxGRPCRequestSize = maxReqSize
347372
}
348373
}
@@ -439,6 +464,14 @@ func newBatchClientMetrics() (batchClientMetrics, error) {
439464
if err != nil {
440465
return batchClientMetrics{}, err
441466
}
467+
batchSplitsTotal, err := meter.Int64Counter(
468+
"chip_ingress.batch.batch_splits_total",
469+
otelmetric.WithDescription("Total number of times a batch was split due to exceeding the effective gRPC request size limit (max request size minus reserved framing overhead)"),
470+
otelmetric.WithUnit("{split}"),
471+
)
472+
if err != nil {
473+
return batchClientMetrics{}, err
474+
}
442475

443476
return batchClientMetrics{
444477
sendRequestsTotal: sendRequestsTotal,
@@ -447,6 +480,7 @@ func newBatchClientMetrics() (batchClientMetrics, error) {
447480
requestSizeBytes: requestSizeBytes,
448481
requestLatencyMS: requestLatencyMS,
449482
configInfo: configInfo,
483+
batchSplitsTotal: batchSplitsTotal,
450484
successStatusAttr: otelmetric.WithAttributeSet(attribute.NewSet(
451485
attribute.String("status", "success"),
452486
)),

pkg/chipingress/batch/client_test.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,10 @@ func TestNewBatchClient(t *testing.T) {
7979
WithBatchSize(1),
8080
WithBatchInterval(time.Second),
8181
WithMessageBuffer(10),
82-
WithMaxGRPCRequestSize(maxGRPCSize),
8382
)
8483
require.NoError(t, err)
84+
client.maxGRPCRequestSize = maxGRPCSize
85+
require.NoError(t, err)
8586
client.Start(t.Context())
8687

8788
err = client.QueueMessage(&chipingress.CloudEventPb{
@@ -303,8 +304,9 @@ func TestSendBatch(t *testing.T) {
303304
}).
304305
Times(3)
305306

306-
client, err := NewBatchClient(mockClient, WithMaxGRPCRequestSize(maxRequestSize))
307+
client, err := NewBatchClient(mockClient)
307308
require.NoError(t, err)
309+
client.maxGRPCRequestSize = maxRequestSize
308310

309311
messages := make([]*messageWithCallback, 0, len(events))
310312
for _, event := range events {
@@ -339,14 +341,69 @@ func TestSendBatch(t *testing.T) {
339341
mockClient.AssertExpectations(t)
340342
})
341343

344+
t.Run("records batch_splits_total metric when batch is split", func(t *testing.T) {
345+
reader, restore := useTestMeterProvider(t)
346+
defer restore()
347+
348+
events := []*chipingress.CloudEventPb{
349+
largeTestEvent("split-metric-1"),
350+
largeTestEvent("split-metric-2"),
351+
largeTestEvent("split-metric-3"),
352+
}
353+
// Set maxRequestSize so that 2 events fit but 3 do not, forcing a split.
354+
maxRequestSize := proto.Size(&chipingress.CloudEventBatch{Events: events[:2]})
355+
356+
mockClient := mocks.NewClient(t)
357+
done := make(chan struct{})
358+
var mu sync.Mutex
359+
var publishCount int
360+
361+
mockClient.
362+
On("PublishBatch", mock.Anything, mock.Anything).
363+
Return(&chipingress.PublishResponse{}, nil).
364+
Run(func(args mock.Arguments) {
365+
mu.Lock()
366+
publishCount++
367+
if publishCount == 2 {
368+
close(done)
369+
}
370+
mu.Unlock()
371+
})
372+
373+
client, err := NewBatchClient(mockClient)
374+
require.NoError(t, err)
375+
client.maxGRPCRequestSize = maxRequestSize
376+
377+
messages := make([]*messageWithCallback, 0, len(events))
378+
for _, event := range events {
379+
messages = append(messages, &messageWithCallback{event: event})
380+
}
381+
382+
client.sendBatch(t.Context(), messages)
383+
384+
select {
385+
case <-done:
386+
case <-time.After(time.Second):
387+
t.Fatal("timeout waiting for split batches to be sent")
388+
}
389+
390+
rm := collectResourceMetrics(t, reader)
391+
splitsMetric := mustMetric(t, rm, "chip_ingress.batch.batch_splits_total")
392+
splitsSum, ok := splitsMetric.Data.(metricdata.Sum[int64])
393+
require.True(t, ok)
394+
require.Len(t, splitsSum.DataPoints, 1)
395+
assert.Equal(t, int64(1), splitsSum.DataPoints[0].Value)
396+
})
397+
342398
t.Run("doesn't publish a single event over max gRPC request size", func(t *testing.T) {
343399
mockClient := mocks.NewClient(t)
344400
callbackDone := make(chan error, 1)
345401
event := largeTestEvent("oversized-id")
346402
maxRequestSize := proto.Size(&chipingress.CloudEventBatch{Events: []*chipingress.CloudEventPb{event}}) - 1
347403

348-
client, err := NewBatchClient(mockClient, WithMaxGRPCRequestSize(maxRequestSize))
404+
client, err := NewBatchClient(mockClient)
349405
require.NoError(t, err)
406+
client.maxGRPCRequestSize = maxRequestSize
350407

351408
client.sendBatch(t.Context(), []*messageWithCallback{
352409
{
@@ -1350,7 +1407,7 @@ func TestBatchClient_Metrics(t *testing.T) {
13501407
WithBatchSize(1),
13511408
WithBatchInterval(time.Second),
13521409
WithMessageBuffer(10),
1353-
WithMaxGRPCRequestSize(2048),
1410+
WithMaxGRPCRequestSize(minMaxGRPCRequestSize),
13541411
)
13551412
require.NoError(t, err)
13561413
client.Start(t.Context())
@@ -1386,7 +1443,7 @@ func TestBatchClient_Metrics(t *testing.T) {
13861443
reqSize := mustMetric(t, rm, "chip_ingress.batch.request_size_bytes")
13871444
reqSizeHist, ok := reqSize.Data.(metricdata.Histogram[int64])
13881445
require.True(t, ok)
1389-
reqSizePoint := mustInt64HistogramPointWithIntAttr(t, reqSizeHist, "max_grpc_request_size_bytes", 2048)
1446+
reqSizePoint := mustInt64HistogramPointWithIntAttr(t, reqSizeHist, "max_grpc_request_size_bytes", minMaxGRPCRequestSize)
13901447
assert.GreaterOrEqual(t, reqSizePoint.Count, uint64(1))
13911448

13921449
latency := mustMetric(t, rm, "chip_ingress.batch.request_latency_ms")
@@ -1401,7 +1458,7 @@ func TestBatchClient_Metrics(t *testing.T) {
14011458
require.NotEmpty(t, configGauge.DataPoints)
14021459
assert.Equal(t, int64(1), configGauge.DataPoints[0].Value)
14031460
assert.True(t, hasIntAttr(configGauge.DataPoints[0].Attributes, "max_batch_size", 1))
1404-
assert.True(t, hasIntAttr(configGauge.DataPoints[0].Attributes, "max_grpc_request_size_bytes", 2048))
1461+
assert.True(t, hasIntAttr(configGauge.DataPoints[0].Attributes, "max_grpc_request_size_bytes", minMaxGRPCRequestSize))
14051462
})
14061463

14071464
t.Run("records failure counters and latency", func(t *testing.T) {
@@ -1540,11 +1597,11 @@ func BenchmarkSendBatch(b *testing.B) {
15401597
WithBatchSize(100),
15411598
WithMessageBuffer(b.N*100+10),
15421599
WithBatchInterval(time.Hour),
1543-
WithMaxGRPCRequestSize(512),
15441600
)
15451601
if err != nil {
15461602
b.Fatal(err)
15471603
}
1604+
client.maxGRPCRequestSize = 512
15481605
client.Start(b.Context())
15491606
defer client.Stop()
15501607

0 commit comments

Comments
 (0)