Skip to content

[9.4](backport #7662) feat: batch enrollment FindAgent searches with pre-refresh dedup - #7665

Merged
ycombinator merged 3 commits into
9.4from
mergify/bp/9.4/pr-7662
Aug 24, 2026
Merged

[9.4](backport #7662) feat: batch enrollment FindAgent searches with pre-refresh dedup#7665
ycombinator merged 3 commits into
9.4from
mergify/bp/9.4/pr-7662

Conversation

@mergify

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

* 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
@mergify mergify Bot added backport conflicts There is a conflict in the backported pull request labels Aug 20, 2026
@mergify
mergify Bot requested a review from a team as a code owner August 20, 2026 07:05
@mergify
mergify Bot requested review from blakerouse and lorienhu August 20, 2026 07:05
@mergify mergify Bot added the backport label Aug 20, 2026
@mergify mergify Bot added the conflicts There is a conflict in the backported pull request label Aug 20, 2026
@mergify

mergify Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

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

@github-actions github-actions Bot added the Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

TL;DR

Buildkite is failing because this backport branch contains unresolved cherry-pick conflict markers (<<<<<<<, =======, >>>>>>>) in Go source files. Remove the markers and complete conflict resolution in the affected files; all 3 failed jobs are downstream of that same compile error.

Remediation

  • Resolve merge conflicts in internal/pkg/bulk/block.go, internal/pkg/bulk/engine.go, internal/pkg/bulk/opt.go, and internal/pkg/dl/search.go so no conflict-marker text remains and the intended 9.4-compatible code is preserved.
  • Re-run .buildkite/scripts/check_ci.sh and .buildkite/scripts/release_test.sh (x86_64 + x86_64 FIPS) after conflict resolution.
Investigation details

Root Cause

This is a code bug introduced by an incomplete backport/cherry-pick conflict resolution. The compiler is parsing git conflict markers as Go tokens.

Evidence from PR metadata and source:

  • Mergify conflict note on this PR reports unmerged paths in:
    • internal/pkg/bulk/block.go
    • internal/pkg/bulk/engine.go
    • internal/pkg/bulk/opt.go
    • internal/pkg/dl/search.go
  • PR head file contents still include markers such as:
    • internal/pkg/bulk/block.go (<<<<<<< HEAD / >>>>>>> 3890bbb ...)
    • internal/pkg/bulk/opt.go (<<<<<<< HEAD / >>>>>>> 3890bbb ...)
    • internal/pkg/dl/search.go (<<<<<<< HEAD / >>>>>>> 3890bbb ...)

Evidence

internal/pkg/bulk/block.go:19:1: expected '}', found '<<'
internal/pkg/bulk/engine.go:624:1: expected statement, found '<<'
internal/pkg/bulk/opt.go:28:1: expected '}', found '<<'
internal/pkg/dl/search.go:29:1: expected declaration, found '<<'

and in packaging jobs:

internal/pkg/bulk/block.go:19:1: syntax error: unexpected <<, expected field name or embedded type
internal/pkg/bulk/engine.go:624:1: syntax error: unexpected <<, expected }
internal/pkg/bulk/opt.go:28:1: syntax error: unexpected <<, expected field name or embedded type

Verification

  • Not run locally in this workflow; diagnosis is directly supported by compiler errors plus visible unresolved conflict markers in PR-head source.

Follow-up

  • After resolving conflicts, if CI still fails, the next likely check is API compatibility around spanLink / dedupe option plumbing in bulk and dl/search paths.

What is this? | From workflow: PR Buildkite Detective

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

@mergify

mergify Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

This pull request has not been merged yet. Could you please review and merge it @ycombinator? 🙏

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
ycombinator previously approved these changes Aug 24, 2026
Addresses prealloc lint warning in opSearch.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ycombinator
ycombinator enabled auto-merge (squash) August 24, 2026 13:27
@ycombinator
ycombinator merged commit d948560 into 9.4 Aug 24, 2026
12 checks passed
@ycombinator
ycombinator deleted the mergify/bp/9.4/pr-7662 branch August 24, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport conflicts There is a conflict in the backported pull request 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.

1 participant