Skip to content

Commit 4b2d54d

Browse files
authored
fix(cli): rescale count-derived money when --override-count replaces a row's count (#1844)
--override-count replaced a recommendation's Count without touching the money derived from it, so the reported savings stopped corresponding to what would be bought. This is the sibling of the truncation defect fixed in #1830 and reuses that helper rather than adding a second scaling path, since two implementations of the same arithmetic drift. On the semantics, which was the judgement call: scaling up is accurate rather than fabricated, because RI and Savings Plan unit pricing has no volume tiering, so 200 units cost exactly twice 100. The alternatives were rejected with reasons. Capping savings while scaling costs needs bespoke arithmetic outside the shared helper and yields an internally inconsistent row where EstimatedSavings no longer equals OnDemandCost minus CommitmentCost. Refusing outright is disproportionate for a documented flag whose purpose is to override the provider, since an operator may legitimately know demand is growing. The chosen behaviour scales and warns explicitly, which is the warn-loudly branch rather than a silent fallback. The warning boundary is RecommendedCount, not the row's current Count. Overriding back up to a figure the provider itself proposed is interpolation within its own evidence, not extrapolation beyond it: --coverage 80 sizes a 100-instance proposal down to 80, so an override back to 100 should not warn. RecommendedCount is populated only on the AWS RI path and stays zero for Savings Plans, --input-csv and non-AWS, so it falls back to the current Count where the provider recorded nothing. Both sides of that boundary are pinned by a test. Ordering with --max-instances composes to a single net ratio against the provider's figures, so there is no double-scaling. Pre-fix the cap derived its ratio from a base the override had already corrupted and faithfully preserved the error; post-fix the base is correct before the cap sees it, pinned against an independently computed expectation. Verified by mutation, each failure by assertion rather than panic, including the mutation that makes evidencedCount ignore RecommendedCount and so warn on interpolation. Absent-versus-zero is preserved: a nil pointer stays nil rather than gaining a fabricated zero. The --override-count functions are extracted into cmd/helpers_count_override.go as a pure move, 0 additions and 87 deletions in cmd/helpers.go with an identical 24-function set either side. That file was already 813 lines before this PR and is now 794, below where it started; the remaining excess is pre-existing debt across six files under cmd/, tracked in #1834. Deferred and tracked: #1845 (truncation does not re-derive ProjectedCoverage), #1834 (the file-size baseline). Closes #1844
1 parent 560175e commit 4b2d54d

5 files changed

Lines changed: 411 additions & 26 deletions

File tree

‎cmd/helpers.go‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -492,25 +492,6 @@ func applySizing(recs []common.Recommendation, cfg Config, coverage float64, dro
492492
return applyCoverage(recs, coverage, drops)
493493
}
494494

495-
// ApplyCountOverride overrides the count for all recommendations.
496-
//
497-
// It does NOT rescale the count-derived money fields, so an overridden row
498-
// still carries the savings of the count the provider proposed. That is the
499-
// same defect ApplyInstanceLimit had before #1830, on a neighboring flag,
500-
// and it runs upstream of the cap. Tracked by #1844.
501-
func ApplyCountOverride(recs []common.Recommendation, overrideCount int32) []common.Recommendation {
502-
if overrideCount <= 0 {
503-
return recs
504-
}
505-
result := make([]common.Recommendation, len(recs))
506-
for i := range recs {
507-
rec := recs[i]
508-
result[i] = rec
509-
result[i].Count = int(overrideCount)
510-
}
511-
return result
512-
}
513-
514495
// ApplyInstanceLimit truncates recs so their total Count does not exceed
515496
// maxInstances. It is a single-shot cap over whatever slice it is handed: the
516497
// caller is responsible for handing it the complete run-wide set, because

‎cmd/helpers_count_override.go‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"github.com/LeanerCloud/CUDly/pkg/common"
5+
)
6+
7+
// ApplyCountOverride replaces the count on every count-denominated
8+
// recommendation with overrideCount, rescaling the money that count derives so
9+
// the row describes the quantity the run will actually buy.
10+
//
11+
// EstimatedSavings, CommitmentCost, OnDemandCost and RecurringMonthlyCost are
12+
// whole-row totals for the count the provider proposed, so replacing Count
13+
// alone leaves the row claiming the money of a quantity nobody will buy
14+
// (#1844). The scaling goes through common.ScaleRecommendationCosts, the same
15+
// helper ApplyInstanceLimit and the coverage paths use, so the arithmetic
16+
// cannot drift between the flags. ProjectedCoverage / ProjectedUtilization are
17+
// count-linear but not re-derived here, exactly as after a cap (#1845).
18+
//
19+
// Savings Plans are left entirely alone, Count included. The flag is
20+
// documented as an override for "all selected RIs", an SP commitment is
21+
// dollar-denominated rather than count-denominated, its Count is a fixed
22+
// placeholder 1 set by the parser, and the SP purchase call reads
23+
// HourlyCommitment rather than Count. Scaling an SP by an instance count would
24+
// multiply the dollars actually committed by an unrelated number.
25+
//
26+
// A row with a non-positive Count is left alone for the same reason
27+
// ApplyInstanceLimit never rescales one: there is no denominator to form a
28+
// ratio from, and setting Count without scaling would reintroduce precisely
29+
// the misstatement above.
30+
//
31+
// Scaling down is unambiguous. Scaling up past the quantity the provider's own
32+
// figures cover is an extrapolation: unit prices are linear so CommitmentCost
33+
// stays true, but savings only accrue on hours a matching resource actually
34+
// runs, so units beyond the observed demand cost money and may save nothing.
35+
// Those rows are reported rather than passed off as measured savings.
36+
//
37+
// Both call sites run this before the run-wide --max-instances cap, so a run
38+
// using both flags scales once from the override ratio and once from the cap
39+
// ratio, which compose to a single net ratio against the provider's figures.
40+
func ApplyCountOverride(recs []common.Recommendation, overrideCount int32) []common.Recommendation {
41+
if overrideCount <= 0 {
42+
return recs
43+
}
44+
result := make([]common.Recommendation, len(recs))
45+
var skippedSP, skippedNonPositive, extrapolated int
46+
for i := range recs {
47+
rec := recs[i]
48+
switch {
49+
case common.IsSavingsPlan(rec.Service):
50+
result[i] = rec
51+
skippedSP++
52+
case rec.Count <= 0:
53+
result[i] = rec
54+
skippedNonPositive++
55+
default:
56+
result[i] = common.ScaleRecommendationCosts(rec, float64(overrideCount)/float64(rec.Count))
57+
result[i].Count = int(overrideCount)
58+
if int(overrideCount) > evidencedCount(rec) {
59+
extrapolated++
60+
}
61+
}
62+
}
63+
reportCountOverride(overrideCount, skippedSP, skippedNonPositive, extrapolated)
64+
return result
65+
}
66+
67+
// evidencedCount is the largest count a recommendation's money figures are
68+
// evidence for: the provider's own pre-sizing proposal when it recorded one,
69+
// otherwise the count the row currently carries. RecommendedCount is populated
70+
// only on the AWS RI path, so the fallback is what the --input-csv and
71+
// non-AWS paths use.
72+
func evidencedCount(rec common.Recommendation) int {
73+
if rec.RecommendedCount > rec.Count {
74+
return rec.RecommendedCount
75+
}
76+
return rec.Count
77+
}
78+
79+
// reportCountOverride names what --override-count could not honor and what it
80+
// honored only by extrapolation. Nothing here is fatal, but every case would
81+
// otherwise change or fail to change a money figure without telling anyone.
82+
func reportCountOverride(overrideCount int32, skippedSP, skippedNonPositive, extrapolated int) {
83+
if skippedSP > 0 {
84+
AppLogger.Printf("⚠️ --override-count left %d Savings Plans recommendation(s) unchanged: an SP commitment is priced in dollars per hour, not in instances, so an instance count cannot size it.\n", skippedSP)
85+
}
86+
if skippedNonPositive > 0 {
87+
AppLogger.Printf("⚠️ --override-count left %d recommendation(s) with a non-positive count unchanged: there is no quantity to rescale their costs from.\n", skippedNonPositive)
88+
}
89+
if extrapolated > 0 {
90+
AppLogger.Printf("⚠️ --override-count=%d exceeds the quantity the provider's figures cover on %d recommendation(s). Their costs scale with the count and stay accurate, but the savings are extrapolated past the observed demand: instances beyond it are billed and may save nothing.\n", overrideCount, extrapolated)
91+
}
92+
}

0 commit comments

Comments
 (0)