Skip to content

fix(recommendations): SP recs never reach Opportunities list -- missing 6th goroutine in GetAllRecommendations fan-out #784

Description

@cristim

Symptom

Savings Plans recommendations never appear in the Opportunities list. The AWS rec-collection sweep returns only EC2, RDS, ElastiCache, OpenSearch, and Redshift rows; SP rows are silently absent.

Observed today (2026-05-28) in dev account 909626172446:

2026/05/28 09:29:15 [INFO] Collected 46 recommendations with $1076.22/month potential savings

46 recs collected, zero of them SP, despite the account having recurring SP-eligible workloads.

Root cause

providers/aws/recommendations/client.go GetAllRecommendations spawns 5 goroutines, one per RI service:

g.Go(func() error { ec2Recs, ec2Err = c.GetRecommendationsForService(gctx, common.ServiceEC2);          return nil })
g.Go(func() error { rdsRecs, rdsErr = c.GetRecommendationsForService(gctx, common.ServiceRDS);          return nil })
g.Go(func() error { cacheRecs, cacheErr = c.GetRecommendationsForService(gctx, common.ServiceElastiCache); return nil })
g.Go(func() error { osRecs, osErr   = c.GetRecommendationsForService(gctx, common.ServiceOpenSearch);   return nil })
g.Go(func() error { redshiftRecs, redshiftErr = c.GetRecommendationsForService(gctx, common.ServiceRedshift); return nil })

There is no goroutine for common.ServiceSavingsPlans (the canonical umbrella SP slug defined in pkg/common/types.go:68).

The dispatch helper in Client.GetRecommendations (lines 67-70) correctly routes SP slugs to getSavingsPlansRecommendations:

if common.IsSavingsPlan(params.Service) {
    return c.getSavingsPlansRecommendations(ctx, params)
}

But that branch is never reached because nothing in the fan-out passes an SP slug. The mergeServiceResults call at line 301 also only enumerates the 5 RI services, so even if SP recs were fetched there's no place for them in the merged output.

Fix

Add a 6th goroutine for common.ServiceSavingsPlans:

var spRecs []common.Recommendation
var spErr error
g.Go(func() error {
    spRecs, spErr = c.GetRecommendationsForService(gctx, common.ServiceSavingsPlans)
    return nil
})

Extend mergeServiceResults to include the SP slot:

return mergeServiceResults(
    serviceResult{name: "EC2",          recs: ec2Recs,      err: ec2Err},
    serviceResult{name: "RDS",          recs: rdsRecs,      err: rdsErr},
    serviceResult{name: "ElastiCache",  recs: cacheRecs,    err: cacheErr},
    serviceResult{name: "OpenSearch",   recs: osRecs,       err: osErr},
    serviceResult{name: "Redshift",     recs: redshiftRecs, err: redshiftErr},
    serviceResult{name: "SavingsPlans", recs: spRecs,       err: spErr},
), nil

The downstream code already handles SP via getSavingsPlansRecommendations (parser_sp.go:30), which iterates planTypesForParams(params) and fans out across the 4 plan types (Compute / EC2Instance / SageMaker / Database) for the umbrella slug. The CE call budget is 2 terms × 3 payment options × 4 plan types = 24 calls per sweep -- within the existing rate-limiter and concurrency caps.

Cost

Each per-service sweep makes 2 terms × 3 payment options = 6 CE calls (plus retries). The umbrella SP slug expands further to 4 plan types internally, so the SP sweep adds ~24 CE calls per refresh. This is bounded by pkg/concurrency and the rate limiter; should not change tail latency materially.

Acceptance criteria

  • GetAllRecommendations spawns 6 goroutines including SP
  • mergeServiceResults receives a serviceResult{name: "SavingsPlans", ...} entry
  • Regression test in client_test.go asserting SP recs are present in the merged output when the mock returns SP rows
  • Manual verification against the dev account: Opportunities list shows SP rows after a refresh
  • No regression in EC2/RDS/ElastiCache/OpenSearch/Redshift counts

Cross-references

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/sHoursimpact/all-usersAffects every userpr-createdA PR has been opened for this issue (dedup guard for the auto-PR loop)pr-mergedThe PR for this issue has been mergedpriority/p1Next up; this sprintseverity/highSignificant harmtriagedItem has been triagedtype/bugDefecturgency/nowDrop other things

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions