Files
Vengal raoandGitHub c802f601cf [exporterhelper][queuebatcher] Remove unused partitions from memory after a given time period (#14655)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue.
Ex. Adding a feature - Explain what this achieves.-->
#### Description

https://github.com/open-telemetry/opentelemetry-collector/issues/14526

<!-- Issue number if applicable -->
#### Link to tracking issue
Fixes #

<!--Describe what testing was performed and which tests were added.-->
#### Testing

<!--Describe the documentation added.-->
#### Documentation

<!--Please delete paragraphs that you did not use before submitting.-->
2026-03-12 22:21:40 +00:00

632 lines
20 KiB
Go

// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package queuebatch
import (
"context"
"errors"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"go.opentelemetry.io/collector/component/componenttest"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/request"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/requesttest"
)
type testContextKey string
const timestampKey testContextKey = "timestamp"
func TestPartitionBatcher_NoSplit_MinThresholdZero_TimeoutDisabled(t *testing.T) {
tests := []struct {
name string
sizerType request.SizerType
sizer request.Sizer
maxWorkers int
}{
{
name: "items/one_worker",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 1,
},
{
name: "items/three_workers",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 3,
},
{
name: "bytes/one_worker",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 1,
},
{
name: "bytes/three_workers",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: tt.sizerType,
MinSize: 0,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, tt.sizer, nil, newWorkerPool(tt.maxWorkers), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
sink.SetExportErr(errors.New("transient error"))
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
<-time.After(10 * time.Millisecond)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 17, Bytes: 17}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 13, Bytes: 13}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 35, Bytes: 35}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 2, Bytes: 2}, done)
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 5 && (sink.ItemsCount() == 75 || sink.BytesCount() == 75)
}, 1*time.Second, 10*time.Millisecond)
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 1, done.errors.Load())
assert.EqualValues(t, 5, done.success.Load())
})
}
}
func TestPartitionBatcher_NoSplit_TimeoutDisabled(t *testing.T) {
tests := []struct {
name string
sizerType request.SizerType
sizer request.Sizer
maxWorkers int
}{
{
name: "items/one_worker",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 1,
},
{
name: "items/three_workers",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 3,
},
{
name: "bytes/one_worker",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 1,
},
{
name: "bytes/three_workers",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: tt.sizerType,
MinSize: 10,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, tt.sizer, nil, newWorkerPool(tt.maxWorkers), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
// These two requests will be dropped because of export error.
sink.SetExportErr(errors.New("transient error"))
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
<-time.After(10 * time.Millisecond)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 7, Bytes: 7}, done)
// This requests will be dropped because of merge error.
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8, MergeErr: errors.New("transient error")}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 13, Bytes: 13}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 35, Bytes: 35}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 2, Bytes: 2}, done)
// Only the requests with 7+13 and 35 will be flushed.
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 2 && (sink.ItemsCount() == 55 || sink.BytesCount() == 55)
}, 1*time.Second, 10*time.Millisecond)
require.NoError(t, ba.Shutdown(context.Background()))
// After shutdown the pending "current batch" is also flushed.
assert.Equal(t, 3, sink.RequestsCount())
assert.True(t, sink.ItemsCount() == 57 || sink.BytesCount() == 57)
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 3, done.errors.Load())
assert.EqualValues(t, 4, done.success.Load())
})
}
}
func TestPartitionBatcher_NoSplit_WithTimeout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping test on Windows, see https://github.com/open-telemetry/opentelemetry-collector/issues/11869")
}
tests := []struct {
name string
sizerType request.SizerType
sizer request.Sizer
maxWorkers int
}{
{
name: "items/one_worker",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 1,
},
{
name: "items/three_workers",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 3,
},
{
name: "bytes/one_worker",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 1,
},
{
name: "bytes/three_workers",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 50 * time.Millisecond,
Sizer: tt.sizerType,
MinSize: 100,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, tt.sizer, nil, newWorkerPool(tt.maxWorkers), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 17, Bytes: 17}, done)
// This requests will be dropped because of merge error.
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8, MergeErr: errors.New("transient error")}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 13, Bytes: 13}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 35, Bytes: 35}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 2, Bytes: 2}, done)
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 1 && (sink.ItemsCount() == 75 || sink.BytesCount() == 75)
}, 1*time.Second, 10*time.Millisecond)
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 1, done.errors.Load())
assert.EqualValues(t, 5, done.success.Load())
})
}
}
func TestPartitionBatcher_Split_TimeoutDisabled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping test on Windows, see https://github.com/open-telemetry/opentelemetry-collector/issues/11847")
}
tests := []struct {
name string
sizerType request.SizerType
sizer request.Sizer
maxWorkers int
}{
{
name: "items/one_worker",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 1,
},
{
name: "items/three_workers",
sizerType: request.SizerTypeItems,
sizer: request.NewItemsSizer(),
maxWorkers: 3,
},
{
name: "bytes/one_worker",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 1,
},
{
name: "bytes/three_workers",
sizerType: request.SizerTypeBytes,
sizer: request.NewBytesSizer(),
maxWorkers: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: tt.sizerType,
MinSize: 100,
MaxSize: 100,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, tt.sizer, nil, newWorkerPool(tt.maxWorkers), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
// This requests will be dropped because of merge error.
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8, MergeErr: errors.New("transient error")}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 17, Bytes: 17}, done)
// This requests will be dropped because of merge error.
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8, MergeErr: errors.New("transient error")}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 13, Bytes: 13}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 35, Bytes: 35}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 2, Bytes: 2}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 30, Bytes: 30}, done)
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 1 && (sink.ItemsCount() == 100 || sink.BytesCount() == 100)
}, 1*time.Second, 10*time.Millisecond)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 900, Bytes: 900}, done)
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 10 && (sink.ItemsCount() == 1000 || sink.BytesCount() == 1000)
}, 1*time.Second, 10*time.Millisecond)
// At this point the 7th not failing request is still pending.
assert.EqualValues(t, 6, done.success.Load())
require.NoError(t, ba.Shutdown(context.Background()))
// After shutdown the pending "current batch" is also flushed.
assert.Equal(t, 11, sink.RequestsCount())
assert.True(t, sink.ItemsCount() == 1005 || sink.BytesCount() == 1005)
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 2, done.errors.Load())
assert.EqualValues(t, 7, done.success.Load())
})
}
}
func TestPartitionBatcher_Shutdown(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 100 * time.Second,
Sizer: request.SizerTypeItems,
MinSize: 10,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(2), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 1, Bytes: 1}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 2, Bytes: 2}, done)
assert.Equal(t, 0, sink.RequestsCount())
assert.Equal(t, 0, sink.ItemsCount())
require.NoError(t, ba.Shutdown(context.Background()))
assert.Equal(t, 1, sink.RequestsCount())
assert.Equal(t, 3, sink.ItemsCount())
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 0, done.errors.Load())
assert.EqualValues(t, 2, done.success.Load())
}
func TestPartitionBatcher_MergeError(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 200 * time.Second,
Sizer: request.SizerTypeItems,
MinSize: 5,
MaxSize: 7,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(2), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 9, Bytes: 9}, done)
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 1 && sink.ItemsCount() == 7
}, 1*time.Second, 10*time.Millisecond)
sink.SetExportErr(errors.New("transient error"))
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 4, Bytes: 4}, done)
assert.Eventually(t, func() bool {
return done.errors.Load() == 2
}, 1*time.Second, 10*time.Millisecond)
// Check that done callback is called for the right number of times.
assert.EqualValues(t, 2, done.errors.Load())
assert.EqualValues(t, 0, done.success.Load())
}
func TestPartitionBatcher_PartialSuccessError(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: request.SizerTypeBytes,
MinSize: 10,
MaxSize: 15,
}
core, observed := observer.New(zap.WarnLevel)
logger := zap.New(core)
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(1), sink.Export, logger, nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
req := &requesttest.FakeRequest{
Items: 100,
Bytes: 100,
MergeErr: errors.New("split error"),
MergeErrResult: []request.Request{&requesttest.FakeRequest{Items: 10, Bytes: 15}},
}
ba.Consume(context.Background(), req, done)
assert.Eventually(t, func() bool {
logs := observed.All()
if len(logs) == 0 {
return false
}
log := logs[0]
return log.Level == zap.WarnLevel &&
log.Message == "Failed to split request."
}, time.Second, 10*time.Millisecond)
require.NoError(t, ba.Shutdown(context.Background()))
// Verify that done callback was called with the returned batch and error for the split.
assert.Equal(t, int64(1), done.errors.Load())
assert.Equal(t, 1, sink.RequestsCount())
assert.Equal(t, 10, sink.ItemsCount())
assert.Equal(t, 15, sink.BytesCount())
}
func TestSPartitionBatcher_PartialSuccessError_AfterOkRequest(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: request.SizerTypeBytes,
MinSize: 10,
MaxSize: 15,
}
core, observed := observer.New(zap.WarnLevel)
logger := zap.New(core)
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(1), sink.Export, logger, nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 5, Bytes: 5}, done)
req := &requesttest.FakeRequest{
Items: 100,
Bytes: 100,
MergeErr: errors.New("split error"),
MergeErrResult: []request.Request{&requesttest.FakeRequest{Items: 10, Bytes: 15}},
}
ba.Consume(context.Background(), req, done)
assert.Eventually(t, func() bool {
logs := observed.All()
if len(logs) == 0 {
return false
}
log := logs[0]
return log.Level == zap.WarnLevel &&
log.Message == "Failed to split request."
}, time.Second, 10*time.Millisecond)
require.NoError(t, ba.Shutdown(context.Background()))
// Verify that done callback was called with the success for the returned batch and error for the split.
assert.Equal(t, int64(1), done.errors.Load())
assert.Equal(t, int64(1), done.success.Load())
assert.Equal(t, 1, sink.RequestsCount())
assert.Equal(t, 10, sink.ItemsCount())
assert.Equal(t, 15, sink.BytesCount())
}
type fakeDone struct {
errors *atomic.Int64
success *atomic.Int64
}
func newFakeDone() fakeDone {
return fakeDone{
errors: &atomic.Int64{},
success: &atomic.Int64{},
}
}
func (fd fakeDone) OnDone(err error) {
if err != nil {
fd.errors.Add(1)
} else {
fd.success.Add(1)
}
}
func TestShardBatcher_EmptyRequestList(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
MinSize: 0,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(1), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
done := newFakeDone()
req := &requesttest.FakeRequest{
Items: 1,
MergeErr: errors.New("force empty list"),
}
ba.Consume(context.Background(), req, done)
assert.Eventually(t, func() bool {
return done.errors.Load() == 1
}, time.Second, 10*time.Millisecond)
assert.Equal(t, int64(0), done.success.Load())
assert.Equal(t, 0, sink.RequestsCount())
}
func TestPartitionBatcher_ContextMerging(t *testing.T) {
tests := []struct {
name string
mergeCtxFunc func(ctx1, ctx2 context.Context) context.Context
}{
{
name: "merge_context_with_timestamp",
mergeCtxFunc: func(ctx1, _ context.Context) context.Context {
return context.WithValue(ctx1, timestampKey, 1234)
},
},
{
name: "merge_context_returns_background",
mergeCtxFunc: func(_, _ context.Context) context.Context {
return context.Background()
},
},
{
name: "nil_merge_context",
mergeCtxFunc: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := BatchConfig{
FlushTimeout: 0,
Sizer: request.SizerTypeItems,
MinSize: 10,
}
sink := requesttest.NewSink()
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), tt.mergeCtxFunc, newWorkerPool(1), sink.Export, zap.NewNop(), nil)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 8, Bytes: 8}, done)
<-time.After(10 * time.Millisecond)
assert.Equal(t, 1, sink.RequestsCount())
assert.EqualValues(t, 2, done.success.Load())
})
}
}
func TestPartitionBatcher_OnEmptyCallbackTriggered(t *testing.T) {
// Use a very short FlushTimeout so the idle threshold (partitionIdleCycles*FlushTimeout) is reached quickly.
cfg := BatchConfig{
FlushTimeout: 10 * time.Millisecond,
Sizer: request.SizerTypeItems,
MinSize: 100, // High min size to ensure data doesn't flush immediately
}
sink := requesttest.NewSink()
onEmptyCalled := &atomic.Int64{}
onEmpty := func() {
onEmptyCalled.Add(1)
}
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(1), sink.Export, zap.NewNop(), onEmpty)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
// Consume some data below min threshold so it stays in currentBatch
done := newFakeDone()
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 5}, done)
// Wait for the batch to be flushed by timeout
assert.Eventually(t, func() bool {
return sink.RequestsCount() == 1
}, 500*time.Millisecond, 10*time.Millisecond)
// Now wait for idle timeout (partitionIdleCycles * FlushTimeout = 10 * 10ms = 100ms)
// The onEmpty callback should be called after the partition is idle for this duration.
assert.Eventually(t, func() bool {
return onEmptyCalled.Load() >= 1
}, 500*time.Millisecond, 10*time.Millisecond)
}
func TestPartitionBatcher_OnEmptyNotCalledWithActiveData(t *testing.T) {
// Test that onEmpty is NOT called when data keeps flowing
cfg := BatchConfig{
FlushTimeout: 20 * time.Millisecond,
Sizer: request.SizerTypeItems,
MinSize: 5,
}
sink := requesttest.NewSink()
onEmptyCalled := &atomic.Int64{}
onEmpty := func() {
onEmptyCalled.Add(1)
}
ba := newPartitionBatcher(cfg, request.NewItemsSizer(), nil, newWorkerPool(1), sink.Export, zap.NewNop(), onEmpty)
require.NoError(t, ba.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, ba.Shutdown(context.Background()))
})
done := newFakeDone()
// Keep sending data to prevent idle timeout
for range 5 {
ba.Consume(context.Background(), &requesttest.FakeRequest{Items: 5}, done)
time.Sleep(15 * time.Millisecond)
}
// Data was flowing, onEmpty should not have been called
assert.Equal(t, int64(0), onEmptyCalled.Load())
// But data should have been flushed
assert.GreaterOrEqual(t, sink.RequestsCount(), 1)
}