Repository navigation
fix(ri-exchange): exclude convertible RIs with no pricing from cost math - #1191
Conversation
monthlyCostFromConvertibleRI returned 0 for a convertible RI whose FixedPrice, UsagePrice and RecurringHourlyAmount are all zero. reshape's pricing gate treats MonthlyCost <= 0 as "no source pricing" and passes every alternative, and costComponent scores it as neutral, so such an RI read as free. A real convertible RI always carries an upfront or hourly charge, so an all-zero price set means missing pricing data. The function now also returns an error when the computed monthly cost is not positive (all-zero, a negative sum, or NaN), the same way LeanerCloud#1187 handles a non-positive duration: convertToExchangeTypes (api) reports the RI in excluded_ris with a pricing-specific reason and convertForAutoExchange (server) logs a warning and drops it. No Upfront RIs (FixedPrice 0 with an hourly charge) are unaffected. The reshape notice now shows each excluded RI's reason instead of always saying AWS returned no term length. Test fixtures that modelled RIs with no pricing now carry a price. Closes LeanerCloud#1188 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 11 billable files and costs up to $2.75. Or wait 54 minutes for your next included review. View limit details
Comment |
The backend now names each excluded RI's reason in excluded_ris; the notice text change is left for the frontend owner (see PR body). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review 2 (claude-fleet, cold): APPROVE e93092f Findings (none blocking):
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: REQUEST_CHANGES e93092f Base: origin/main 9fceda3. Findings
Consistency with #1187: same shape. The duration check runs first; the API path reports the RI in Attacked:
|
…RI copies Note in each copy that the other exists and how they differ (only the api copy wraps the sentinels), and say that convertForAutoExchange also serves the ladder write path, naming both in its exclusion log line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This replies to the VERDICT (REQUEST_CHANGES) and Independent review 2 on e93092f. The new head is 9df76b6. Merged origin/main (7998620; no rebase). The merge was clean. Main's changes to VERDICT findings
Independent review 2
Verification, on the merged tree at 9df76b6 ( The PR stays a draft. |
|
VERDICT: REQUEST_CHANGES 9df76b6 Delta e93092f..9df76b6: a merge of main c8bdd46 plus 9df76b6. That commit only adds comments: "keep in sync" notes on both monthlyCostFromConvertibleRI copies, and the convertForAutoExchange doc and log text now mention the ladder path. There is no code or test change, and the frontend is untouched. e93092f (already the old head) is the commit that reverted the frontend fix. Findings
Money path: the formula is unchanged and the guard runs after the duration check. NaN, 0 and negative costs are rejected. No Upfront and usage-only RIs are kept. This is consistent with #1187: same sentinel style and the same exclusion slot. The server copy deliberately has no sentinels, its callers (handler_ri_exchange.go:140, ladder_write.go:59) log and drop, and its comment says so. Attacked: merge-tree vs origin/main 6af022f is clean (tree 47096e8d). On the merged tree: go test ./internal/api ./internal/server gave 4702 passed. Integration (testcontainers, under lockf): TestReshapeRecommendations_Integration_* 4/4 PASS and TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient PASS.
CI on 9df76b6: Build Docker Image, Security Scanning and CI Success fail on GO-2026-6809 (stdlib, fixed in 1.26.10). That comes from the old base (go 1.26.9 at c8bdd46); main is now 1.26.10. Everything else passes. |
Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts in both monthlyCostFromConvertibleRI copies: main (LeanerCloud#1260) replaced them with one shared ricost.MonthlyCostFromConvertibleRI, and this branch added the no-positive-price exclusion (LeanerCloud#1188) to each copy. Kept main's single implementation and moved this branch's check into it, with the sentinels exported as ricost.ErrNonPositiveDuration and ricost.ErrNoPricing so the API's excludedRIReason still picks the per-RI reason. Both wrappers now just delegate, so the "keep in sync" notes are gone. ricost's table pinned "zero prices -> 0, no error" as today's behavior "rather than endorsing it"; that row now expects the no-pricing error, and a negative-sum row is added. The 4-eyes fixture added on main (LeanerCloud#1108) used an unpriced RI, which is now excluded, so it gets the same FixedPrice the other fixtures on this branch use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged current main (c8bb869) into the branch, in two merge commits: fd374a9, then 0a8f0ce for #1426. New head: Merge resolution. Main's #1260 replaced both
@cristim, your REQUEST_CHANGES on 9df76b6, point by point
Checks on the merged tree, with no cloud credentials:
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC6. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Root cause
monthlyCostFromConvertibleRI(in bothinternal/api/handler_ri_exchange.goandinternal/server/handler_ri_exchange.go) computed(FixedPrice / hours_per_term + UsagePrice + RecurringHourlyAmount) * 730with no check on the result. A convertible RI with all three price fields at zero gotMonthlyCost0. In cloud-commitments-gopkg/exchange/reshape.go,pricingGatePassesreturns true forMonthlyCost <= 0andcostComponentscores it as a neutral 0.5, so the RI read as free and every cross-family alternative passed the dollar-units gate. A real convertible RI always has an upfront or hourly charge, so an all-zero price set means AWS returned no pricing.Fix
This takes the issue's first option and follows the pattern #1187 used for a non-positive duration. The change is platform-side only; the go library is untouched.
monthlyCostFromConvertibleRInow also returns an error when the computed monthly cost is not positive. That covers an all-zero price set, a negative sum and NaN (!(cost > 0)). The duration check still runs first.errRINonPositiveDuration/errRINoPricing).convertToExchangeTypesusesexcludedRIReason(err)so the RI appears inexcluded_riswith a pricing-specific reason instead of the term-length one. It is logged with a warning and given no recommendation.convertForAutoExchangelogs a warning and drops the RI, the same as the duration case. It has two callers, the auto-exchange run (handler_ri_exchange.go) and the ladder write path (ladder_write.go), so the change applies to both. The library'sAnalyzeReshapingdoes not readMonthlyCosttoday, so this is a behavior change on both paths: an underused RI whose pricing is unknown is no longer auto-exchanged or ladder-reshaped. It fails closed (no money moves), and the only signal is the warning log; the auto-exchange result and notifications do not report the skip.FixedPrice0 with a recurring hourly charge) and usage-priced RIs are unaffected. Tests cover both shapes from the issue.openapi.yaml'sexcluded_risdescription now says each entry carries its own reason (no term length, or no pricing).renderExcludedRIsNoticeinfrontend/src/riexchange.tshard-codes "left out because AWS returned no term length", so for an unpriced RI the notice will name the wrong cause. Showing each entry'sreason(e.g.${e.ri_id}: ${e.reason}) would fix it. A change was drafted and then reverted in e93092f, because frontend work is left to the UI owner. Merge order: the maintainer review asks for that fix to land in a frontend PR merged before this one (or together with it). Until then the UI notice names the wrong cause for an unpriced RI.FixedPrice, because otherwise they would now be excluded. Where a test depends on a cross-family alternative passing the dollar-units gate (TestGetReshapeRecommendations_AlternativeCurrencyFromRIsand the reshape integration tests), the price is small enough (100 over 1y, about 8.33/month) that the seeded alternatives still pass. A comment explains the arithmetic.This PR doesn't change the approve/reject token paths that #1172 / #1175 edit. The
openapi.yamlhunk (around line 1333) and the test-file appends don't overlap their hunks.Verification
All of the evidence below comes from unit, mock and fixture tests plus local Postgres integration tests. No real AWS account was used: the RI lists come from fakes, and nothing called a cloud API. Go tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid.New tests:
TestConvertToExchangeTypes_ExcludesRIWithoutPricing(all-zero and negative excluded with the pricing reason; No Upfront, usage-only and all-upfront kept with the correct cost),TestGetReshapeRecommendations_ReportsUnpricedRIs(handler end to end),TestMonthlyCostFromConvertibleRI_NoPositivePriceErrors(all-zero, negative and NaN; sentinel wrapping; duration checked first).TestConvertForAutoExchange_ExcludesRIWithoutPricing,TestMonthlyCostFromConvertibleRI_NoPositivePriceErrors.TestReshapeRecommendations_Integration_EndToEndnow also seeds an unpriced RI and asserts it is reported with the pricing reason.Fail-before / pass-after:
internal/server/handler_ri_exchange.goreverted toorigin/main:TestConvertForAutoExchange_ExcludesRIWithoutPricingFAIL (gotri-unpricedwith MonthlyCost 0 andri-negativewith -73),TestMonthlyCostFromConvertibleRI_NoPositivePriceErrorsFAIL (all zero: ... got cost=0 err=<nil>). Both pass with the fix.should have 2 item(s), but has 1)cost < 0: 3 FAILcost <= 0(no NaN handling): the NaN case FAILsCommands, all passing on the pushed SHA unless noted:
Gaps: I haven't checked against a real AWS account that
DescribeReservedInstancesnever returns an all-zero price set for a valid RI. The fix relies on the library populatingRecurringHourlyAmountfromRecurringCharges, whichListConvertibleReservedInstancesdoes in the pinned version.Out of scope
FixedPriceis missing while the hourly parts are populated, which ends up silently under-priced. That can't be told apart from a legitimate No Upfront RI using these fields, so this PR keeps such RIs. Telling them apart would needOfferingTypechecks or the library's "price unknown" option (the issue's alternative fix).pricingGatePassesrefuses, isn't done here.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,triaged,urgency/this-quarter,impact/few,effort/xs,type/bug.Closes #1188
🤖 Generated with Claude Code