Skip to content

[9.4](backport #7567) bulk: add configurable concurrency limit for ReadSecrets - #7628

Merged
ycombinator merged 1 commit into
9.4from
mergify/bp/9.4/pr-7567
Aug 12, 2026
Merged

[9.4](backport #7567) bulk: add configurable concurrency limit for ReadSecrets#7628
ycombinator merged 1 commit into
9.4from
mergify/bp/9.4/pr-7567

Conversation

@mergify

@mergify mergify Bot commented Aug 11, 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

* bulk: add max_concurrent_secret_reads config option

ReadSecrets makes direct HTTP calls to ES (bypassing the bulk dispatch
cap) for each secret reference in an agent checkin. Under high
concurrent checkin load this can produce unbounded concurrent ES
connections and memory pressure.

Add a MaxConcurrentSecretReads config field to ServerBulk, backed by a
semaphore.Weighted in the Bulker struct. When set, ReadSecrets acquires
a slot before proceeding and releases it on return, bounding the number
of simultaneous secret reads fleet-server can perform.

Default is 0 (no limit) to preserve existing behaviour. Fleet-controller
can set this via server.bulk.max_concurrent_secret_reads in the project
config secret.

Related: https://github.com/elastic/ingest-dev/issues/8991

* bulk: wire max_concurrent_secret_reads through BulkOpt

Add bulkOptT field, WithMaxConcurrentSecretReads BulkOpt constructor,
zerolog logging, and BulkOptsFromCfg wiring for the new
max_concurrent_secret_reads config option.

Related: https://github.com/elastic/ingest-dev/issues/8991

* bulk: enforce concurrent ReadSecrets limit via semaphore

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: https://github.com/elastic/ingest-dev/issues/8991

* bulk: fix goimports: restore constant alignment, set default to 32

* bulk: set maxConcurrentSecretReads default in parseBulkOpts

* config: default max_concurrent_secret_reads to 32

* bulk: remove spurious extra space in constant comment

* bulk: reuse defaultAPIKeyMaxParallel for readSecretsLimit default

* bulk: reuse defaultAPIKeyMaxParallel for readSecretsLimit default

* bulk: always initialize readSecretsLimit, matching apikeyLimit pattern

* bulk: acquire readSecretsLimit per secret call, matching apikeyLimit granularity

* bulk: change maxConcurrentSecretReads to int, matching apikeyMaxParallel

* config: use named constant and int type for MaxConcurrentSecretReads

* bulk: add comment on readSecretsLimit acquire

* bulk: trim acquire comment

* changelog: add fragment for ReadSecrets concurrency limit

* bulk: add unit tests for ReadSecrets concurrency limit

* fix: reorder imports in engine.go and simplify changelog fragment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* bulk: fix zero-value readSecretsLimit and add own default constant

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>

* bulk: remove max_concurrent_secret_reads from user-facing config

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>

* bulk: add comment explaining nil readSecretsLimit for zero value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add X-Elastic-Product header to blockingTransport responses

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>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 5bef901)
@mergify mergify Bot added the backport label Aug 11, 2026
@mergify
mergify Bot requested a review from a team as a code owner August 11, 2026 23:08
@mergify
mergify Bot requested review from blakerouse and swiatekm August 11, 2026 23:08
@mergify mergify Bot added the backport label Aug 11, 2026
@ycombinator
ycombinator enabled auto-merge (squash) August 11, 2026 23:47
@ycombinator
ycombinator merged commit 8c51745 into 9.4 Aug 12, 2026
13 checks passed
@ycombinator
ycombinator deleted the mergify/bp/9.4/pr-7567 branch August 12, 2026 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant