Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
44485a9
bulk: add max_concurrent_secret_reads config option
ycombinator Aug 6, 2026
0d27580
bulk: wire max_concurrent_secret_reads through BulkOpt
ycombinator Aug 6, 2026
0b9ad69
bulk: enforce concurrent ReadSecrets limit via semaphore
ycombinator Aug 6, 2026
ff0b7a4
bulk: fix goimports: restore constant alignment, set default to 32
ycombinator Aug 6, 2026
4b16bc7
bulk: set maxConcurrentSecretReads default in parseBulkOpts
ycombinator Aug 6, 2026
8dd3e06
config: default max_concurrent_secret_reads to 32
ycombinator Aug 6, 2026
24351f4
bulk: remove spurious extra space in constant comment
ycombinator Aug 6, 2026
b933c15
bulk: reuse defaultAPIKeyMaxParallel for readSecretsLimit default
ycombinator Aug 6, 2026
3ecf311
bulk: reuse defaultAPIKeyMaxParallel for readSecretsLimit default
ycombinator Aug 6, 2026
d6f5f16
bulk: always initialize readSecretsLimit, matching apikeyLimit pattern
ycombinator Aug 6, 2026
cca2152
bulk: acquire readSecretsLimit per secret call, matching apikeyLimit …
ycombinator Aug 6, 2026
afaf5ed
bulk: change maxConcurrentSecretReads to int, matching apikeyMaxParallel
ycombinator Aug 6, 2026
2fc3907
config: use named constant and int type for MaxConcurrentSecretReads
ycombinator Aug 6, 2026
9158e59
bulk: add comment on readSecretsLimit acquire
ycombinator Aug 6, 2026
5d857b5
bulk: trim acquire comment
ycombinator Aug 6, 2026
3b9df17
changelog: add fragment for ReadSecrets concurrency limit
ycombinator Aug 6, 2026
2fea54e
bulk: add unit tests for ReadSecrets concurrency limit
ycombinator Aug 6, 2026
7b6ab54
fix: reorder imports in engine.go and simplify changelog fragment
ycombinator Aug 6, 2026
315c5ce
bulk: fix zero-value readSecretsLimit and add own default constant
ycombinator Aug 7, 2026
4a29c59
bulk: remove max_concurrent_secret_reads from user-facing config
ycombinator Aug 7, 2026
8ff6818
bulk: add comment explaining nil readSecretsLimit for zero value
ycombinator Aug 7, 2026
c0d5581
test: add X-Elastic-Product header to blockingTransport responses
ycombinator Aug 7, 2026
8a19124
test: use wg.Go in TestReadSecretsDefaultConcurrency
ycombinator Aug 7, 2026
aaa3d9d
test: bound secret read concurrency waits
ycombinator Aug 10, 2026
1d0a31d
test: use test context for secret read limits
ycombinator Aug 10, 2026
229dfce
test: use wait group go for secret reads
ycombinator Aug 10, 2026
bd742ba
test: unblock secret read workers on failure
ycombinator Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelog/fragments/1786037058-rate-limit-read-secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
kind: bug

summary: Limit concurrent secret reads to reduce memory pressure under high agent check-in load

description: |
Under high concurrent agent check-in load, fleet-server could open an
unbounded number of concurrent connections to Elasticsearch to resolve
policy secret references, contributing to out-of-memory conditions on
large deployments.

Secret reads are now capped at 32 concurrent in-flight requests.
Excess reads wait until a slot is available.

component: fleet-server
20 changes: 18 additions & 2 deletions internal/pkg/bulk/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import (
"sync/atomic"
"time"

"github.com/elastic/go-ucfg"

"github.com/elastic/fleet-server/v7/internal/pkg/apikey"
"github.com/elastic/fleet-server/v7/internal/pkg/build"
"github.com/elastic/fleet-server/v7/internal/pkg/config"
"github.com/elastic/fleet-server/v7/internal/pkg/es"
"github.com/elastic/fleet-server/v7/internal/pkg/logger/ecs"
"github.com/elastic/go-ucfg"

"github.com/rs/zerolog"
"go.elastic.co/apm/v2"
Expand Down Expand Up @@ -121,6 +122,7 @@ type Bulker struct {
blkPool sync.Pool
flushBufPool sync.Pool
apikeyLimit *semaphore.Weighted
readSecretsLimit *semaphore.Weighted
tracer *apm.Tracer
cancelFn context.CancelFunc
pendingBulkDispatches atomic.Int64
Expand All @@ -137,6 +139,7 @@ const (
defaultMaxPending = 32
defaultBlockQueueSz = 32 // Small capacity to allow multiOp to spin fast
defaultAPIKeyMaxParallel = 32
defaultMaxConcurrentSecretReads = 32
defaultApikeyMaxReqSize = 100 * 1024 * 1024
defaultFlushContextTimeout = time.Minute * 1
defaultMaxPendingBulkDispatches int64 = 0 // 0 means no limit
Expand All @@ -157,7 +160,7 @@ func NewBulker(es esapi.Transport, tracer *apm.Tracer, opts ...BulkOpt) *Bulker
return &bulkT{ch: make(chan respT, 1)}
}

return &Bulker{
b := &Bulker{
opts: bopts,
es: es,
ch: make(chan *bulkT, bopts.blockQueueSz),
Expand All @@ -169,6 +172,11 @@ func NewBulker(es esapi.Transport, tracer *apm.Tracer, opts ...BulkOpt) *Bulker
// remote ES bulkers
bulkerMap: make(map[string]Bulk),
}
// 0 means no limit; leave readSecretsLimit nil so ReadSecrets skips the semaphore.
if bopts.maxConcurrentSecretReads > 0 {
b.readSecretsLimit = semaphore.NewWeighted(int64(bopts.maxConcurrentSecretReads))
}
return b
}

func (b *Bulker) GetBulker(outputName string) Bulk {
Expand Down Expand Up @@ -328,7 +336,15 @@ func (b *Bulker) ReadSecrets(ctx context.Context, secretIds []string) (map[strin
result := make(map[string]string)
esClient := b.Client()
for _, id := range secretIds {
if b.readSecretsLimit != nil {
if err := b.readSecretsLimit.Acquire(ctx, 1); err != nil {
return nil, err
}
}
val, err := ReadSecret(ctx, esClient, id)
if b.readSecretsLimit != nil {
b.readSecretsLimit.Release(1)
}
if err != nil {
if errors.Is(err, ErrSecretNotFound) {
zerolog.Ctx(ctx).Warn().Str("secret_id", id).Msg("secret not found; policy will load without it")
Expand Down
11 changes: 11 additions & 0 deletions internal/pkg/bulk/opt.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type bulkOptT struct {
apikeyMaxParallel int
apikeyMaxReqSize int
maxPendingBulkDispatches int64
maxConcurrentSecretReads int
policyTokens []config.PolicyToken
bi build.Info
}
Expand Down Expand Up @@ -124,6 +125,14 @@ func WithMaxPendingBulkDispatches(max int64) BulkOpt {
}
}

// WithMaxConcurrentSecretReads sets the upper bound on concurrent ReadSecrets calls.
// When the limit is reached, ReadSecrets blocks until a slot is available. 0 means no limit.
func WithMaxConcurrentSecretReads(max int) BulkOpt {
return func(opt *bulkOptT) {
opt.maxConcurrentSecretReads = max
}
}

// WithAPIKeyMaxParallel sets the number of api key operations outstanding
func WithAPIKeyMaxParallel(max int) BulkOpt {
return func(opt *bulkOptT) {
Expand Down Expand Up @@ -163,6 +172,7 @@ func parseBulkOpts(opts ...BulkOpt) bulkOptT {
blockQueueSz: defaultBlockQueueSz,
apikeyMaxReqSize: defaultApikeyMaxReqSize,
maxPendingBulkDispatches: defaultMaxPendingBulkDispatches,
maxConcurrentSecretReads: defaultMaxConcurrentSecretReads,
policyTokens: []config.PolicyToken{}, // default is empty
}

Expand All @@ -182,6 +192,7 @@ func (o *bulkOptT) MarshalZerologObject(e *zerolog.Event) {
e.Int("apikeyMaxParallel", o.apikeyMaxParallel)
e.Int("apikeyMaxReqSize", o.apikeyMaxReqSize)
e.Int64("maxPendingBulkDispatches", o.maxPendingBulkDispatches)
e.Int("maxConcurrentSecretReads", o.maxConcurrentSecretReads)
}

// BulkOptsFromCfg transforms config to a slize of BulkOpt
Expand Down
167 changes: 167 additions & 0 deletions internal/pkg/bulk/secret_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.

package bulk

import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/elastic/go-elasticsearch/v8"
"github.com/stretchr/testify/require"
)

// blockingTransport is an http.RoundTripper that blocks until gate is closed
// and tracks how many requests are currently in-flight.
type blockingTransport struct {
gate chan struct{}
gateOnce sync.Once
inFlight atomic.Int64
}

func (m *blockingTransport) unblock() {
m.gateOnce.Do(func() { close(m.gate) })
}

func (m *blockingTransport) RoundTrip(_ *http.Request) (*http.Response, error) {
m.inFlight.Add(1)
defer m.inFlight.Add(-1)
<-m.gate
h := http.Header{}
h.Set("X-Elastic-Product", "Elasticsearch")
body := `{"value":"test"}`
return &http.Response{
StatusCode: http.StatusOK,
Header: h,
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}

func newTestBulkerWithTransport(t *testing.T, transport http.RoundTripper, opts ...BulkOpt) *Bulker {
t.Helper()
esClient, err := elasticsearch.NewClient(elasticsearch.Config{
Transport: transport,
Addresses: []string{"http://localhost:9200"},
})
require.NoError(t, err)
return NewBulker(esClient, nil, opts...)
}

// TestReadSecretsLimitsConcurrency verifies that WithMaxConcurrentSecretReads(1)
// allows at most one ReadSecret HTTP call in-flight at a time across concurrent
// ReadSecrets callers.
func TestReadSecretsLimitsConcurrency(t *testing.T) {
mt := &blockingTransport{gate: make(chan struct{})}
defer mt.unblock()
b := newTestBulkerWithTransport(t, mt, WithMaxConcurrentSecretReads(1))

var wg sync.WaitGroup
errs := make([]error, 2)

wg.Go(func() {
_, errs[0] = b.ReadSecrets(t.Context(), []string{"id1"})
})
wg.Go(func() {
_, errs[1] = b.ReadSecrets(t.Context(), []string{"id2"})
})

require.Eventually(t, func() bool {
return mt.inFlight.Load() >= 1
}, time.Second, time.Millisecond, "wait for the first goroutine to enter the transport")

// With semaphore capacity 1, the second goroutine is blocked on Acquire
// and cannot have entered the transport yet.
require.Equal(t, int64(1), mt.inFlight.Load())

// Unblock both goroutines and wait for them to finish.
mt.unblock()
wg.Wait()

require.NoError(t, errs[0])
require.NoError(t, errs[1])
}

// TestReadSecretsContextCancelledWhileWaiting verifies that ReadSecrets returns
// context.Canceled immediately when the semaphore is full and the caller's context
// is already cancelled.
func TestReadSecretsContextCancelledWhileWaiting(t *testing.T) {
mt := &blockingTransport{gate: make(chan struct{})}
defer close(mt.gate)

b := newTestBulkerWithTransport(t, mt, WithMaxConcurrentSecretReads(1))

// Manually hold the only semaphore slot so ReadSecrets must wait.
err := b.readSecretsLimit.Acquire(t.Context(), 1)
require.NoError(t, err)
defer b.readSecretsLimit.Release(1)

// Call ReadSecrets with an already-cancelled context.
ctx, cancel := context.WithCancel(t.Context())
cancel()

_, err = b.ReadSecrets(ctx, []string{"id1"})
require.ErrorIs(t, err, context.Canceled)
}

// TestReadSecretsNoLimitWhenZero verifies that WithMaxConcurrentSecretReads(0)
// disables the concurrency limit: readSecretsLimit is nil and ReadSecrets
// completes without blocking.
func TestReadSecretsNoLimitWhenZero(t *testing.T) {
mt := &blockingTransport{gate: make(chan struct{})}
close(mt.gate) // unblocked so ReadSecrets returns immediately

b := newTestBulkerWithTransport(t, mt, WithMaxConcurrentSecretReads(0))

require.Nil(t, b.readSecretsLimit)

_, err := b.ReadSecrets(t.Context(), []string{"id1"})
require.NoError(t, err)
}

// TestReadSecretsDefaultConcurrency verifies that a Bulker created without
// WithMaxConcurrentSecretReads initialises readSecretsLimit with the default
// capacity of defaultMaxConcurrentSecretReads (32). It confirms the capacity
// indirectly: after filling all slots via concurrent ReadSecrets calls that
// block in the transport, an additional Acquire with a cancelled context
// returns context.Canceled immediately.
func TestReadSecretsDefaultConcurrency(t *testing.T) {
mt := &blockingTransport{gate: make(chan struct{})}
defer mt.unblock()

// No WithMaxConcurrentSecretReads option → uses defaultMaxConcurrentSecretReads.
b := newTestBulkerWithTransport(t, mt)

require.NotNil(t, b.readSecretsLimit)

// Launch defaultMaxConcurrentSecretReads goroutines, each calling ReadSecrets
// with a single unique secret ID. Each goroutine will acquire one semaphore
// slot and block in the transport, filling all capacity.
var wg sync.WaitGroup
for i := range defaultMaxConcurrentSecretReads {
wg.Go(func() {
_, _ = b.ReadSecrets(t.Context(), []string{fmt.Sprintf("id%d", i)})
})
}

require.Eventually(t, func() bool {
return mt.inFlight.Load() >= int64(defaultMaxConcurrentSecretReads)
}, time.Second, time.Millisecond, "wait for all goroutines to enter the transport")

// The semaphore is now full; a cancelled-context acquire must return immediately.
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := b.readSecretsLimit.Acquire(ctx, 1)
require.ErrorIs(t, err, context.Canceled)

// Unblock all goroutines.
mt.unblock()
wg.Wait()
}
Loading