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

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