Skip to content

fix(purchases): narrow Describe*Offerings calls + cap pagination across 7 services (lookup root cause behind 60s budget overrun) #688

Description

@cristim

Symptom

Execution 9336a111-85c5-4b74-9991-367350de9bac (Lambda 60s budget, dev env, pre-#684) failed with:

EC2: DescribeReservedInstancesOfferings ... canceled, context deadline exceeded
AUDIT LOSS: failed to save execution status: failed to begin transaction: context deadline exceeded

CloudWatch report: Duration: 60000.77 ms -- the Lambda spent its entire budget paginating through DescribeReservedInstancesOfferings and was killed before it could even write the failed status (compounding into #632-style audit loss).

Reproduction via AWS CLI:

aws ec2 describe-reserved-instances-offerings ... --max-results 100
  -> returns 0 offerings + NextToken
aws ec2 describe-reserved-instances-offerings ...  (no max-results)
  -> never returns; killed manually

Distinction from #667

#667 diagnosed the same failure shape as an SDK-retry budget overrun and asserted "This is NOT a pagination issue -- buildOfferingFilters correctly narrows by ... so the result set is typically a single page." The CLI reproduction above falsifies that assertion: AWS returns empty pages with NextToken even for the narrow filter set, walking sparse offerings indefinitely. #684 (closing #667) caps the per-rec wall clock at 30s via context.WithTimeout and makes hangs observable, but the underlying lookup still spends the full budget; the rec fails every time it hits this offering layout. This issue is the lookup-correctness fix that #684's budget cap is masking.

Root cause: offering lookup is under-constrained

providers/aws/services/ec2/client.go:320-346 (buildOfferingFilters):

return []types.Filter{
    {Name: aws.String("instance-type"),       Values: []string{rec.ResourceType}},
    {Name: aws.String("product-description"), Values: []string{platform}},
    {Name: aws.String("instance-tenancy"),    Values: []string{tenancy}},
    {Name: aws.String("scope"),               Values: []string{scope}},
    {Name: aws.String("duration"),            Values: []string{strconv.FormatInt(c.getDurationValue(rec.Term), 10)}},
    {Name: aws.String("offering-class"),      Values: []string{c.getOfferingClass(rec.PaymentOption)}},  // always "convertible"
}

Critical gap: the offering-type filter (payment option) is never set. AWS therefore returns every payment variant of every offering that matches the other filters, and the loop at client.go:367-398 then returns result.ReservedInstancesOfferings[0] from the first non-empty page without verifying its OfferingType matches the requested payment option.

Two failure modes follow:

  1. Deep pagination on sparse offerings: AWS walks pages of empty results with NextToken until it lands on a matching offering set or the budget expires.
  2. Wrong-variant return: if the first non-empty page contains a no-upfront variant when the rec asked for all-upfront (or vice versa), the wrong offering is purchased silently.

The same shape recurs across the sibling services that copy buildOfferingFilters-style logic: RDS, ElastiCache, MemoryDB, OpenSearch, Redshift, SavingsPlans. Each has its own filter syntax (Filters[] for EC2, direct fields for the others) but the same "filter set under-constrains the API" pattern likely applies.

Fix scope: sweep all 7 purchase-path services

For each of providers/aws/services/{ec2,rds,elasticache,memorydb,opensearch,redshift,savingsplans}/client.go:

  1. Narrow the describe call to the minimum needed for a deterministic match. Each service's Describe*Offerings API supports filters beyond what we currently set; identify the maximally narrow filter set (region is implicit via the client's region; instance/node/cluster type, term, payment option, offering class, product description, tenancy/scope, etc. where applicable) and apply all of them.

  2. Add the missing payment-option filter explicitly (EC2 offering-type, equivalents per service):

    Rec value AWS value
    no-upfront No Upfront
    partial-upfront Partial Upfront
    all-upfront All Upfront
  3. Verify the returned offering's payment option matches before returning it (defense in depth -- even with the filter, the loop should reject mismatched variants instead of grabbing offerings[0]).

  4. Cap pagination at a fixed page count (e.g. maxOfferingPages = 5 -- with MaxResults=100 that's up to 500 offerings) and fail fast with an explicit pagination cap reached after N pages error that names the rec. This converts "Lambda timeout + audit loss" into "fast diagnostic error + clean status save".

  5. Honour ctx.Done() between pages so a parent-context cancellation surfaces immediately instead of waiting for the next AWS response.

  6. Increase MaxResults from 100 to 1000 (the AWS max for these APIs) so fewer round-trips are needed when a match is buried.

Acceptance criteria

  • Every findOfferingID (or equivalent) across the 7 services applies the maximally narrow filter set, including the payment-option mapping
  • Each returns an error if AWS hands back an offering whose payment option does not match the rec
  • Each caps pagination at a fixed page count and emits a diagnostic error citing the rec details
  • Each checks ctx.Err() between pages
  • Regression test per service: mock returning N empty pages with NextToken -> assert fast failure with the cap error (not a context-deadline error)
  • Regression test per service: mock returning a non-matching offering on page 1 -> assert it is rejected (not silently purchased)
  • CloudWatch repro: t4g.nano rec on the dev env returns a deterministic error within seconds instead of timing out at 60s

Risk / blast radius

  • Touches all 7 purchase-path service clients. Each has unit tests; new tests are required.
  • The verification step (AWS + Azure: add read-only sanity checks + AWS RI exchange #3) tightens semantics: a previously-silent-wrong-variant purchase becomes a loud error. This is the correct behaviour but may surface real recs that have ambiguous PaymentOption strings.
  • No frontend / API contract change.

Cross-references

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