Skip to content

[semantic-refactor] CCR follower-active wait reimplements polling loop instead of using asyncutils.WaitForStateTransitionΒ #4370

Description

@github-actions

πŸ”§ 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

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

  • Review refactoring findings
  • Prioritize refactoring tasks
  • Create refactoring plan
  • Implement changes
  • Update tests as needed
  • Verify no functionality broken

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 Β· β—·

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions