feat: add synchronous enrollment write strategy to prevent ghost agents - #7712
Conversation
Introduce inputs[].server.feature_flags.sync_enrollment_write (default false). When true, createFleetAgent writes the agent document directly with op_type=create&refresh=wait_for so the document is committed and visible to search before the enrollment response is sent. Any retry on any pod finds the existing document immediately, eliminating ghost agents caused by the async bulk queue / EOF race. The existing async path is unchanged when the flag is false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
There was a problem hiding this comment.
Pull request overview
This PR introduces an opt-in enrollment write strategy to prevent “ghost agent” duplicates when enrollment responses are lost and the agent retries against a different fleet-server instance before the original write becomes searchable.
Changes:
- Added
inputs[].server.feature_flags.sync_enrollment_writeconfiguration flag (defaultfalse) to select enrollment write strategy. - Implemented synchronous enrollment agent-document creation using
op_type=createwithrefresh=wait_for, treating HTTP 409 conflicts as success. - Added a changelog fragment describing the new feature flag and behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| internal/pkg/config/input.go | Adds the SyncEnrollmentWrite feature flag to Fleet Server’s feature flags config. |
| internal/pkg/api/handleEnroll.go | Plumbs the feature flag into enrollment and adds a synchronous ES write path for agent document creation. |
| changelog/fragments/1787924366-sync-enrollment-write.yaml | Documents the enhancement and how the new feature flag changes enrollment behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
TL;DRBuildkite failed in Remediation
Investigation detailsRoot Cause
to:
in
This causes Evidence
Verification
Follow-up
What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
…porary status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The config tag uses an underscore prefix (_sync_enrollment_write) but the doc comment didn't make the exact key explicit, causing inconsistency with the PR description. Spell out the full key in the comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
internal/pkg/config/input.go:131
- The PR description and example config use
inputs[].server.feature_flags.sync_enrollment_write, but the code/config tag added here expectsfeature_flags._sync_enrollment_write. As-written, following the PR description won’t enable the feature flag, which is a discrepancy with the stated usage.
// SyncEnrollmentWrite (config key: feature_flags._sync_enrollment_write) selects the enrollment write strategy.
// When false (default), agent documents are written via the async bulk queue — existing behaviour.
// When true, agent documents are written synchronously with refresh=wait_for, making the document
// immediately searchable before the enrollment response is sent and eliminating ghost agents.
SyncEnrollmentWrite bool `config:"_sync_enrollment_write"`
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/pkg/api/handleEnroll_test.go:632
- This test checks that non-409 errors are surfaced, but it doesn’t verify that the sync-write request is using
op_type=createandrefresh=wait_for. Adding assertions on the outgoing request makes the test protect the behavior the feature flag is meant to enforce.
mt.RoundTripFn = func(req *http.Request) (*http.Response, error) {
…ests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/pkg/api/handleEnroll_test.go:621
- The new sync-write enrollment tests depend on
MockTransport, which is defined inhandleUpload_test.go(same package). This creates a brittle coupling between unrelated tests; if the upload tests are refactored/removed, these enroll tests will stop compiling. Consider movingMockTransportto a dedicated shared test helper file (e.g.internal/pkg/api/mock_transport_test.go) or defining a local transport helper in this file.
func TestCreateFleetAgentSyncWrite409Succeeds(t *testing.T) {
mt := &MockTransport{}
mt.RoundTripFn = func(req *http.Request) (*http.Response, error) {
assertSyncEnrollParams(t, req)
return &http.Response{
|
@Mergifyio backport 9.5 9.4 8.19 |
✅ Backports have been createdDetails
Cherry-pick of d6324c7 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 d6324c7 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 |
…ts (#7712) (#7722) * feat: add synchronous enrollment write strategy to prevent ghost agents Introduce inputs[].server.feature_flags.sync_enrollment_write (default false). When true, createFleetAgent writes the agent document directly with op_type=create&refresh=wait_for so the document is committed and visible to search before the enrollment response is sent. Any retry on any pod finds the existing document immediately, eliminating ghost agents caused by the async bulk queue / EOF race. The existing async path is unchanged when the flag is false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: rename feature flag to _sync_enrollment_write to signal temporary status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: fix createFleetAgent call to pass syncWrite argument Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add unit tests for sync enrollment write path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: spell out full config key in SyncEnrollmentWrite comment The config tag uses an underscore prefix (_sync_enrollment_write) but the doc comment didn't make the exact key explicit, causing inconsistency with the PR description. Spell out the full key in the comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove changelog fragment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix spelling behaviour -> behavior in comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: assert op_type=create and refresh=wait_for in sync enrollment tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit d6324c7) # Conflicts: # internal/pkg/config/input.go Co-authored-by: Shaunak Kashyap <ycombinator@gmail.com>
…ts (#7712) (#7721) * feat: add synchronous enrollment write strategy to prevent ghost agents Introduce inputs[].server.feature_flags.sync_enrollment_write (default false). When true, createFleetAgent writes the agent document directly with op_type=create&refresh=wait_for so the document is committed and visible to search before the enrollment response is sent. Any retry on any pod finds the existing document immediately, eliminating ghost agents caused by the async bulk queue / EOF race. The existing async path is unchanged when the flag is false. * refactor: rename feature flag to _sync_enrollment_write to signal temporary status * test: fix createFleetAgent call to pass syncWrite argument * test: add unit tests for sync enrollment write path * docs: spell out full config key in SyncEnrollmentWrite comment The config tag uses an underscore prefix (_sync_enrollment_write) but the doc comment didn't make the exact key explicit, causing inconsistency with the PR description. Spell out the full key in the comment. * chore: remove changelog fragment * chore: fix spelling behaviour -> behavior in comment * test: assert op_type=create and refresh=wait_for in sync enrollment tests --------- (cherry picked from commit d6324c7) 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?
When an enrollment write is committed to fleet-server's in-memory bulk queue but the HTTP response is lost before it reaches the agent (network drop, EOF), the agent retries enrollment. If the retry lands on a different pod, that pod's
FindAgentsearch finds nothing — the write hasn't been flushed to Elasticsearch yet — and creates a second agent document. These duplicate documents ("ghost agents") accumulate and do not resolve on their own.The batched pre-refresh dedup approach in #7662 cannot prevent this: cross-pod retries are never in the same flush batch, so in-batch dedup never fires. Evidence from the 30k serverless checkin scale test: 35 ghost agents with
enrolled_attimestamps clustered in a 21-second window (11:53:42–11:54:03 UTC), zeroErrEnrollDuplicate429s — confirming in-batch dedup never triggered for any of the 35 retries.How does this PR solve the problem?
A new feature flag,
inputs[].server.feature_flags._sync_enrollment_write(default:false), selects the enrollment write strategy.When
false(default), the existing async bulk queue path is unchanged.When
true,createFleetAgentwrites the agent document directly to Elasticsearch usingop_type=create&refresh=wait_for. ES blocks until the document is committed and visible to search on all shards before returning. The enrollment response is not sent until this write completes, so any retry on any pod finds the existing document immediately. A 409 fromop_type=createis treated as success.The
_prefix on the flag name signals that it is internal and temporary. The intent is to enable it in Staging via serverless-gitops, validate with the 30k checkin scale test, and then remove the flag entirely — making the sync path the permanent enrollment write path.How to test this PR locally
Enable the flag and run the 30k Fleet Server checkin scale test against Staging. Confirm ghost agent count is 0 after enrollment completes.
Design Checklist
Checklist
./changelog/fragmentsusing the changelog toolRelated issues