bulk: add configurable concurrency limit for ReadSecrets - #7567
Conversation
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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
ReadSecretsunconditionally acquires/releasesreadSecretsLimit. 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_readsis 0 (no limit), butparseBulkOptssetsmaxConcurrentSecretReadstodefaultAPIKeyMaxParallel(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_readsis 0 (no limit), butInitDefaultssets 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.
TL;DRBuildkite Remediation
Investigation detailsRoot Cause
Evidence
Verification
Follow-upIf failure persists after committing What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
fe1f3f5 to
b415564
Compare
There was a problem hiding this comment.
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_readsis 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),ReadSecretswill 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) viaInitDefaultsand 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),
ReadSecretsneeds to guard Acquire/Release. As written, it will panic ifreadSecretsLimitis 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)
There was a problem hiding this comment.
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:178
- readSecretsLimit is initialized directly from maxConcurrentSecretReads (default 32) without considering the ES client's MaxConnPerHost budgeting that BulkOptsFromCfg applies to apikeyMaxParallel. If an operator lowers max_conn_per_host (or raises flush_max_pending), apikeyMaxParallel will shrink to leave room for bulk flushes, but secret reads will still allow 32 concurrent requests and can consume the remaining connections, potentially starving bulk flushes and other direct ES calls.
Consider capping the secret-read semaphore at apikeyMaxParallel so it respects the same connection budget.
// 0 means no limit; leave readSecretsLimit nil so ReadSecrets skips the semaphore.
if bopts.maxConcurrentSecretReads > 0 {
b.readSecretsLimit = semaphore.NewWeighted(int64(bopts.maxConcurrentSecretReads))
}
internal/pkg/bulk/opt.go:130
- The doc comment says this limits "concurrent ReadSecrets calls", but the implementation acquires/releases around each ReadSecret HTTP request inside the loop. The limit therefore bounds concurrent secret-read requests (potentially allowing multiple ReadSecrets invocations to overlap), not the number of active ReadSecrets calls. Tightening the wording will avoid confusion for callers of this option.
// 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 {
|
The logic looks correct to me, but why is this a separate ES connection for each goroutine? Shouldn't it multiplex or go through a pool? |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/pkg/bulk/secret_limit_test.go:133
- If this test fails before reaching the final
close(mt.gate), the goroutines blocked inblockingTransport.RoundTripwill never be released, which can hang the test suite. Add a deferred “safe close” formt.gateso goroutines always unblock even on FailNow paths.
mt := &blockingTransport{gate: make(chan struct{})}
// No WithMaxConcurrentSecretReads option → uses defaultMaxConcurrentSecretReads.
b := newTestBulkerWithTransport(t, mt)
The requests do go through a connection pool. The ES client used by the individual requests is shared. That client's HTTP transport pools and reuses connections. The requests remain individual, one per secret, because the Fleet secrets ES API has no bulk-read endpoint. This change limits concurrent in-flight reads to 32. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
internal/pkg/bulk/opt.go:199
- Typo in comment: "slize" → "slice".
e.Int("maxConcurrentSecretReads", o.maxConcurrentSecretReads)
}
// BulkOptsFromCfg transforms config to a slize of BulkOpt
// used to bridge to configuration subsystem
internal/pkg/bulk/engine.go:347
- The semaphore slot is released only on the normal return path. If ReadSecret panics (or is otherwise interrupted before the explicit Release), the slot will be leaked and future ReadSecrets calls can block indefinitely. Wrap the ReadSecret call so Release is deferred after a successful Acquire.
if b.readSecretsLimit != nil {
if err := b.readSecretsLimit.Acquire(ctx, 1); err != nil {
return nil, err
}
}
internal/pkg/bulk/opt.go:129
- The docstring says this limits concurrent "ReadSecrets" calls, but the semaphore actually limits concurrent in-flight secret reads (each ReadSecret HTTP call). Rewording avoids confusion 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.
|
@Mergifyio backport 9.5 9.4 8.19 |
✅ Backports have been createdDetails
Cherry-pick of 5bef901 has failed: To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally |
* 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 * 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. * 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. * bulk: add comment explaining nil readSecretsLimit for zero value * 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". --------- (cherry picked from commit 5bef901) Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…eadSecrets (#7629) * bulk: add configurable concurrency limit for ReadSecrets (#7567) * 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) # Conflicts: # internal/pkg/bulk/engine.go * Fix 8.19 backport conflict --------- Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com>
* 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 * 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. * 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. * bulk: add comment explaining nil readSecretsLimit for zero value * 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". --------- (cherry picked from commit 5bef901) Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
What is the problem this PR solves?
ReadSecretsmakes 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 themax_pending_bulk_dispatchescap 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
ReadSecretsper-checkin call ~24h before OOMs began.How does this PR solve the problem?
Adds a
semaphore.Weighted(readSecretsLimit) toBulker, capping concurrent in-flight secret reads at 32 (matchingapikeyLimit).ReadSecretsacquires 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. SettingreadSecretsLimittonil(by passingWithMaxConcurrentSecretReads(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
apikeyMaxParallelis handled.Files changed
internal/pkg/bulk/engine.go: addreadSecretsLimit *semaphore.WeightedtoBulker; initialize inNewBulkerwhen limit > 0; nil-check acquire/release inReadSecrets; adddefaultMaxConcurrentSecretReads = 32internal/pkg/bulk/opt.go: addmaxConcurrentSecretReadsfield,WithMaxConcurrentSecretReadsBulkOpt, zerolog logginginternal/pkg/bulk/secret_limit_test.go: unit tests covering the concurrency limit, context cancellation while waiting, zero-value (no limit), and default capacityHow to test this PR locally
Design Checklist
Checklist
./changelog/fragmentsusing the changelog toolRelated issues