Skip to content

bulk: add configurable concurrency limit for ReadSecrets - #7567

Open
ycombinator wants to merge 23 commits into
elastic:mainfrom
ycombinator:rate-limit-read-secrets
Open

bulk: add configurable concurrency limit for ReadSecrets#7567
ycombinator wants to merge 23 commits into
elastic:mainfrom
ycombinator:rate-limit-read-secrets

Conversation

@ycombinator

@ycombinator ycombinator commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What is the problem this PR solves?

ReadSecrets makes direct HTTP calls to the ES Fleet secrets API (one per secret reference per agent checkin) without going through the bulk dispatch queue. This means it is not subject to the max_pending_bulk_dispatches cap introduced in #6751. Under high concurrent checkin load — as seen in large serverless projects — each checkin goroutine issues an independent ES connection, causing unbounded concurrent ES connections and additional memory pressure (goroutines, HTTP buffers, response allocations).

This was observed as a contributing factor in an OOM incident for a large production serverless security project. PR #7416 added the ReadSecrets per-checkin call ~24h before OOMs began.

How does this PR solve the problem?

Adds a semaphore.Weighted (readSecretsLimit) to Bulker, capping concurrent in-flight secret reads at 32 (matching apikeyLimit). ReadSecrets acquires a slot before each ES call and releases it immediately after — callers that arrive when all slots are taken block until one is free or their context is cancelled. Setting readSecretsLimit to nil (by passing WithMaxConcurrentSecretReads(0)) disables the cap entirely; the default is always 32.

The limit is intentionally not exposed as a user-facing config option, consistent with how apikeyMaxParallel is handled.

Files changed

  • internal/pkg/bulk/engine.go: add readSecretsLimit *semaphore.Weighted to Bulker; initialize in NewBulker when limit > 0; nil-check acquire/release in ReadSecrets; add defaultMaxConcurrentSecretReads = 32
  • internal/pkg/bulk/opt.go: add maxConcurrentSecretReads field, WithMaxConcurrentSecretReads BulkOpt, zerolog logging
  • internal/pkg/bulk/secret_limit_test.go: unit tests covering the concurrency limit, context cancellation while waiting, zero-value (no limit), and default capacity

How to test this PR locally

go test -v -count=1 -run=TestReadSecrets ./internal/pkg/bulk

Design Checklist

  • I have ensured my design is stateless and will work when multiple fleet-server instances are behind a load balancer.
  • I have or intend to scale test my changes, ensuring it will work reliably with 100K+ agents connected.
  • I have included fail safe mechanisms to limit the load on fleet-server: rate limiting, circuit breakers, caching, load shedding, etc.

Checklist

  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have made corresponding change to the default configuration files
  • I have added tests that prove my fix is effective or that my feature works
  • I have added an entry in ./changelog/fragments using the changelog tool

Related issues

@ycombinator
ycombinator requested a review from a team as a code owner August 6, 2026 16:19
@mergify

mergify Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
To fixup this pull request, you need to add the backport labels for the needed
branches, such as:

  • backport-./d./d is the label to automatically backport to the 8./d branch. /d is the digit
  • backport-active-all is the label that automatically backports to all active branches.
  • backport-active-8 is the label that automatically backports to all active minor branches for the 8 major.
  • backport-active-9 is the label that automatically backports to all active minor branches for the 9 major.

@ycombinator
ycombinator requested review from belimawr, blakerouse and swiatekm and removed request for lorienhu and samuelvl August 6, 2026 16:41
@ycombinator ycombinator added the backport-active-all Automated backport with mergify to all the active branches label Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 6, 2026 16:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings August 6, 2026 23:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

internal/pkg/bulk/engine.go:337

  • ReadSecrets unconditionally acquires/releases readSecretsLimit. If the limit is disabled by configuration (e.g., max=0), this should be a no-op; additionally, the release should only happen when an acquire occurred.
		if err := b.readSecretsLimit.Acquire(ctx, 1); err != nil {
			return nil, err
		}
		val, err := ReadSecret(ctx, esClient, id)
		b.readSecretsLimit.Release(1)

internal/pkg/bulk/opt.go:175

  • PR description says the default for max_concurrent_secret_reads is 0 (no limit), but parseBulkOpts sets maxConcurrentSecretReads to defaultAPIKeyMaxParallel (32). This changes behavior by default and contradicts the option's "0 means no limit" semantics unless explicitly configured.
		apikeyMaxParallel:        defaultAPIKeyMaxParallel,
		blockQueueSz:             defaultBlockQueueSz,
		apikeyMaxReqSize:         defaultApikeyMaxReqSize,
		maxPendingBulkDispatches: defaultMaxPendingBulkDispatches,
		maxConcurrentSecretReads: defaultAPIKeyMaxParallel,

internal/pkg/bulk/secret_limit_test.go:120

  • This test asserts an implicit default concurrency of 32, but the PR description states the default should be 0 (no limit). To avoid baking in a default and to keep the test valid regardless of the chosen default, configure the limit explicitly in the test.
// TestReadSecretsDefaultConcurrency verifies that a Bulker created without
// WithMaxConcurrentSecretReads initialises readSecretsLimit with the default
// capacity of defaultAPIKeyMaxParallel (32). It confirms the capacity
// indirectly: after filling all 32 slots via concurrent ReadSecrets calls
// that block in the transport, an additional Acquire with a cancelled context

internal/pkg/config/input.go:49

  • PR description says the default for server.bulk.max_concurrent_secret_reads is 0 (no limit), but InitDefaults sets it to 32 (and introduces a constant for that default). This makes the new limit enabled by default and conflicts with the stated "preserving existing behaviour" default.
// defaultMaxConcurrentSecretReads matches defaultAPIKeyMaxParallel in the bulk package.
const defaultMaxConcurrentSecretReads = 32

changelog/fragments/1786037058-rate-limit-read-secrets.yaml:13

  • The changelog fragment states the new option defaults to 32, but the PR description says the default is 0 (no limit). This should match the actual default behavior to avoid misleading operators.
  A new configuration option, server.bulk.max_concurrent_secret_reads
  (default 32), limits how many secret reads can be in-flight at once.
  Excess reads wait until a slot is available.

Comment thread internal/pkg/bulk/engine.go
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Buildkite Run check-ci failed because CI auto-fix checks produced unstaged changes, so the NoChanges guard failed at git update-index --refresh. This is a CI hygiene failure (format/fix drift), not a runtime logic failure.

Remediation

  • Run mage check:ci locally on rate-limit-read-secrets, then commit all resulting changes (including internal/pkg/bulk/engine.go and internal/pkg/bulk/secret_limit_test.go).
  • Push and re-run Buildkite; this step should pass once the tree remains clean after Check.Imports/Check.Fix.
Investigation details

Root Cause

Run check-ci executes .buildkite/scripts/check_ci.sh, which calls mage check:ci (.buildkite/scripts/check_ci.sh:13). check:ci runs Generate, Check.Imports, Check.Fix, Check.Headers, Check.Notice, then Check.NoChanges (magefile.go:625-628).

Check.NoChanges enforces a clean tree via git update-index --refresh and git diff-index --exit-code HEAD -- (magefile.go:596-616). In this build, those checks detected file rewrites still needed:

  • internal/pkg/bulk/engine.go (alignment/formatting around NewBulker, e.g. readSecretsLimit initializer at ~L167 in PR head)
  • internal/pkg/bulk/secret_limit_test.go (TestReadSecretsDefaultConcurrency, loop around ~L125 where CI wants wg.Go(...) style rewrite)

Evidence

diff --git a/internal/pkg/bulk/secret_limit_test.go b/internal/pkg/bulk/secret_limit_test.go
@@ -125,11 +125,9 @@ func TestReadSecretsDefaultConcurrency(t *testing.T) {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
			_, _ = b.ReadSecrets(context.Background(), []string{fmt.Sprintf("id%d", i)})
-		}()
+		})
internal/pkg/bulk/secret_limit_test.go: needs update
Error: git update-index failure: running "git update-index --refresh" failed with exit code 1

Verification

  • Not run locally in this detective workflow; analysis is based on the provided Buildkite failure artifacts and PR metadata.
  • Checked for matching flaky-test issue signal (label:flaky-test + git update-index) and found none.

Follow-up

If failure persists after committing mage check:ci output, capture the next Run check-ci log; it will likely be a new gate/failure signature rather than this one.


What is this? | From workflow: PR Buildkite Detective

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

Copilot AI review requested due to automatic review settings August 7, 2026 14:11
@ycombinator
ycombinator force-pushed the rate-limit-read-secrets branch from fe1f3f5 to b415564 Compare August 7, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

internal/pkg/bulk/engine.go:170

  • max_concurrent_secret_reads is documented as "0 means no limit", but NewBulker always creates a weighted semaphore with that value. If the config/opt sets 0 (or a negative value), ReadSecrets will block forever on Acquire (or potentially panic), effectively breaking secret resolution.

Consider treating values <= 0 as "unlimited" by leaving readSecretsLimit nil (or otherwise bypassing Acquire/Release), matching the existing apikeyLimit pattern of only limiting when configured.

		blkPool:               sync.Pool{New: poolFunc},
		flushBufPool:          sync.Pool{New: func() any { return new(bytes.Buffer) }},
		apikeyLimit:           semaphore.NewWeighted(int64(bopts.apikeyMaxParallel)),
		readSecretsLimit:      semaphore.NewWeighted(int64(bopts.maxConcurrentSecretReads)),
		tracer:                tracer,

