Skip to content

Commit 7171f70

Browse files
fix(otelconsumer): add back-off to retries (#50294)
* fix(otelconsumer): add back-off to retries Relates #50217 * fix tests The fbreceiver's `TestConsumeContract` test was failing due to the long retries. In this change, I'm using the existing `isReceiverTest` flag to shorten the retry backoff to 1ms in tests. I'd rather fix this by passing the retry config explicitly to the otelconsumer either via user config or API. * test: make tests more useful The previous version generated by AI and not reviewed by me was not very useful. * test: add margin for backoff duration
1 parent f00c983 commit 7171f70

2 files changed

Lines changed: 135 additions & 9 deletions

File tree

libbeat/otel/otelconsumer/otelconsumer.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ import (
2323
"fmt"
2424
"os"
2525
"runtime"
26+
"sync"
2627
"time"
2728

2829
"github.com/elastic/beats/v7/libbeat/beat"
2930
"github.com/elastic/beats/v7/libbeat/common"
31+
"github.com/elastic/beats/v7/libbeat/common/backoff"
3032
"github.com/elastic/beats/v7/libbeat/otel/otelctx"
3133
"github.com/elastic/beats/v7/libbeat/otel/otelmap"
3234
"github.com/elastic/beats/v7/libbeat/outputs"
@@ -45,19 +47,36 @@ import (
4547
const (
4648
// esDocumentIDAttribute is the attribute key used to store the document ID in the log record.
4749
esDocumentIDAttribute = "elasticsearch.document_id"
50+
51+
retryBackoffInit = 1 * time.Second
52+
retryBackoffMax = 60 * time.Second
4853
)
4954

55+
type retryConfig struct {
56+
init time.Duration
57+
max time.Duration
58+
}
59+
5060
type otelConsumer struct {
5161
observer outputs.Observer
5262
logsConsumer consumer.Logs
5363
beatInfo beat.Info
5464
log *logp.Logger
5565
isReceiverTest bool // whether we are running in receivertest context
66+
67+
retry retryConfig
68+
retryBackoff backoff.Backoff
69+
backoffInit sync.Once
5670
}
5771

5872
func MakeOtelConsumer(beat beat.Info, observer outputs.Observer) (outputs.Group, error) {
5973
isReceiverTest := os.Getenv("OTELCONSUMER_RECEIVERTEST") == "1"
6074

75+
retry := retryConfig{init: retryBackoffInit, max: retryBackoffMax}
76+
if isReceiverTest {
77+
retry = retryConfig{init: 1 * time.Millisecond, max: 2 * time.Millisecond}
78+
}
79+
6180
// Default to runtime.NumCPU() workers
6281
clients := make([]outputs.Client, 0, runtime.NumCPU())
6382
for range runtime.NumCPU() {
@@ -67,6 +86,7 @@ func MakeOtelConsumer(beat beat.Info, observer outputs.Observer) (outputs.Group,
6786
beatInfo: beat,
6887
log: beat.Logger.Named("otelconsumer"),
6988
isReceiverTest: isReceiverTest,
89+
retry: retry,
7090
})
7191
}
7292

@@ -191,9 +211,19 @@ func (out *otelConsumer) logsPublish(ctx context.Context, batch publisher.Batch)
191211
}
192212
}
193213

214+
out.backoffInit.Do(func() {
215+
out.retryBackoff = backoff.NewEqualJitterBackoff(ctx.Done(), out.retry.init, out.retry.max)
216+
})
217+
194218
err := out.logsConsumer.ConsumeLogs(otelctx.NewConsumerContext(ctx, out.beatInfo), pLogs)
195219
if err != nil {
196-
// Permanent errors shouldn't be retried. This tipically means
220+
// Queue full errors are expected backpressure signals, not true errors.
221+
// Skip logging to avoid log spam since we already track this via metrics.
222+
if !errors.Is(err, exporterhelper.ErrQueueIsFull) {
223+
out.log.Errorf("failed to publish batch events to otel collector pipeline: %v", err)
224+
}
225+
226+
// Permanent errors shouldn't be retried. This typically means
197227
// the data cannot be serialized by the exporter that is attached
198228
// to the pipeline or when the destination refuses the data because
199229
// it cannot decode it. Retrying in this case is useless.
@@ -204,19 +234,18 @@ func (out *otelConsumer) logsPublish(ctx context.Context, batch publisher.Batch)
204234
batch.Drop()
205235
} else {
206236
st.RetryableErrors(len(events))
237+
if !out.retryBackoff.Wait() {
238+
batch.Cancelled()
239+
return nil
240+
}
207241
batch.Retry()
208242
}
209-
210-
// Queue full errors are expected backpressure signals, not true errors.
211-
// Skip logging to avoid log spam since we already track this via metrics.
212-
if !errors.Is(err, exporterhelper.ErrQueueIsFull) {
213-
out.log.Errorf("failed to publish batch events to otel collector pipeline: %v", err)
214-
}
215243
return nil
216244
}
217245

218246
batch.ACK()
219247
st.AckedEvents(len(events))
248+
out.retryBackoff.Reset()
220249
return nil
221250
}
222251

libbeat/otel/otelconsumer/otelconsumer_test.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ func TestPublish(t *testing.T) {
6060
logger := logptest.NewTestingLogger(t, "")
6161
logConsumer, err := consumer.NewLogs(consumeFn)
6262
assert.NoError(t, err)
63-
consumer := &otelConsumer{
63+
return &otelConsumer{
6464
observer: outputs.NewNilObserver(),
6565
logsConsumer: logConsumer,
6666
beatInfo: beatInfo,
6767
log: logger.Named("otelconsumer"),
68+
retry: retryConfig{init: 1 * time.Millisecond, max: 2 * time.Millisecond},
6869
}
69-
return consumer
7070
}
7171

7272
t.Run("ack batch on consumer success", func(t *testing.T) {
@@ -266,6 +266,103 @@ func TestPublish(t *testing.T) {
266266
assert.Equal(t, outest.BatchRetry, batch.Signals[0].Tag)
267267
})
268268

269+
t.Run("retries are delayed by exponential backoff", func(t *testing.T) {
270+
const (
271+
initBackoff = 50 * time.Millisecond
272+
maxBackoff = 500 * time.Millisecond
273+
)
274+
275+
otelConsumer := makeOtelConsumer(t, func(ctx context.Context, ld plog.Logs) error {
276+
return errors.New("retryable error")
277+
})
278+
otelConsumer.retry = retryConfig{init: initBackoff, max: maxBackoff}
279+
280+
// Measure the duration of each Publish call. Each call blocks during
281+
// backoff Wait(), so elapsed time reflects the actual backoff delay.
282+
var durations []time.Duration
283+
for range 3 {
284+
batch := outest.NewBatch(event1)
285+
start := time.Now()
286+
err := otelConsumer.Publish(ctx, batch)
287+
durations = append(durations, time.Since(start))
288+
require.NoError(t, err, "Publish should not return an error")
289+
assert.Equal(t, outest.BatchRetry, batch.Signals[0].Tag, "batch should be retried")
290+
}
291+
292+
assert.GreaterOrEqual(t, durations[0], initBackoff, "first retry delay should be at least ~init")
293+
assert.GreaterOrEqual(t, durations[1], 2*initBackoff, "second retry delay should be at least ~2*init (exponential growth)")
294+
assert.GreaterOrEqual(t, durations[2], 4*initBackoff, "third retry delay should be at least ~4*init (exponential growth)")
295+
})
296+
297+
t.Run("backoff resets on success", func(t *testing.T) {
298+
const (
299+
initBackoff = 50 * time.Millisecond
300+
maxBackoff = 500 * time.Millisecond
301+
)
302+
303+
callCount := 0
304+
otelConsumer := makeOtelConsumer(t, func(ctx context.Context, ld plog.Logs) error {
305+
callCount++
306+
if callCount == 3 {
307+
return nil
308+
}
309+
return errors.New("retryable error")
310+
})
311+
otelConsumer.retry = retryConfig{init: initBackoff, max: maxBackoff}
312+
313+
// Two failures grow the backoff past init level.
314+
batch1 := outest.NewBatch(event1)
315+
err := otelConsumer.Publish(ctx, batch1)
316+
require.NoError(t, err)
317+
assert.Equal(t, outest.BatchRetry, batch1.Signals[0].Tag, "first batch should be retried")
318+
319+
batch2 := outest.NewBatch(event1)
320+
err = otelConsumer.Publish(ctx, batch2)
321+
require.NoError(t, err)
322+
assert.Equal(t, outest.BatchRetry, batch2.Signals[0].Tag, "second batch should be retried")
323+
324+
// Third call succeeds, triggering backoff Reset().
325+
batch3 := outest.NewBatch(event1)
326+
err = otelConsumer.Publish(ctx, batch3)
327+
require.NoError(t, err)
328+
assert.Equal(t, outest.BatchACK, batch3.Signals[0].Tag, "third batch should be acked")
329+
330+
// Next failure should use init-level backoff ([init, 2*init) = [50ms, 100ms)),
331+
// not the grown level which would be [4*init, 8*init) = [200ms, 400ms).
332+
batch4 := outest.NewBatch(event1)
333+
start := time.Now()
334+
err = otelConsumer.Publish(ctx, batch4)
335+
duration := time.Since(start)
336+
require.NoError(t, err)
337+
assert.Equal(t, outest.BatchRetry, batch4.Signals[0].Tag, "fourth batch should be retried")
338+
// In equal jitter backoff strategy, initial backoff is between initBackoff and 2*initBackoff.
339+
const margin = 10 * time.Millisecond
340+
assert.Less(t, duration, 2*initBackoff+margin, "after success, backoff should reset to init level, not continue growing (got %v)", duration)
341+
})
342+
343+
t.Run("cancels batch when context is cancelled during backoff", func(t *testing.T) {
344+
batch := outest.NewBatch(event1)
345+
346+
cancelCtx, cancelFn := context.WithCancel(context.Background())
347+
otelConsumer := makeOtelConsumer(t, func(ctx context.Context, ld plog.Logs) error {
348+
return errors.New("retryable error")
349+
})
350+
otelConsumer.retry = retryConfig{init: 10 * time.Second, max: 10 * time.Second}
351+
352+
publishDone := make(chan struct{})
353+
go func() {
354+
_ = otelConsumer.Publish(cancelCtx, batch)
355+
close(publishDone)
356+
}()
357+
358+
time.Sleep(50 * time.Millisecond)
359+
cancelFn()
360+
<-publishDone
361+
362+
assert.Len(t, batch.Signals, 1)
363+
assert.Equal(t, outest.BatchCancelled, batch.Signals[0].Tag)
364+
})
365+
269366
t.Run("sets the elasticsearchexporter doc id attribute from metadata", func(t *testing.T) {
270367
batch := outest.NewBatch(event4)
271368

0 commit comments

Comments
 (0)