Skip to content

feat: batch enrollment FindAgent searches with pre-refresh dedup - #7662

Merged
ycombinator merged 10 commits into
elastic:mainfrom
ycombinator:feat/enroll-search-bulker-queue
Aug 20, 2026
Merged

feat: batch enrollment FindAgent searches with pre-refresh dedup#7662
ycombinator merged 10 commits into
elastic:mainfrom
ycombinator:feat/enroll-search-bulker-queue

Conversation

@ycombinator

@ycombinator ycombinator commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 same enrollment_id.

How does this PR solve the problem?

Adds a new kQueueEnrollSearch bulker queue that batches enrollment FindAgent searches and, before executing them, fires a single POST .fleet-agents/_refresh to make all recent writes visible. Within each batch:

  1. Pre-refresh: all unique refreshIndex values in the batch are refreshed so retries see the most recent writes.
  2. De-duplication: requests sharing the same enrollment_id (passed via WithDedupeKey) 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.
  3. Error propagation: if the canonical search itself returns an ES error, that error is forwarded to duplicates instead of 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_interval and inputs[].server.bulk.enroll.flush_threshold_cnt.

How to test this PR locally

Run the integration test added in this PR:

ELASTICSEARCH_HOSTS=localhost:9200 ELASTICSEARCH_SERVICE_TOKEN=<token> \
  go test -tags integration ./internal/pkg/bulk/... -run TestEnrollSearchDedup -v

For manual testing, set a low flush_threshold_cnt (e.g. 2) in config and fire concurrent enrollment requests with the same enrollment_id. Only one agent document should be created.

Design Checklist

  • I have ensured my design is stateless and will work when multiple fleet-server instances are behind a load balancer. (Each instance maintains its own bulker queue; the pre-refresh + op_type: create write guarantees in ES ensure correctness across instances.)
  • 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. (Flush interval and threshold count cap the refresh rate; duplicates shed load via 429.)

Checklist

  • 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

  • Relates elastic/horde#532

@ycombinator
ycombinator requested a review from a team as a code owner August 19, 2026 11:04
@ycombinator
ycombinator requested review from macdewee and samuelvl and a lite review from Copilot August 19, 2026 11:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 enrollment FindAgent searches through the new queue.
  • Added ErrEnrollDuplicate and 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.

Comment thread internal/pkg/bulk/opSearch.go Outdated
Comment thread internal/pkg/bulk/opSearch.go Outdated
Comment thread internal/pkg/bulk/opSearch.go
Copilot AI review requested due to automatic review settings August 19, 2026 11:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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.

Copilot AI review requested due to automatic review settings August 19, 2026 11:19
@ycombinator
ycombinator requested a review from blakerouse August 19, 2026 11:20
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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
}

Comment thread internal/pkg/bulk/enroll_search_integration_test.go
@mergify

mergify Bot commented Aug 19, 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 added Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team backport-active-all Automated backport with mergify to all the active branches labels Aug 19, 2026
Copilot AI review requested due to automatic review settings August 19, 2026 12:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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 numConcurrent does not compile because range can't be used over an int. Iterate over the slice (or use an index loop) so the goroutines are started as intended.
	for i := range numConcurrent {

Comment thread internal/pkg/config/input.go Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 12:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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 from refreshResp.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 via parseError (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())

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

This comment has been minimized.

@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings August 19, 2026 13:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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 blakerouse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread changelog/fragments/1787137647-enroll-search-bulker-queue.yaml Outdated
Comment thread internal/pkg/config/input.go Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 14:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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 refreshIndex values from canonicals. If a batch contains items with a refreshIndex set 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 from refreshResp.String(). Depending on the underlying Response.String()/error parsing, closing early can discard useful error details (and String() 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

  • bufSz uses queue.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)

@ycombinator
ycombinator merged commit 3890bbb into elastic:main Aug 20, 2026
12 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

@Mergifyio backport 9.5 9.4 8.19

@mergify

mergify Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

backport 9.5 9.4 8.19

✅ Backports have been created

Details

Cherry-pick of 3890bbb has failed:

On branch mergify/bp/9.4/pr-7662
Your branch is up to date with 'origin/9.4'.

You are currently cherry-picking commit 3890bbb.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	new file:   changelog/fragments/1787137647-enroll-search-bulker-queue.yaml
	modified:   internal/pkg/api/error.go
	modified:   internal/pkg/api/handleEnroll.go
	new file:   internal/pkg/bulk/enroll_search_integration_test.go
	modified:   internal/pkg/bulk/opSearch.go
	modified:   internal/pkg/bulk/queue.go
	modified:   internal/pkg/config/input.go
	modified:   internal/pkg/dl/agent.go
	modified:   internal/pkg/dl/common.go

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   internal/pkg/bulk/block.go
	both modified:   internal/pkg/bulk/engine.go
	both modified:   internal/pkg/bulk/opt.go
	both modified:   internal/pkg/dl/search.go

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:

On branch mergify/bp/8.19/pr-7662
Your branch is up to date with 'origin/8.19'.

You are currently cherry-picking commit 3890bbb.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	new file:   changelog/fragments/1787137647-enroll-search-bulker-queue.yaml
	modified:   internal/pkg/api/error.go
	modified:   internal/pkg/api/handleEnroll.go
	new file:   internal/pkg/bulk/enroll_search_integration_test.go
	modified:   internal/pkg/bulk/opSearch.go
	modified:   internal/pkg/bulk/queue.go
	modified:   internal/pkg/config/input.go
	modified:   internal/pkg/dl/agent.go
	modified:   internal/pkg/dl/common.go

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   internal/pkg/bulk/block.go
	both modified:   internal/pkg/bulk/engine.go
	both modified:   internal/pkg/bulk/opt.go
	both modified:   internal/pkg/dl/search.go

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

@ycombinator
ycombinator deleted the feat/enroll-search-bulker-queue branch August 24, 2026 13:04
ycombinator added a commit that referenced this pull request Aug 24, 2026
…) (#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>
ycombinator added a commit that referenced this pull request Aug 24, 2026
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>
ycombinator added a commit that referenced this pull request Aug 24, 2026
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>
ycombinator added a commit that referenced this pull request Aug 24, 2026
… 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>
ycombinator added a commit that referenced this pull request Aug 24, 2026
…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>
ycombinator added a commit to ycombinator/kibana that referenced this pull request Aug 25, 2026
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>
ycombinator added a commit to ycombinator/fleet-server that referenced this pull request Aug 26, 2026
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>
ycombinator added a commit that referenced this pull request Aug 26, 2026
…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>
ycombinator added a commit that referenced this pull request Aug 27, 2026
…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>
ycombinator added a commit that referenced this pull request Aug 27, 2026
…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>
ycombinator added a commit that referenced this pull request Aug 27, 2026
…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>
Copilot AI mentioned this pull request Sep 2, 2026
3 tasks
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 Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants