feat: batch enrollment FindAgent searches with pre-refresh dedup - #7662
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses “ghost agent” document creation during high-concurrency enrollments by introducing a dedicated enrollment FindAgent search batching path that performs an explicit index refresh and per-batch deduplication keyed by enrollment_id.
Changes:
- Added a dedicated bulker queue (
kQueueEnrollSearch) for enrollment searches with independent flush interval/threshold configuration. - Introduced
bulk.WithDedupeKey()and DL plumbing (dl.WithBulkOpts) to route enrollmentFindAgentsearches through the new queue. - Added
ErrEnrollDuplicateand API error mapping to return HTTP 429 for duplicate-in-batch enrollment retries.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/pkg/bulk/block.go | Adds per-search dedupe/refresh metadata to bulk queue items. |
| internal/pkg/bulk/queue.go | Introduces a new queue type for enrollment search batching. |
| internal/pkg/bulk/opt.go | Adds search-level dedupe option and config-bridge options for the new queue’s flush behavior. |
| internal/pkg/bulk/engine.go | Routes dedupe-keyed searches to the new queue and adds independent flush timer/threshold handling. |
| internal/pkg/bulk/opSearch.go | Implements flushEnrollSearch() with refresh + dedupe + msearch dispatch. |
| internal/pkg/config/input.go | Adds enroll_batcher config struct for enrollment search batching. |
| internal/pkg/dl/common.go | Adds WithBulkOpts() to pass bulk.Opt values through DL helpers. |
| internal/pkg/dl/search.go | Threads bulk.Opt variadic options into SearchWithOneParam(). |
| internal/pkg/dl/agent.go | Passes DL bulk opts through FindAgent() to the bulker search call. |
| internal/pkg/api/handleEnroll.go | Uses dedupe-keyed enrollment search and returns on ErrEnrollDuplicate. |
| internal/pkg/api/error.go | Maps ErrEnrollDuplicate to HTTP 429. |
Suppressed comments (1)
internal/pkg/config/input.go:76
- ServerBulk.InitDefaults() sets defaults for the main bulk queue but does not initialize the new EnrollBatcher defaults. As a result, BulkOptsFromCfg() can override the bulker's built-in defaults with zero values (e.g. enrollFlushInterval=0, threshold=0) when enroll_batcher is omitted from config, changing runtime behavior unexpectedly.
func (c *ServerBulk) InitDefaults() {
c.FlushInterval = 250 * time.Millisecond
c.FlushThresholdCount = 2048
c.FlushThresholdSize = 1024 * 1024
c.FlushMaxPending = 8
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (4)
internal/pkg/config/input.go:75
- ServerBulk.InitDefaults() doesn’t initialize the new EnrollBulker defaults. When config doesn’t specify inputs[].server.bulk.enroll_bulker.*, zero values will flow into BulkOptsFromCfg and override the bulker’s internal defaults (0s interval / 0 threshold), causing immediate flush/refresh behavior.
func (c *ServerBulk) InitDefaults() {
c.FlushInterval = 250 * time.Millisecond
c.FlushThresholdCount = 2048
c.FlushThresholdSize = 1024 * 1024
c.FlushMaxPending = 8
internal/pkg/bulk/opSearch.go:345
- Duplicate enrollment requests are responded to with ErrEnrollDuplicate, but the response omits idx (can break callers that rely on idx), and the bulkT for duplicates is never returned to the pool (Bulker.Search only freeBlk()s on success). Also, if the canonical search returns a per-item error, duplicates should get that same error instead of ErrEnrollDuplicate to avoid futile retries.
for i, n := range canonicals {
response := &blk.Responses[i]
select {
case n.ch <- respT{err: response.deriveError(), idx: n.idx, data: response}:
default:
panic("Unexpected blocked response channel on flushEnrollSearch canonical")
}
for _, dupe := range dupesByKey[n.dedupeKey] {
select {
case dupe.ch <- respT{err: ErrEnrollDuplicate}:
internal/pkg/bulk/opSearch.go:284
- refreshResp.Body is closed before calling refreshResp.String() in the IsError() path. Response.String() reads the body, so this can lead to empty/incorrect error details and may break diagnostics.
if refreshResp.Body != nil {
refreshResp.Body.Close()
}
if refreshResp.IsError() {
err = fmt.Errorf("enroll search refresh failed: %s", refreshResp.String())
internal/pkg/bulk/opSearch.go:251
- This introduces a new queue type (kQueueEnrollSearch) with refresh-before-search ordering and concurrent request de-duplication. There are existing unit tests in internal/pkg/bulk, but no coverage for this new path; adding tests would help ensure dedupe behavior (canonical vs duplicate), refresh call ordering, and correct response fanout.
// flushEnrollSearch handles the kQueueEnrollSearch queue. It:
// 1. Groups items by dedupeKey — first occurrence is canonical, the rest are duplicates.
// 2. Refreshes the index named by the first item's refreshIndex.
// 3. Sends an msearch containing only the canonical items.
// 4. Dispatches results to canonical items; sends ErrEnrollDuplicate to duplicates.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/pkg/config/input.go:76
- ServerBulk.InitDefaults initializes the existing bulk defaults but does not initialize the new EnrollBulker nested config. Because BulkOptsFromCfg always applies EnrollBulker.FlushInterval/FlushThresholdCount, these will be zero unless defaults are set, resulting in an enroll search flush interval/threshold of 0 (immediate timer fires / flush every item).
func (c *ServerBulk) InitDefaults() {
c.FlushInterval = 250 * time.Millisecond
c.FlushThresholdCount = 2048
c.FlushThresholdSize = 1024 * 1024
c.FlushMaxPending = 8
}
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/pkg/bulk/enroll_search_integration_test.go:53
for i := range numConcurrentdoes not compile becauserangecan't be used over anint. Iterate over the slice (or use an index loop) so the goroutines are started as intended.
for i := range numConcurrent {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/pkg/bulk/opSearch.go:305
- The refresh error path closes the response body before checking
IsError()and then builds an error fromrefreshResp.String(). That discards the structured Elasticsearch error body (and may also break existing callers that look for specific substrings like "no such index"). Consider parsing the error viaparseError(like the msearch path) before closing the body.
if refreshResp.Body != nil {
refreshResp.Body.Close()
}
if refreshResp.IsError() {
err = fmt.Errorf("enroll search refresh failed: %s", refreshResp.String())
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 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (4)
internal/pkg/bulk/opSearch.go:336
- Same as above for the msearch request: failing the queue locally and returning nil prevents flushQueue from recording the flush error at the queue level. Returning the error here is safe because no per-item responses have been sent yet.
res, err := msearchReq.Do(ctx, b.es)
if err != nil {
failQueue(queue, err)
return nil
}
if res.Body != nil {
defer res.Body.Close()
}
if res.IsError() {
err = parseError(res, zerolog.Ctx(ctx))
failQueue(queue, err)
return nil
}
internal/pkg/bulk/opSearch.go:287
- refreshIndices is built only from canonical items. If multiple requests share the same dedupeKey but provide different refreshIndex values (or if the oldest request has an empty refreshIndex), the pre-refresh step may not include all indices present in the batch, contradicting the PR behavior described and potentially reintroducing NRT-visibility races.
// Refresh all unique indices in the batch before searching so retries see the most recent writes.
refreshIndices := make(map[string]struct{})
for _, n := range canonicals {
if n.refreshIndex != "" {
refreshIndices[n.refreshIndex] = struct{}{}
}
}
internal/pkg/bulk/opSearch.go:314
- bufSz uses queue.pending, which includes bytes from duplicate requests even though the msearch body is built from canonicals only. Under high duplicate rates this can cause large, unnecessary buffer growth and memory pressure.
// Build msearch body from canonical items only.
const kRoughEstimatePerItem = 256
bufSz := max(len(canonicals)*kRoughEstimatePerItem, queue.pending)
buf := b.flushBufPool.Get().(*bytes.Buffer) //nolint:errcheck // we control what is placed in the pool
buf.Reset()
buf.Grow(bufSz)
defer b.flushBufPool.Put(buf)
internal/pkg/bulk/opSearch.go:305
- flushEnrollSearch calls failQueue(...) and then returns nil on refresh/msearch request failures. This makes flushQueue log the flush as successful (err=nil) and skips its centralized error capture (APM + structured logs). Since no responses have been sent yet on these paths, prefer returning the error and letting flushQueue handle failQueue/error reporting consistently.
This issue also appears on line 324 of the same file.
refreshResp, err := esapi.IndicesRefreshRequest{Index: idxSlice}.Do(ctx, b.es)
if err != nil {
failQueue(queue, err)
return nil
}
if refreshResp.Body != nil {
refreshResp.Body.Close()
}
if refreshResp.IsError() {
err = fmt.Errorf("enroll search refresh failed: %s", refreshResp.String())
failQueue(queue, err)
return nil
}
blakerouse
left a comment
There was a problem hiding this comment.
Overall I like this. Just a few comments.
What I was really looking for was that a new request didn't piggy-back off an already refresh call. It does not, it waits for the next one, which is the correct behavior.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
internal/pkg/bulk/opSearch.go:287
- The pre-refresh step only collects
refreshIndexvalues fromcanonicals. If a batch contains items with arefreshIndexset only on a duplicate (or if future callers mix refresh indices per key), that index will not be refreshed even though it is present in the batch. This contradicts the stated behavior (“refresh all unique refreshIndex values in the batch”) and can reintroduce the near-real-time visibility issue for some requests.
Consider collecting refresh indices from all queued items instead of canonicals.
// Refresh all unique indices in the batch before searching so retries see the most recent writes.
refreshIndices := make(map[string]struct{})
for _, n := range canonicals {
if n.refreshIndex != "" {
refreshIndices[n.refreshIndex] = struct{}{}
internal/pkg/bulk/opSearch.go:306
- The refresh response body is closed before checking
IsError()and before building an error message fromrefreshResp.String(). Depending on the underlyingResponse.String()/error parsing, closing early can discard useful error details (andString()may not be able to read the body at all).
Defer closing the body until after error handling, and prefer parseError for consistency with the msearch path.
refreshResp, err := esapi.IndicesRefreshRequest{Index: idxSlice}.Do(ctx, b.es)
if err != nil {
failQueue(queue, err)
return nil
}
if refreshResp.Body != nil {
refreshResp.Body.Close()
}
if refreshResp.IsError() {
err = fmt.Errorf("enroll search refresh failed: %s", refreshResp.String())
internal/pkg/bulk/opSearch.go:314
bufSzusesqueue.pending, but this queue's pending byte count includes duplicates that are not written into the msearch body (only canonicals are). With a large number of duplicates, this can cause unnecessary large buffer growth and transient memory spikes.
Compute the pending byte count from canonicals (or sum their buffer lengths) when sizing the flush buffer.
// Build msearch body from canonical items only.
const kRoughEstimatePerItem = 256
bufSz := max(len(canonicals)*kRoughEstimatePerItem, queue.pending)
buf := b.flushBufPool.Get().(*bytes.Buffer) //nolint:errcheck // we control what is placed in the pool
buf.Reset()
buf.Grow(bufSz)
defer b.flushBufPool.Put(buf)
|
@Mergifyio backport 9.5 9.4 8.19 |
✅ Backports have been createdDetails
Cherry-pick of 3890bbb 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
Cherry-pick of 3890bbb 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 |
…) (#7664) * feat: batch enrollment FindAgent searches with pre-refresh dedup via kQueueEnrollSearch Adds a dedicated kQueueEnrollSearch bulker queue that prevents duplicate agent documents caused by concurrent enrollment retries with the same enrollment_id. The queue: - Flushes independently from other queues (default: every 1s or 50 items) - Calls POST .fleet-agents/_refresh before executing the msearch batch, ensuring retries see agent documents written by earlier requests - De-dupes concurrent requests with the same enrollment_id: the first (canonical) request gets the search result; duplicates receive ErrEnrollDuplicate (HTTP 429) so the agent retries after backoff * chore: add changelog fragment for kQueueEnrollSearch enrollment dedup * refactor: rename EnrollBatcher → EnrollBulker in config and opts * fix: correct LIFO→FIFO order and refresh all unique indices in flushEnrollSearch Address two Copilot review findings in flushEnrollSearch: 1. The bulker queue is LIFO (head = newest item), so iterating queue.head directly made the *newest* request canonical, not the oldest. Collect all items into a slice and reverse it so the oldest request is canonical for each dedupeKey (FIFO semantics). 2. Only canonicals[0].refreshIndex was refreshed, silently skipping any additional indices present in the batch. Collect all unique refreshIndex values from every canonical and refresh them all. * fix: propagate canonical search error to duplicates in flushEnrollSearch When a canonical request's msearch returns an ES error, duplicates were receiving ErrEnrollDuplicate, which callers treat as a benign retry signal. This silently hid the real failure. Now: if deriveError() on a canonical response is non-nil, that error is forwarded to all duplicates sharing the same dedupeKey, so callers surface the actual operational failure rather than retrying indefinitely under the impression it was just a duplicate. ErrEnrollDuplicate is still sent when the canonical search succeeded. * test: add integration test for enrollment search dedup (kQueueEnrollSearch) Fires numConcurrent goroutines all calling bulker.Search with the same dedupeKey against a test index. Sets enrollFlushThresholdCount above the concurrent count so all requests land in one batch and are flushed by the timer. Asserts exactly one canonical result and numConcurrent-1 ErrEnrollDuplicate responses. * fix: address golangci-lint failures (nolintlint, goimports) - Add explanation to nolint:errcheck directive in flushEnrollSearch (nolintlint requires a reason comment) - Realign ServerBulk struct fields after adding the wider ServerBulkEnrollBulker type (goimports formatting) * fix: call EnrollBulker.InitDefaults() from ServerBulk.InitDefaults() ucfg calls each nested struct's InitDefaults() automatically when loading config from a file, so the EnrollBulker defaults (1s, 50) were correctly applied at runtime. However, tests that construct expected configs by calling InitDefaults() manually through the chain were getting zero values for EnrollBulker, causing TestConfig to fail. * fix: eliminate data race in flushEnrollSearch dispatch loop After sending on n.ch, the receiving goroutine (Search) immediately calls freeBlk(n) -> reset(), which zeroes all bulkT fields including dedupeKey. Reading n.dedupeKey after the channel send to look up duplicate waiters was therefore a data race detected by -race. Fix: capture n.dedupeKey into a local variable before the send, so the map lookup uses a stack-allocated copy that is not subject to concurrent modification. This matches the pattern already used in flushSearch, which saves n.next before its channel send with the comment "n is invalid immediately on channel send". * fix: rename enroll_bulker config key to enroll, simplify changelog - inputs[].server.bulk.enroll_bulker -> inputs[].server.bulk.enroll (per blakerouse feedback: _bulker suffix is redundant in this context) - Rewrite changelog to focus on the user-visible problem (ghost agents) rather than implementation details --------- (cherry picked from commit 3890bbb) Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolve conflicts in block.go, engine.go, opt.go, and dl/search.go by keeping pointer semantics for spanLink (consistent with 9.4 codebase) while adding the new DedupeKey/RefreshIndex fields and WithDedupeKey opt. Also adapt flushEnrollSearch to use a local buffer instead of flushBufPool which does not exist in 9.4. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolve conflicts in block.go, engine.go, opt.go, and dl/search.go by keeping pointer semantics for spanLink (consistent with 8.19 codebase) while adding the new DedupeKey/RefreshIndex fields and WithDedupeKey opt. Also adapt flushEnrollSearch to use a local buffer instead of flushBufPool which does not exist in 8.19. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… pre-refresh dedup (#7666) * feat: batch enrollment FindAgent searches with pre-refresh dedup (#7662) * feat: batch enrollment FindAgent searches with pre-refresh dedup via kQueueEnrollSearch Adds a dedicated kQueueEnrollSearch bulker queue that prevents duplicate agent documents caused by concurrent enrollment retries with the same enrollment_id. The queue: - Flushes independently from other queues (default: every 1s or 50 items) - Calls POST .fleet-agents/_refresh before executing the msearch batch, ensuring retries see agent documents written by earlier requests - De-dupes concurrent requests with the same enrollment_id: the first (canonical) request gets the search result; duplicates receive ErrEnrollDuplicate (HTTP 429) so the agent retries after backoff Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changelog fragment for kQueueEnrollSearch enrollment dedup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: rename EnrollBatcher → EnrollBulker in config and opts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: correct LIFO→FIFO order and refresh all unique indices in flushEnrollSearch Address two Copilot review findings in flushEnrollSearch: 1. The bulker queue is LIFO (head = newest item), so iterating queue.head directly made the *newest* request canonical, not the oldest. Collect all items into a slice and reverse it so the oldest request is canonical for each dedupeKey (FIFO semantics). 2. Only canonicals[0].refreshIndex was refreshed, silently skipping any additional indices present in the batch. Collect all unique refreshIndex values from every canonical and refresh them all. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: propagate canonical search error to duplicates in flushEnrollSearch When a canonical request's msearch returns an ES error, duplicates were receiving ErrEnrollDuplicate, which callers treat as a benign retry signal. This silently hid the real failure. Now: if deriveError() on a canonical response is non-nil, that error is forwarded to all duplicates sharing the same dedupeKey, so callers surface the actual operational failure rather than retrying indefinitely under the impression it was just a duplicate. ErrEnrollDuplicate is still sent when the canonical search succeeded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add integration test for enrollment search dedup (kQueueEnrollSearch) Fires numConcurrent goroutines all calling bulker.Search with the same dedupeKey against a test index. Sets enrollFlushThresholdCount above the concurrent count so all requests land in one batch and are flushed by the timer. Asserts exactly one canonical result and numConcurrent-1 ErrEnrollDuplicate responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address golangci-lint failures (nolintlint, goimports) - Add explanation to nolint:errcheck directive in flushEnrollSearch (nolintlint requires a reason comment) - Realign ServerBulk struct fields after adding the wider ServerBulkEnrollBulker type (goimports formatting) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: call EnrollBulker.InitDefaults() from ServerBulk.InitDefaults() ucfg calls each nested struct's InitDefaults() automatically when loading config from a file, so the EnrollBulker defaults (1s, 50) were correctly applied at runtime. However, tests that construct expected configs by calling InitDefaults() manually through the chain were getting zero values for EnrollBulker, causing TestConfig to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: eliminate data race in flushEnrollSearch dispatch loop After sending on n.ch, the receiving goroutine (Search) immediately calls freeBlk(n) -> reset(), which zeroes all bulkT fields including dedupeKey. Reading n.dedupeKey after the channel send to look up duplicate waiters was therefore a data race detected by -race. Fix: capture n.dedupeKey into a local variable before the send, so the map lookup uses a stack-allocated copy that is not subject to concurrent modification. This matches the pattern already used in flushSearch, which saves n.next before its channel send with the comment "n is invalid immediately on channel send". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: rename enroll_bulker config key to enroll, simplify changelog - inputs[].server.bulk.enroll_bulker -> inputs[].server.bulk.enroll (per blakerouse feedback: _bulker suffix is redundant in this context) - Rewrite changelog to focus on the user-visible problem (ghost agents) rather than implementation details Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 3890bbb) # Conflicts: # internal/pkg/bulk/block.go # internal/pkg/bulk/engine.go # internal/pkg/bulk/opt.go # internal/pkg/dl/search.go * fix: resolve cherry-pick conflicts in 8.19 backport of #7662 Resolve conflicts in block.go, engine.go, opt.go, and dl/search.go by keeping pointer semantics for spanLink (consistent with 8.19 codebase) while adding the new DedupeKey/RefreshIndex fields and WithDedupeKey opt. Also adapt flushEnrollSearch to use a local buffer instead of flushBufPool which does not exist in 8.19. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preallocate indices slice in writeMsearchMeta (lint) Addresses prealloc lint warning in opSearch.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…pre-refresh dedup (#7665) * feat: batch enrollment FindAgent searches with pre-refresh dedup (#7662) * feat: batch enrollment FindAgent searches with pre-refresh dedup via kQueueEnrollSearch Adds a dedicated kQueueEnrollSearch bulker queue that prevents duplicate agent documents caused by concurrent enrollment retries with the same enrollment_id. The queue: - Flushes independently from other queues (default: every 1s or 50 items) - Calls POST .fleet-agents/_refresh before executing the msearch batch, ensuring retries see agent documents written by earlier requests - De-dupes concurrent requests with the same enrollment_id: the first (canonical) request gets the search result; duplicates receive ErrEnrollDuplicate (HTTP 429) so the agent retries after backoff Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changelog fragment for kQueueEnrollSearch enrollment dedup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: rename EnrollBatcher → EnrollBulker in config and opts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: correct LIFO→FIFO order and refresh all unique indices in flushEnrollSearch Address two Copilot review findings in flushEnrollSearch: 1. The bulker queue is LIFO (head = newest item), so iterating queue.head directly made the *newest* request canonical, not the oldest. Collect all items into a slice and reverse it so the oldest request is canonical for each dedupeKey (FIFO semantics). 2. Only canonicals[0].refreshIndex was refreshed, silently skipping any additional indices present in the batch. Collect all unique refreshIndex values from every canonical and refresh them all. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: propagate canonical search error to duplicates in flushEnrollSearch When a canonical request's msearch returns an ES error, duplicates were receiving ErrEnrollDuplicate, which callers treat as a benign retry signal. This silently hid the real failure. Now: if deriveError() on a canonical response is non-nil, that error is forwarded to all duplicates sharing the same dedupeKey, so callers surface the actual operational failure rather than retrying indefinitely under the impression it was just a duplicate. ErrEnrollDuplicate is still sent when the canonical search succeeded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add integration test for enrollment search dedup (kQueueEnrollSearch) Fires numConcurrent goroutines all calling bulker.Search with the same dedupeKey against a test index. Sets enrollFlushThresholdCount above the concurrent count so all requests land in one batch and are flushed by the timer. Asserts exactly one canonical result and numConcurrent-1 ErrEnrollDuplicate responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address golangci-lint failures (nolintlint, goimports) - Add explanation to nolint:errcheck directive in flushEnrollSearch (nolintlint requires a reason comment) - Realign ServerBulk struct fields after adding the wider ServerBulkEnrollBulker type (goimports formatting) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: call EnrollBulker.InitDefaults() from ServerBulk.InitDefaults() ucfg calls each nested struct's InitDefaults() automatically when loading config from a file, so the EnrollBulker defaults (1s, 50) were correctly applied at runtime. However, tests that construct expected configs by calling InitDefaults() manually through the chain were getting zero values for EnrollBulker, causing TestConfig to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: eliminate data race in flushEnrollSearch dispatch loop After sending on n.ch, the receiving goroutine (Search) immediately calls freeBlk(n) -> reset(), which zeroes all bulkT fields including dedupeKey. Reading n.dedupeKey after the channel send to look up duplicate waiters was therefore a data race detected by -race. Fix: capture n.dedupeKey into a local variable before the send, so the map lookup uses a stack-allocated copy that is not subject to concurrent modification. This matches the pattern already used in flushSearch, which saves n.next before its channel send with the comment "n is invalid immediately on channel send". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: rename enroll_bulker config key to enroll, simplify changelog - inputs[].server.bulk.enroll_bulker -> inputs[].server.bulk.enroll (per blakerouse feedback: _bulker suffix is redundant in this context) - Rewrite changelog to focus on the user-visible problem (ghost agents) rather than implementation details Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 3890bbb) # Conflicts: # internal/pkg/bulk/block.go # internal/pkg/bulk/engine.go # internal/pkg/bulk/opt.go # internal/pkg/dl/search.go * fix: resolve cherry-pick conflicts in 9.4 backport of #7662 Resolve conflicts in block.go, engine.go, opt.go, and dl/search.go by keeping pointer semantics for spanLink (consistent with 9.4 codebase) while adding the new DedupeKey/RefreshIndex fields and WithDedupeKey opt. Also adapt flushEnrollSearch to use a local buffer instead of flushBufPool which does not exist in 9.4. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preallocate indices slice in writeMsearchMeta (lint) Addresses prealloc lint warning in opSearch.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fleet Server PR elastic/fleet-server#7662 introduced a pre-enrollment _refresh call on .fleet-agents. On a fresh Serverless project the index does not yet exist (it is lazy-created on first enrollment write), so every enrollment attempt fails with a 404 from _refresh before any document can be written — a deadlock where the index is never created because enrollment always fails first. Create .fleet-agents explicitly at the end of Fleet setup (after the fleet_server package and its index templates are installed so that the correct mappings are applied). resource_already_exists_exception is treated as a no-op, making this safe for concurrent Kibana instances. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On a fresh Serverless project .fleet-agents does not exist when the first enrollment attempt arrives. The pre-enrollment _refresh call introduced in elastic#7662 returned HTTP 404, which caused failQueue to be called and every enrollment to fail permanently — a deadlock where the index is never created because enrollment always fails before the first write lands. Pass ignore_unavailable=true to the refresh request so a missing index is treated as a no-op (HTTP 200, zero shards). The first enrollment proceeds to the msearch, writes the agent document (ES auto-creates .fleet-agents-7 with the correct alias and mappings via the system index descriptor), and subsequent retries with the same enrollment_id deduplicate correctly. Also fix the body-close ordering introduced in elastic#7662: Body.Close() was called before String() in the error path, hiding the real ES error behind "read on closed response body". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…th (#7688) * fix: close enroll search refresh response body after reading error When the pre-enrollment _refresh call returns a non-2xx response, the body was closed before String() was called to format the error message, producing "error reading response body: http: read on closed response body" instead of the actual ES error. Defer the close so String() can read the body first. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ignore missing .fleet-agents index on enrollment _refresh On a fresh Serverless project .fleet-agents does not exist when the first enrollment attempt arrives. The pre-enrollment _refresh call introduced in #7662 returned HTTP 404, which caused failQueue to be called and every enrollment to fail permanently — a deadlock where the index is never created because enrollment always fails before the first write lands. Pass ignore_unavailable=true to the refresh request so a missing index is treated as a no-op (HTTP 200, zero shards). The first enrollment proceeds to the msearch, writes the agent document (ES auto-creates .fleet-agents-7 with the correct alias and mappings via the system index descriptor), and subsequent retries with the same enrollment_id deduplicate correctly. Also fix the body-close ordering introduced in #7662: Body.Close() was called before String() in the error path, hiding the real ES error behind "read on closed response body". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fixup: address review feedback — close refresh body immediately, add missing-index integration test, remove unneeded changelog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…th (#7688) (#7690) * fix: close enroll search refresh response body after reading error When the pre-enrollment _refresh call returns a non-2xx response, the body was closed before String() was called to format the error message, producing "error reading response body: http: read on closed response body" instead of the actual ES error. Defer the close so String() can read the body first. * fix: ignore missing .fleet-agents index on enrollment _refresh On a fresh Serverless project .fleet-agents does not exist when the first enrollment attempt arrives. The pre-enrollment _refresh call introduced in #7662 returned HTTP 404, which caused failQueue to be called and every enrollment to fail permanently — a deadlock where the index is never created because enrollment always fails before the first write lands. Pass ignore_unavailable=true to the refresh request so a missing index is treated as a no-op (HTTP 200, zero shards). The first enrollment proceeds to the msearch, writes the agent document (ES auto-creates .fleet-agents-7 with the correct alias and mappings via the system index descriptor), and subsequent retries with the same enrollment_id deduplicate correctly. Also fix the body-close ordering introduced in #7662: Body.Close() was called before String() in the error path, hiding the real ES error behind "read on closed response body". * fixup: address review feedback — close refresh body immediately, add missing-index integration test, remove unneeded changelog --------- (cherry picked from commit 9ab9134) Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…th (#7688) (#7692) * fix: close enroll search refresh response body after reading error When the pre-enrollment _refresh call returns a non-2xx response, the body was closed before String() was called to format the error message, producing "error reading response body: http: read on closed response body" instead of the actual ES error. Defer the close so String() can read the body first. * fix: ignore missing .fleet-agents index on enrollment _refresh On a fresh Serverless project .fleet-agents does not exist when the first enrollment attempt arrives. The pre-enrollment _refresh call introduced in #7662 returned HTTP 404, which caused failQueue to be called and every enrollment to fail permanently — a deadlock where the index is never created because enrollment always fails before the first write lands. Pass ignore_unavailable=true to the refresh request so a missing index is treated as a no-op (HTTP 200, zero shards). The first enrollment proceeds to the msearch, writes the agent document (ES auto-creates .fleet-agents-7 with the correct alias and mappings via the system index descriptor), and subsequent retries with the same enrollment_id deduplicate correctly. Also fix the body-close ordering introduced in #7662: Body.Close() was called before String() in the error path, hiding the real ES error behind "read on closed response body". * fixup: address review feedback — close refresh body immediately, add missing-index integration test, remove unneeded changelog --------- (cherry picked from commit 9ab9134) Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…th (#7688) (#7691) * fix: close enroll search refresh response body after reading error When the pre-enrollment _refresh call returns a non-2xx response, the body was closed before String() was called to format the error message, producing "error reading response body: http: read on closed response body" instead of the actual ES error. Defer the close so String() can read the body first. * fix: ignore missing .fleet-agents index on enrollment _refresh On a fresh Serverless project .fleet-agents does not exist when the first enrollment attempt arrives. The pre-enrollment _refresh call introduced in #7662 returned HTTP 404, which caused failQueue to be called and every enrollment to fail permanently — a deadlock where the index is never created because enrollment always fails before the first write lands. Pass ignore_unavailable=true to the refresh request so a missing index is treated as a no-op (HTTP 200, zero shards). The first enrollment proceeds to the msearch, writes the agent document (ES auto-creates .fleet-agents-7 with the correct alias and mappings via the system index descriptor), and subsequent retries with the same enrollment_id deduplicate correctly. Also fix the body-close ordering introduced in #7662: Body.Close() was called before String() in the error path, hiding the real ES error behind "read on closed response body". * fixup: address review feedback — close refresh body immediately, add missing-index integration test, remove unneeded changelog --------- (cherry picked from commit 9ab9134) 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?
At 30k+ agent scale, enrollment retries can create duplicate (ghost) agent documents in
.fleet-agents. The root cause is ES near-real-time search latency: when an agent retries enrollment before the previous write is visible to search (default refresh interval: 1s on ES, 5s on Serverless), a concurrent retry also finds "no existing agent" and writes a second document with the sameenrollment_id.How does this PR solve the problem?
Adds a new
kQueueEnrollSearchbulker queue that batches enrollmentFindAgentsearches and, before executing them, fires a singlePOST .fleet-agents/_refreshto make all recent writes visible. Within each batch:refreshIndexvalues in the batch are refreshed so retries see the most recent writes.enrollment_id(passed viaWithDedupeKey) are grouped by FIFO order — the oldest (first) request is canonical and is included in the msearch; duplicates receive HTTP 429 (ErrEnrollDuplicate) and retry after backoff.ErrEnrollDuplicate, so operational failures are not silently hidden.The queue flushes when N requests accumulate (default: 50) or after M seconds (default: 1s), whichever comes first — capping refresh calls at one per fleet-server instance per flush window. Both thresholds are configurable via
inputs[].server.bulk.enroll.flush_intervalandinputs[].server.bulk.enroll.flush_threshold_cnt.How to test this PR locally
Run the integration test added in this PR:
For manual testing, set a low
flush_threshold_cnt(e.g. 2) in config and fire concurrent enrollment requests with the sameenrollment_id. Only one agent document should be created.Design Checklist
op_type: createwrite guarantees in ES ensure correctness across instances.)Checklist
./changelog/fragmentsusing the changelog toolRelated issues