π§ Semantic Refactor: CCR follower-active waiter duplicates the already-centralized polling loop
Analysis of repository: elastic/terraform-provider-elasticstack
Summary
internal/asyncutils/state_waiter.go exports WaitForStateTransition, a generic "poll until desired state or context deadline" helper, and it has already been adopted by essentially every other "wait for X to reach state Y" function in the codebase (Fleet package installs, Fleet agent-policy tamper protection, ML job/datafeed state, connector sync-job completion, security entity-store install/uninstall). One function was missed during that consolidation and still hand-rolls the same ticker/deadline/select-on-ctx.Done() loop that asyncutils exists to replace.
Concrete Evidence
Opportunity: waitForFollowerActive reimplements asyncutils.WaitForStateTransition
-
Severity: Medium
-
Type: duplicate-function / misplaced-function
-
Locations:
internal/elasticsearch/ccr/followerindex/create.go:141-185 β waitForFollowerActive(ctx context.Context, client *clients.ElasticsearchScopedClient, indexName string) (*estypes.FollowerIndex, diag.Diagnostics) β hand-rolled polling loop using time.Now()/deadline/time.After/select { case <-ctx.Done(): ... case <-time.After(...): }
Compare against the already-migrated callers of the shared helper:
internal/fleet/agentpolicy/tamper_protection.go:38-69 β waitForTamperProtection wraps ctx with context.WithTimeout and delegates the poll loop to asyncutils.WaitForStateTransition(waitCtx, "fleet agent policy", policyID, func(waitCtx context.Context) (bool, error) { ... })
internal/clients/fleet/packages.go:447-... β waitForPackageInstalled β same delegation pattern
internal/fleet/customintegration/update.go:113-... β waitForInstalledCustomIntegration β same delegation pattern
internal/fleet/integration/create.go:171-... β waitForFleetIntegrationInstalledState β same delegation pattern
internal/elasticsearch/connector/sync_job_create/action.go:175-... β waitForSyncJobCompletionWithInterval β same delegation pattern (with asyncutils.WithPollInterval)
internal/elasticsearch/ml/jobstate/state_utils.go β waitForJobState β same delegation pattern
-
Code Sample (current, duplicated loop shape):
// internal/elasticsearch/ccr/followerindex/create.go:141
func waitForFollowerActive(ctx context.Context, client *clients.ElasticsearchScopedClient, indexName string) (*estypes.FollowerIndex, diag.Diagnostics) {
var diags diag.Diagnostics
deadline := time.Now().Add(followerActiveTimeout)
var last *estypes.FollowerIndex
for {
follower, getDiags := elasticsearch.GetFollowerIndex(ctx, client, indexName)
diags.Append(getDiags...)
if diags.HasError() { return last, diags }
if follower != nil {
last = follower
if follower.Status.String() == statusActive && follower.Parameters != nil {
return follower, diags
}
}
if !time.Now().Before(deadline) { /* add timeout diag */ return last, diags }
select {
case <-ctx.Done(): /* add canceled diag */ return last, diags
case <-time.After(followerActivePollInterval):
}
}
}
vs. the pattern already used elsewhere:
// internal/fleet/agentpolicy/tamper_protection.go:44
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
waitErr := asyncutils.WaitForStateTransition(waitCtx, "fleet agent policy", policyID, func(waitCtx context.Context) (bool, error) {
got, getDiags := fleet.GetAgentPolicy(waitCtx, fleetClient, policyID, spaceID)
if getDiags.HasError() { return false, fmt.Errorf(...) }
if got != nil && got.IsProtected { reloaded = got; return true, nil }
return false, nil
})
Impact Analysis
- Maintainability: The hand-rolled loop in
waitForFollowerActive has to independently get right the same edge cases asyncutils.WaitForStateTransition already handles and is unit-tested for (context cancellation, deadline expiry, poll cadence) β see internal/asyncutils/state_waiter_test.go. Bug fixes or behavior changes to the shared waiter (e.g. exponential backoff, jitter, logging via tflog) will not automatically apply to this outlier.
- Organization: This is the only remaining "wait for API state" function in
internal/elasticsearch/** and internal/fleet/** that does not import asyncutils, despite the package being the established convention for this exact shape of logic.
- Duplication Risk: Low risk of behavioral drift today (the two loops are logically equivalent), but each future "wait for X" function that copies
waitForFollowerActive instead of the asyncutils-based examples perpetuates the pattern the codebase has otherwise standardized away from.
Refactoring Recommendations
- Migrate
waitForFollowerActive to asyncutils.WaitForStateTransition
- Target:
internal/elasticsearch/ccr/followerindex/create.go
- Action: consolidate
- Approach: wrap
ctx with context.WithTimeout(ctx, followerActiveTimeout), move the GetFollowerIndex call and statusActive/Parameters != nil check into a asyncutils.StateChecker closure that captures last by reference (mirroring reloaded in waitForTamperProtection), call asyncutils.WaitForStateTransition(waitCtx, "ccr follower index", indexName, checker, asyncutils.WithPollInterval(followerActivePollInterval)), then translate the returned error into the existing distinct "timed out" vs "context canceled" diagnostics by checking errors.Is(err, context.DeadlineExceeded) / errors.Is(err, context.Canceled) so the current user-facing messages are preserved.
- Estimated effort: 2-4 hours including updating/verifying the acceptance tests around CCR follower creation.
- Benefits: removes a bespoke polling implementation (~30 lines), inherits any future improvements to the shared waiter for free, and removes the last outlier in an otherwise-completed migration.
Implementation Checklist
Analysis Metadata
- Analyzed Files: ~1457 non-test
.go files under internal/ and provider/
- Detection Method: Serena project activation + regex sweep for
^func \w*(WaitFor|waitFor)\w*\( across internal/, cross-checked each match for an asyncutils import to find the one outlier still using a manual time.After/select loop
- Analysis Date: 2026-07-31
Generated by Semantic Function Refactor Β· sonnet50 Β· 24 AIC Β· β 0.497 AIC Β· β·
π§ Semantic Refactor: CCR follower-active waiter duplicates the already-centralized polling loop
Analysis of repository: elastic/terraform-provider-elasticstack
Summary
internal/asyncutils/state_waiter.goexportsWaitForStateTransition, a generic "poll until desired state or context deadline" helper, and it has already been adopted by essentially every other "wait for X to reach state Y" function in the codebase (Fleet package installs, Fleet agent-policy tamper protection, ML job/datafeed state, connector sync-job completion, security entity-store install/uninstall). One function was missed during that consolidation and still hand-rolls the same ticker/deadline/select-on-ctx.Done()loop thatasyncutilsexists to replace.Concrete Evidence
Opportunity:
waitForFollowerActivereimplementsasyncutils.WaitForStateTransitionSeverity: Medium
Type: duplicate-function / misplaced-function
Locations:
internal/elasticsearch/ccr/followerindex/create.go:141-185βwaitForFollowerActive(ctx context.Context, client *clients.ElasticsearchScopedClient, indexName string) (*estypes.FollowerIndex, diag.Diagnostics)β hand-rolled polling loop usingtime.Now()/deadline/time.After/select { case <-ctx.Done(): ... case <-time.After(...): }Compare against the already-migrated callers of the shared helper:
internal/fleet/agentpolicy/tamper_protection.go:38-69βwaitForTamperProtectionwrapsctxwithcontext.WithTimeoutand delegates the poll loop toasyncutils.WaitForStateTransition(waitCtx, "fleet agent policy", policyID, func(waitCtx context.Context) (bool, error) { ... })internal/clients/fleet/packages.go:447-...βwaitForPackageInstalledβ same delegation patterninternal/fleet/customintegration/update.go:113-...βwaitForInstalledCustomIntegrationβ same delegation patterninternal/fleet/integration/create.go:171-...βwaitForFleetIntegrationInstalledStateβ same delegation patterninternal/elasticsearch/connector/sync_job_create/action.go:175-...βwaitForSyncJobCompletionWithIntervalβ same delegation pattern (withasyncutils.WithPollInterval)internal/elasticsearch/ml/jobstate/state_utils.goβwaitForJobStateβ same delegation patternCode Sample (current, duplicated loop shape):
vs. the pattern already used elsewhere:
Impact Analysis
waitForFollowerActivehas to independently get right the same edge casesasyncutils.WaitForStateTransitionalready handles and is unit-tested for (context cancellation, deadline expiry, poll cadence) β seeinternal/asyncutils/state_waiter_test.go. Bug fixes or behavior changes to the shared waiter (e.g. exponential backoff, jitter, logging viatflog) will not automatically apply to this outlier.internal/elasticsearch/**andinternal/fleet/**that does not importasyncutils, despite the package being the established convention for this exact shape of logic.waitForFollowerActiveinstead of theasyncutils-based examples perpetuates the pattern the codebase has otherwise standardized away from.Refactoring Recommendations
waitForFollowerActivetoasyncutils.WaitForStateTransitioninternal/elasticsearch/ccr/followerindex/create.goctxwithcontext.WithTimeout(ctx, followerActiveTimeout), move theGetFollowerIndexcall andstatusActive/Parameters != nilcheck into aasyncutils.StateCheckerclosure that captureslastby reference (mirroringreloadedinwaitForTamperProtection), callasyncutils.WaitForStateTransition(waitCtx, "ccr follower index", indexName, checker, asyncutils.WithPollInterval(followerActivePollInterval)), then translate the returnederrorinto the existing distinct "timed out" vs "context canceled" diagnostics by checkingerrors.Is(err, context.DeadlineExceeded)/errors.Is(err, context.Canceled)so the current user-facing messages are preserved.Implementation Checklist
Analysis Metadata
.gofiles underinternal/andprovider/^func \w*(WaitFor|waitFor)\w*\(acrossinternal/, cross-checked each match for anasyncutilsimport to find the one outlier still using a manualtime.After/selectloop