Skip to content

fix(aws/recs): RI coverage keeps only the last CE bucket but divides by the full lookback window #51

Description

@cristim

Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.

Where

  • providers/aws/recommendations/coverage.go:275-287 (fetchCoveragePaged, the loop over result.CoveragesByTime) with the record closures at :226 and :250
  • providers/aws/recommendations/coverage.go:186 (windowHours = lookbackDays * 24) and :359-361 (poolCoverageFromGroup computing AvgInstancesPerHour = TotalRunningHours / windowHours)
  • Correct sibling implementation: providers/aws/recommendations/sp_coverage.go:217-266 (spCoverageAccumulator.add / .summarize), which pins Granularity: DAILY and accumulates across every period before dividing
  • Downstream consumers: providers/aws/recommendations/coverage.go:409-456 (ApplyCoverageMapToRecommendations) and providers/aws/ladder/layer_states.go:100 (computeEC2CoveragePct)

What

fetchCoveragePaged walks result.CoveragesByTime, which holds one entry per Cost Explorer time bucket, and for each bucket calls record(...), which does an unconditional out[key] = cov. Whatever bucket CE happens to return last silently replaces every earlier bucket, so the map ends up holding a single bucket's running hours.

AvgInstancesPerHour is then computed as TotalRunningHours / windowHours, where windowHours is the full lookback (lookbackDays * 24). So one bucket's hours are divided by the whole window.

The Savings Plans path does this correctly. The RI path is the outlier, and it is structurally unable to control the bucketing: it sets GroupBy and therefore cannot also set Granularity, so the number of buckets CE returns is not under its control.

Failure scenario

GetRICoverageMap(ctx, 30, ["us-east-1"]) run on the 2nd of a month. CE returns two buckets: the previous month, and the 2-day partial current month. The 2-day bucket wins.

A pool genuinely running 40 instances/hr reports TotalRunningHours of roughly 1920 (40 x 48h), divided by windowHours = 720, giving AvgInstancesPerHour = 2.67 instead of 40, roughly a 15x understatement. Pct is likewise taken from a 2-day sample rather than the requested 30 days.

ApplyCoverageMapToRecommendations then rescales every recommendation's AverageInstancesUsedPerHour down by that factor (recs[i].AverageInstancesUsedPerHour *= cov.AvgInstancesPerHour / recSum), so a --target-coverage run buys roughly 15x fewer RIs than the pool actually needs. The under-buy is silent: no error, no warning, and the numbers look internally consistent. The same distortion feeds the ladder's EC2 coverage input via computeEC2CoveragePct.

The magnitude depends on where in the month the run happens, so the same account sized on the 2nd and on the 28th produces materially different purchase plans from identical usage.

Fix direction

Accumulate TotalRunningHours and hour-weighted coverage across all CoveragesByTime entries per pool key before dividing, mirroring spCoverageAccumulator.add / .summarize, instead of last-write-wins. Add a test that feeds two buckets of unequal length for the same pool key and asserts the summed hours (this fails pre-fix, where only the last bucket survives).

Related

Findings from the 2026-09-02 codebase audit

Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.

A07-019 (medium)

The sibling this issue points at as correct has the same divisor bug. spCoverageAccumulator.add increments a.days only for buckets that carried a Coverage block (providers/aws/recommendations/sp_coverage.go:222-240), but summarize at :246-248 divides the accumulated dollars by the caller's lookbackDays*24 unconditionally (:355). Cost Explorer lags 24-48h and omits buckets with no eligible activity, so a 30-day request that returns 10 populated days reports CoveredUSDPerHour and EligibleUSDPerHour at a third of the true rate. Days is returned so a caller could detect it, but spCoverageAdapter.GetSPCoverageSummary (providers/aws/ladder/adapters.go:257) keeps only CoveragePct and no caller reads Days. CoveragePct itself is a ratio and is unaffected, and a repo-wide grep finds no production reader for the three rate fields, so nothing is displayed wrong today. Worth fixing in the same pass so the accumulator pattern this issue adopts is correct at the source. Finding A07-019.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions