Skip to content

fix(purchase): stamp the purchased EC2 offering class on history - #509

Merged
cristim merged 2 commits into
mainfrom
fix/purchase-honour-offering-class
Oct 5, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/purchase-honour-offering-class

Conversation

@cristim

@cristim cristim commented Oct 5, 2026

Copy link
Copy Markdown
Member

What

savePurchaseHistory stamped a literal "convertible" into purchase_history.offering_class for every AWS EC2 purchase. The EC2 client in the pinned cloud-commitments-go/providers/aws (services/ec2/client.go resolveOfferingClassType) buys the class from global_config.offering_class, which can be "standard". The stamp also matched only the legacy "ec2" slug, so canonical "compute" recs got no class at all.

The marketplace-sell guard (internal/api/handler_marketplace.go) reads this column instead of asking AWS, so every Standard RI bought by CUDly was recorded as unsellable.

How

  • processPurchaseRecommendations resolves the configured class once into an ec2types.OfferingClassType (resolveEC2OfferingClass, same mapping as the pinned client: "" and "convertible" give Convertible, "standard" gives Standard).
  • An unknown value returns errUnknownOfferingClass before any provider client is built. Previously the error came per EC2 rec from the provider. Config writes already reject unknown values (config.validateOfferingClass), so this only fires on a tampered or corrupted row.
  • savePurchaseHistory stamps the resolved class for AWS recs whose service maps to ServiceEC2 or ServiceCompute (via the existing mapServiceType).
  • The PurchaseOptions.OfferingClass value sent to the provider is unchanged.

Recommendations carry no offering class in the pinned library (common.ComputeDetails has none). The class is a purchase-time setting, so this PR leaves the purchase request alone and only fixes the recorded value.

Verification

GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true

  • New tests in internal/purchase/money_path_regression_test.go drive processPurchaseRecommendations with GlobalConfig.OfferingClass set, capture the provider's PurchaseOptions and the saved history row.
  • Against origin/main's execution.go:
    --- FAIL: TestOfferingClassStampedOnHistoryMatchesPurchase/standard_legacy_slug     expected: "standard" actual: "convertible"
    --- FAIL: TestOfferingClassStampedOnHistoryMatchesPurchase/standard_canonical_slug  expected: "standard" actual: ""
    --- FAIL: TestOfferingClassStampedOnHistoryMatchesPurchase/unset_means_the_client_default  expected: "convertible" actual: ""
    --- FAIL: TestUnknownOfferingClassRefusesBeforePurchase  An error is expected but got nil.
    
  • With the fix: all pass. Mutation probe (stamp literal Convertible again) fails the two standard cases.
  • go build ./..., go vet ./..., go vet -tags integration ./internal/...: clean.
  • go test ./internal/purchase/ ./internal/api/ ./internal/server/: 3276 passed.
  • golangci-lint run ./internal/purchase/...: no issues.

All evidence is mock-based (fixture provider and store); no real purchase was made. No Postgres integration test: the store write path for offering_class is unchanged and already covered by the migration 000087 round-trip test.

Deliberately left

  • Existing history rows already stamped "convertible" for Standard RIs are not backfilled. The marketplace handler only re-fetches from AWS when the column is empty, so those rows stay wrong until a backfill or a re-fetch is done. This needs an owner decision.
  • The hard-coded "convertible" in the RI exchange handlers (internal/api/handler_ri_exchange.go, internal/server/handler_ri_exchange.go) is correct there, because exchanges only apply to Convertible RIs.

Closes #112

savePurchaseHistory wrote a literal "convertible" into
purchase_history.offering_class for every AWS EC2 purchase, but the EC2
client buys the class from global_config.offering_class, which can be
"standard". The marketplace-sell guard reads this column instead of
asking AWS, so every Standard RI was recorded as unsellable. The guard
also matched only the legacy "ec2" slug, so canonical "compute" recs got
no class at all.

Resolve the configured class once into an ec2types.OfferingClassType
using the same mapping as the pinned EC2 client ("" means convertible),
stamp that value for both slugs, and refuse the run with
errUnknownOfferingClass before any purchase when the value is unknown.

Closes #112
@cristim cristim added priority/p2 Backlog-worthy severity/high Significant harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/s Hours type/bug Defect triaged Item has been triaged labels Oct 5, 2026
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 4 billable files and costs up to $1.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 13 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 50 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 0d33669d-a3c7-41f6-9df2-05e28e7a90a4
📥 Commits

Reviewing files that changed from the base of the PR and between e65a8a3 and 8b4a9d5.

📒 Files selected for processing (4)
  • frontend/src/history.ts
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • internal/purchase/money_path_regression_test.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

The class is now stamped from the configured offering class, not a
hard-coded convertible. Comment only.
@cristim
cristim merged commit 63b0cdf into main Oct 5, 2026
27 checks passed
cristim pushed a commit that referenced this pull request Oct 20, 2026
…ws (#1105)

Before #509, savePurchaseHistory stamped every AWS 'ec2' purchase_history
row 'convertible' regardless of global_config.offering_class, so Standard
RIs bought while the setting was 'standard' are recorded as convertible
and the marketplace-sell guard refuses them. global_config keeps no
history, so only AWS can tell those rows apart.

Migration 000133 implements option (a) from the issue: it sets
offering_class to NULL on AWS 'ec2'/'compute' rows stamped convertible,
so the marketplace-list handler's populateOfferingClass re-fetches the
class from AWS and re-stamps the row. 'standard' stamps, other services
and other providers are untouched; a 'standard' stamp committed while
the migration waits on the row lock survives (READ COMMITTED re-check).
The down migration is a no-op.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(purchase): purchase_history.offering_class stamped convertible even when a Standard RI was bought

1 participant