internal/pkg/config/input.go:65

  • PR description says the default should be 0 (no limit) to preserve existing behavior, but the code and changelog set a non-zero default (32) via InitDefaults and the bulk option defaults.

Please align the default behavior and docs (PR description, changelog, and bulk option defaults) so operators don’t accidentally throttle or, if they set 0 expecting "unlimited", hit a deadlock/panic depending on implementation.

// defaultMaxConcurrentSecretReads matches defaultAPIKeyMaxParallel in the bulk package.
const defaultMaxConcurrentSecretReads = 32

type ServerBulk struct {
	FlushInterval            time.Duration `config:"flush_interval"`
	FlushThresholdCount      int           `config:"flush_threshold_cnt"`
	FlushThresholdSize       int           `config:"flush_threshold_size"`
	FlushMaxPending          int           `config:"flush_max_pending"`
	MaxPendingBulkDispatches int64         `config:"max_pending_bulk_dispatches"`
	MaxConcurrentSecretReads int           `config:"max_concurrent_secret_reads"`
}

func (c *ServerBulk) InitDefaults() {
	c.FlushInterval = 250 * time.Millisecond
	c.FlushThresholdCount = 2048
	c.FlushThresholdSize = 1024 * 1024
	c.FlushMaxPending = 8
	c.MaxConcurrentSecretReads = defaultMaxConcurrentSecretReads
}

internal/pkg/bulk/engine.go:339

  • After making the semaphore optional (nil when unlimited), ReadSecrets needs to guard Acquire/Release. As written, it will panic if readSecretsLimit is nil, and it also makes it harder to reason about the intended "0 means no limit" behavior.
		if err := b.readSecretsLimit.Acquire(ctx, 1); err != nil {
			return nil, err
		}
		val, err := ReadSecret(ctx, esClient, id)
		b.readSecretsLimit.Release(1)

Comment on lines +126 to +133
var wg sync.WaitGroup
for i := range defaultAPIKeyMaxParallel {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = b.ReadSecrets(context.Background(), []string{fmt.Sprintf("id%d", i)})
}()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does. We are using Go v1.26.5.

Copilot AI review requested due to automatic review settings August 7, 2026 14:34
ycombinator and others added 21 commits August 7, 2026 08:16
Add bulkOptT field, WithMaxConcurrentSecretReads BulkOpt constructor,
zerolog logging, and BulkOptsFromCfg wiring for the new
max_concurrent_secret_reads config option.

Related: elastic/ingest-dev#8991
ReadSecrets currently bypasses the bulk dispatch cap and makes unbounded
concurrent direct HTTP calls to the ES Fleet secrets API. Under the high
checkin concurrency seen in large serverless projects this causes both
memory pressure and ES connection exhaustion.

Add a readSecretsLimit *semaphore.Weighted to Bulker, initialized from
the new max_concurrent_secret_reads config option (0 = no limit).
ReadSecrets acquires one slot before performing secret reads and releases
it on return, matching the pattern already used by apikeyLimit.

Related: elastic/ingest-dev#8991
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When maxConcurrentSecretReads is 0 (documented as "no limit"),
NewBulker was creating a zero-capacity semaphore, causing all
ReadSecrets calls to block until context cancellation instead of
running without a limit.

Fix by only initialising readSecretsLimit when the value is > 0, and
nil-checking before Acquire/Release in ReadSecrets.

Also introduce defaultMaxConcurrentSecretReads as its own constant
(32) rather than reusing defaultAPIKeyMaxParallel, so the two limits
can evolve independently. Drop the stale comment in config/input.go
that referenced the apikey constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apikeyMaxParallel is not user-configurable, so secret reads should
follow the same pattern. Remove MaxConcurrentSecretReads from
ServerBulk, drop the corresponding config constant, and remove the
WithMaxConcurrentSecretReads wiring from parseBulkOptsFromConfig.
The limit is now always defaultMaxConcurrentSecretReads (32).

Update the changelog to drop the mention of a config option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
go-elasticsearch performs a product check on the first request and
requires the X-Elastic-Product: Elasticsearch response header. Without
it, ReadSecrets calls that actually reach the transport fail with
"the client noticed that the server is not Elasticsearch".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 7, 2026 15:16
@ycombinator
ycombinator force-pushed the rate-limit-read-secrets branch from 616ffa4 to c0d5581 Compare August 7, 2026 15:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/pkg/bulk/engine.go:22

  • The import block is not goimports-compliant: github.com/elastic/go-ucfg is split into its own group and the third-party imports are fragmented around the local github.com/elastic/fleet-server/... imports. This will likely fail the repo’s import formatting checks; regroup third-party imports together and keep local imports in their own group.
	"time"

	"github.com/elastic/go-ucfg"

	"github.com/elastic/fleet-server/v7/internal/pkg/apikey"

