Repository navigation
refactor(ri-exchange): share the convertible RI monthly cost formula - #1260
Conversation
internal/api and internal/server each carried a verbatim copy of monthlyCostFromConvertibleRI with a bare 730. Move the one implementation to the new internal/ricost package with a named HoursPerMonth constant; both packages' monthlyCostFromConvertibleRI now delegate to it. Results are bit-for-bit unchanged; a table test pins the exact values the old copies returned, including the non-positive duration guard and zero prices. The frontend half of the issue (730 vs 1/720) is left for the UI owner. Refs LeanerCloud#337 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
|
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: rB10. 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: rB13. 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 |
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>
Root cause
monthlyCostFromConvertibleRIexisted twice, verbatim, ininternal/api/handler_ri_exchange.go(reshape recommendations) andinternal/server/handler_ri_exchange.go(auto-exchange and ladder paths). Each copy had its own doc comment and a bare* 730. The two copies could drift, and the dollar-units pre-filter depends on both sides computing the same number.Fix
internal/ricost(monthly_cost.go) holds the only implementation,ricost.MonthlyCostFromConvertibleRI, plusHoursPerMonth = 730.0. Its comment explains that this is AWS's canonical hours-per-month convention (8760 h / 12) and matcheshoursPerMonthin cloud-commitments-go'sproviders/aws/recommendations. That constant is unexported in another module, so it can't be imported here. The3600seconds-to-hours divisor is named too (secondsPerHour).internal/apiandinternal/server,monthlyCostFromConvertibleRIis now a one-line delegator to the shared function. I kept the package-local name on purpose. The call sites (convertToExchangeTypes,convertForAutoExchange) and the existing tests in both packages stay as they are. Open PR fix(ri-exchange): exclude convertible RIs with no pricing from cost math #1191 also adds tests in both packages that call this name, and they keep compiling after a merge.internal/serveralready importsinternal/api, but putting cost math into the HTTP handler package just so the scheduler side can reach it would couple them the wrong way. The new package depends only on the go library'sec2svc.ConvertibleRI.Refs #337
Refs, not a closing keyword, because the issue's frontend acceptance criterion (730 vs 1/720) is not part of this PR. See the follow-up below.Verification
All of this is unit-level evidence. No cloud API was called. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.New test
internal/ricost/monthly_cost_test.go:TestMonthlyCostFromConvertibleRIis a table test covering 1y and 3y all-upfront, no-upfront, partial-upfront, usage-price-only, all three components, zero prices, a 1-second term, and zero duration, negative duration, and zero duration plus zero prices (the guard). It compares the float64 results exactly (assert.Equal, notInDelta) and checks the error text exactly. The expected values were taken by running the old code onorigin/main(a14dc8f) through both old copies. The api and server outputs were identical to each other.TestHoursPerMonthchecks that the constant is 730 = 365*24/12.Before/after evidence:
monthlyCostFromConvertibleRI, was run in a throwaway worktree atorigin/maina14dc8f. It passes in bothinternal/apiandinternal/server. This is a refactor, so the test is meant to pass before and after; it guards against behavior change rather than reproducing a bug.ricost.MonthlyCostFromConvertibleRI. A temporary capture test printed every case's result (strconv.FormatFloat(v, 'g', -1, 64)) and error through each package'smonthlyCostFromConvertibleRI, before and after the change. The output was byte-identical in both packages, and the temporary file was then deleted.internal/ricost/monthly_cost.go(each was reverted afterwards):HoursPerMonth730 to 720: FAILsecondsPerHour3600 to 3660: FAIL<= 0to< 0: FAILUsagePrice: FAILRecurringHourlyAmount: FAILFixedPrice*730/hours/730) still passes, because it rounds the same on these inputs. The fixed cases don't catch every possible rounding-order change. The shipped expression is textually the old one, so this only matters for future edits.Commands (all passed at ff3420c):
Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree:
queue/platform@936996ad= origin/maina14dc8f2plus these heads, merged in this order:fix/issue-72andfix/issue-312conflict on one line ininternal/api/handler_dashboard.go(both change thecalculateCommitmentMetricscall). The tree used #72's side with #312's validatedprovidervariable; whichever PR merges second takes that same resolution.Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:936996ad:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without--build-tags integration.tests/e2eis a separate module, so it was vetted under its owne2etag.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1, 60-minute local timeout because the VM was shared):936996ad, these pass:internal/server,internal/credentials,internal/scheduler,internal/database/postgres/migrations,internal/purchaseandinternal/api.internal/configpasses exceptTestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).26795376, same branches at earlier heads):internal/analytics,internal/auth,internal/commitmentopts,internal/databaseandcmd/lambda/clear-rate-limitall pass. The later fixes don't touch those packages.httpToLambdaRequestsignature change;scripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.Overlap with other open PRs
fix/issue-1188, exclude convertible RIs with no pricing) rewrites the bodies and doc comments of bothmonthlyCostFromConvertibleRIcopies. It adds a!(cost > 0)check, and in api it wraps the errors in the sentinelserrRINonPositiveDuration/errRINoPricing. Those are the same lines this PR replaces, so whichever merges second will get a textual conflict in both handler files. Once both land, the pricing check belongs inricost.MonthlyCostFromConvertibleRI, so it exists in one place. The sentinels would then move to (or be exported from)internal/ricost, so api'sexcludedRIReasoncan still useerrors.Is. fix(ri-exchange): exclude convertible RIs with no pricing from cost math #1191's tests call the package-localmonthlyCostFromConvertibleRI, which this PR keeps, so they need no rename. Note thatTestMonthlyCostFromConvertibleRI's "zero prices" row pins today's0, nilresult. fix(ri-exchange): exclude convertible RIs with no pricing from cost math #1191 makes that an error on purpose, so that row has to change to expect an error when the two are combined.internal/api/handler_ri_exchange.goonly around lines 1312 and 2020 (the exchange refusal handling). There is no overlap with this PR's hunks (the import block and the function around line 1600).internal/apiimport tointernal/server/handler_ri_exchange.gobetweenec2svcandconfig. This PR adds thericostimport afteremail, two lines away, so it should merge cleanly. The other open PRs that touch these files (fix(config): validate ri_exchange_mode on PUT /api/config #1243, fix(api): answer a missing RI exchange like a wrong token on approve and reject #1175, fix(api): answer expired and lost-race token actions alike on revoke and RI exchange #1172, fix(db): let ri_exchange_history.account_id hold Azure and GCP identifiers #1170, fix(auth): bind group account scope entries to account IDs #1116, fix(ri-exchange): run unattended exchanges as manual while 4-eyes is on #1108) edit unrelated hunks.Out of scope
730comments and thebasisNotestring ininternal/archera/dto.go("730-hour monthly rates") describe the convention in user-facing text. They aren't arithmetic, so I left them alone.Frontend follow-up for the UI owner
This PR changes nothing under
frontend/. The issue's frontend criterion is still open:frontend/src/modules/savings-history.ts:25usesHOURS_PER_MONTH = 730for monthly to hourly. Its comment// 365.25 * 24 / 12is wrong: that works out to 730.5. 730 comes from 365 * 24 / 12.frontend/src/recommendations.ts:1655(PERIOD_FACTOR.hourly: 1 / 720 // 24 × 30 hrs/mo, used byscaleCost) converts the same API monthly figures to hourly with 720. The backend and the AWS-derived monthly figures use 730-hour months, so this view's hourly numbers come out about 1.4% higher (730/720) than savings-history's for the same monthly value. Suggested fix: export a singleHOURS_PER_MONTH = 730and use1 / HOURS_PER_MONTHinPERIOD_FACTOR. The same table'sdaily: 1 / 30makes the same 30-day assumption (730 h = 30.42 days), so it's worth deciding on it at the same time.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p3,severity/low,urgency/eventually,impact/internal,effort/s,type/chore,triaged.🤖 Generated with Claude Code