Skip to content

fix(aws): credit replacement RIs bought inside the coverage lookback in the EC2 expiry deduction - #449

Merged
cristim merged 1 commit into
mainfrom
fix/346-ec2-expiry-replacement-lookback
Oct 16, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/346-ec2-expiry-replacement-lookback

Conversation

@cristim

@cristim cristim commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

The EC2 per-pool expiry adjustment (providers/aws/recommendations/expiry.go) subtracts RIs that expire inside the window from lagged Cost Explorer coverage. It gave no credit for replacement RIs already bought inside the coverage lookback. Lagged coverage does not see those yet, so a re-run recommended buying the replacements again. The RDS family path fixed the same problem in #347 (family_nu.go:392-523). This ports that rule to the EC2 per-pool path.

New API

  • PoolExpiryOptions{WindowDays, CoverageLookbackDays}.
  • AdjustExistingCoverageForExpiringCommitmentsWithCoverageOptions(recs, commitments, coverage, opts).

Rule

Per pool, R is the summed Count of commitments that meet all three conditions:

  • they are active or payment-pending;
  • they do not expire inside the window;
  • their StartDate is after now minus CoverageLookbackDays, credited only when that is above 0.

The deduction becomes max(0, expiring - R). Pools that net to zero are skipped. The 2-line lookback check mirrors family_nu.go:511-512 and has a comment pointing there. family_nu.go itself is unchanged.

Compatibility

The existing ...WithCoverage function now delegates with no lookback. Its existing tests are unchanged and green, and its callers see no change until they pass a lookback. The CLI, MCP and platform need to call the new function with the same lookbackDays they give the coverage fetch. Only the CLI calls it today (cmd/multi_service_helpers.go:540); its adoption is tracked in LeanerCloud/cloud-commitments-cli#2235. MCP and platform do not call it. The size-flex and pool-key limitations are tracked in #450.

Godoc limitations

  • The rule is conservative. It can under-buy, never over-buy, because R is credited in full although lagged coverage already holds part of it.
  • A growth purchase in the same pool inside the lookback is indistinguishable from a replacement.
  • A replacement of a different size in the same family is not credited (a size-flex gap).

Edge change on the zero-lookback path: an expiring RI with a negative Count used to raise coverage. It is now clamped to 0.

Pools are org-wide (commitmentPoolKey has no account dimension), so a replacement in another linked account is credited. That is consistent with org-wide coverage and RI sharing.

CHANGELOG.md has an Unreleased / Added entry, in the style of #347.

Closes #346

Verification (macOS, Go 1.26.9)

Build and lint

  • (cd providers/aws && GOWORK=off go test -race -short ./...): passes.
  • make build, make vet: pass.
  • golangci-lint v2.10.1 (CI pin): 0 issues.
  • gocyclo -over 10: clean.

New tests (expiry_lookback_test.go, 11 top-level):

  • replacement inside the lookback gives no re-buy, measured against a baseline from the same fixture with no commitments;
  • replacement before the lookback;
  • partial replacement and over-replacement;
  • retired, expired, queued and pending-return are not credited, and payment-pending is credited;
  • region, type and service mismatch;
  • account is ignored;
  • a pool with nothing expiring;
  • missing demand;
  • lookback 0 still deducts;
  • skipped recs keep their seeded ExistingCoveragePercentExact, both pointer and value.

Fail-before. With the credit disabled, 7 test functions fail: InsideLookback, BeforeLookback, PartialAndOverReplacement, States, PoolMatching, MissingDemand, SkippedRecs. The independent reviewer re-ran this in a scratch copy.

Mutation probes (reviewer, scratch copy), each failing tests:

  • drop the lookback gate;
  • drop the max(0, ...) cap;
  • ignore the state filter;
  • adjust pools that net to zero.

Review. The plan had 2 passes and the diff 2 passes, all by the same non-author reviewer, with no actionable findings on tree b9bf8002. One test literal changed after review: the "account ignored" row uses "other-linked-account" instead of a 12-digit account ID that tripped the git-secrets hook. The behaviour under test is unchanged.

Fixtures only. No AWS call or purchase.

🤖 Generated with Claude Code

…in the EC2 expiry deduction

The per-pool expiry adjustment subtracted expiring RIs from lagged Cost
Explorer coverage without seeing replacements bought inside the coverage
window, so a re-run recommended buying them again. The new
AdjustExistingCoverageForExpiringCommitmentsWithCoverageOptions credits
active or payment-pending RIs that started after now minus
CoverageLookbackDays and do not expire in the window, and deducts
max(0, expiring - replacements) per pool, mirroring the RDS family path
from #347. The existing function delegates with no lookback, so its
callers see no change until they pass one.

Closes #346

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

  • 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 52 minutes for your next included review.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-go/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 2b12f763-6cb9-4d7d-8610-607f81c29dc8
📥 Commits

Reviewing files that changed from the base of the PR and between 4566498 and 0f74be1.

📒 Files selected for processing: 3
  • CHANGELOG.md
  • providers/aws/recommendations/expiry.go
  • providers/aws/recommendations/expiry_lookback_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/bug Defect triaged Item has been triaged labels Oct 16, 2026
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Independent review verdict: CLEAN at 0f74be1

Reviewer: fresh Claude agent (Sonnet, high effort), not the author. Coordinator: claude-cc-reviewer.

Formula, per pool key = region + instance type (commitmentPoolKey, expiry.go ~214):

  • Before: deducted = E, the sum of Count for active or payment-pending RIs with EndDate <= now+window.
  • After: deducted = max(0, E − R). R is the sum of Count for active or payment-pending, non-expiring RIs in the same key with StartDate > now−lookback, credited only when CoverageLookbackDays > 0. This mirrors family_nu.go:511-512.

Hand-checked scenarios (coverage 60% on demand 30, E=10 expiring in 15d, lookback 30):

Replacement Result
R=4 started 5d ago 40% (credited)
R=4 started 45d ago, or exactly 30d ago 26.67% (not credited; the bound is strict)
R=4 payment-pending, 3d in the future 40% (credited, conservative)
R=20 over-replacement net 0; the deduction never goes negative
R=4 other type or other region not credited
  • The replacement is credited once, only to the matching key. The deduction is <= E, so it is never worse than before.
  • The RDS path (rdsPoolKey) is unchanged, and the ...WithCoverage wrapper passes no lookback, so existing callers are unchanged. The fix is inert until the CLI passes the lookback (cli#2235).
  • go vet ./recommendations/... is clean, and go test -race -count=1 ./recommendations/... passes 840.
  • Mutation probes: disabling the credit fails 7 tests. Dropping the lookback gate, doubling the credit, and ignoring key matching are each killed. Fail-before: reverting expiry.go fails to compile (PoolExpiryOptions undefined), so the behavioural proof is the disabled-credit mutation.

Non-blocking:

Gate:

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

CI is green on head 0f74be18: pre-commit 38934006766 and Build & Test 38934006740.

@cristim
cristim merged commit 512e42f into main Oct 16, 2026
15 checks passed
@cristim
cristim deleted the fix/346-ec2-expiry-replacement-lookback branch October 16, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EC2 per-pool expiry path re-buys replacement RIs bought inside the coverage lookback

1 participant