internal/pkg/bulk/opt.go:130

  • The doc comment says this limits “concurrent ReadSecrets calls”, but the code actually limits concurrent in-flight secret read HTTP requests performed within ReadSecrets. Tweaking the wording would make the behavior clearer for callers.
// 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 {

go fix rewrites the wg.Add(1)/go func()/defer wg.Done() pattern
to wg.Go (available since Go 1.25).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 7, 2026 15:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

internal/pkg/bulk/secret_limit_test.go:153

  • This is another unbounded busy-spin loop with no timeout. If mt.inFlight never reaches defaultMaxConcurrentSecretReads, the test will hang indefinitely and consume CPU. Consider switching to a bounded wait (e.g., require.Eventually) or using explicit synchronization (channels) instead of polling.
	// Spin until all slots are occupied.
	for mt.inFlight.Load() < int64(defaultMaxConcurrentSecretReads) {
		// wait for all goroutines to enter the transport
	}

internal/pkg/bulk/engine.go:34

  • The import grouping/order here doesn’t match the convention used elsewhere in internal/pkg/bulk (e.g. internal/pkg/bulk/opBulk.go:7-20, where third-party imports are grouped together and local github.com/elastic/fleet-server/... imports are in a separate group). This is likely to fail goimports/mage check:imports.
	"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/rs/zerolog"
	"go.elastic.co/apm/v2"
	"golang.org/x/sync/semaphore"

	"github.com/elastic/go-elasticsearch/v8"
	"github.com/elastic/go-elasticsearch/v8/esapi"
)

internal/pkg/bulk/secret_limit_test.go:75

  • This is an unbounded busy-spin loop with no timeout. If the request never enters the transport (e.g., unexpected error path), the test will hang indefinitely and burn CPU in CI. Prefer a bounded wait (e.g., require.Eventually with a short timeout) to fail fast and avoid tight spinning.

This issue also appears on line 150 of the same file.

	// Spin until at least one request is in the transport.
	for mt.inFlight.Load() < 1 {
		// wait for the first goroutine to enter the transport
	}

Comment on lines +143 to +148
var wg sync.WaitGroup
for i := range defaultMaxConcurrentSecretReads {
wg.Go(func() {
_, _ = b.ReadSecrets(context.Background(), []string{fmt.Sprintf("id%d", i)})
})
}

@belimawr belimawr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the busy wait are blocking.

One last nit pick (feel free to ignore it): you could use t.Context() instead of context.Background().

Comment on lines +62 to +79
wg.Add(2)
go func() {
defer wg.Done()
_, errs[0] = b.ReadSecrets(context.Background(), []string{"id1"})
}()
go func() {
defer wg.Done()
_, errs[1] = b.ReadSecrets(context.Background(), []string{"id2"})
}()

// Spin until at least one request is in the transport.
for mt.inFlight.Load() < 1 {
// 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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the code can be modernised with wg.Go and use require.Eventually instead of a busy wait:

Suggested change
wg.Add(2)
go func() {
defer wg.Done()
_, errs[0] = b.ReadSecrets(context.Background(), []string{"id1"})
}()
go func() {
defer wg.Done()
_, errs[1] = b.ReadSecrets(context.Background(), []string{"id2"})
}()
// Spin until at least one request is in the transport.
for mt.inFlight.Load() < 1 {
// 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())
wg.Go(func() { _, errs[0] = b.ReadSecrets(t.Context(), []string{"id1"}) })
wg.Go(func() { _, errs[0] = b.ReadSecrets(t.Context(), []string{"id2"}) })
// With semaphore capacity 1, the second goroutine is blocked on Acquire
// and cannot have entered the transport yet.
require.Eventually(
t,
func() bool { return mt.inFlight.Load() == 1 },
time.Second,
10*time.Millisecond,
"there are one or less requests in flight")

Comment on lines +150 to +153
// Spin until all slots are occupied.
for mt.inFlight.Load() < int64(defaultMaxConcurrentSecretReads) {
// wait for all goroutines to enter the transport
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That can be replaced by a require.Eventually

Suggested change
// Spin until all slots are occupied.
for mt.inFlight.Load() < int64(defaultMaxConcurrentSecretReads) {
// wait for all goroutines to enter the transport
}
require.Eventually(
t,
func() bool { return mt.inFlight.Load() == defaultMaxConcurrentSecretReads },
time.Second,
10*time.Millisecond,
"expecting %d in flight requests to block",
defaultMaxConcurrentSecretReads,
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-active-all Automated backport with mergify to all the active branches

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants