You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Source: PR #242 shipped UI for #220 + #221, but the underlying data is broken.
Symptoms (from current production data)
AZURE Test Azure Joshua Standard_D2as_v4 3y $107 sav $65 upfront $0 monthly 96.7% eff
AWS CUDly host db.t4g.micro 1y $4 sav $95 upfront $0 monthly -35.6% eff ← negative
AWS CUDly host db.t4g.micro 1y $3 sav $0 upfront $0 monthly 100.0% eff ← infinite ROI
AWS CUDly host t4g.nano 3y $2 sav $0 upfront $0 monthly 100.0% eff ← infinite ROI
AWS CUDly host t4g.nano 1y $1 sav $22 upfront $0 monthly -20.0% eff ← negative
Every row shows $0 monthly. Effective % is negative for ~1y partial-upfront recs and 100% for any rec with $0 upfront. Both are artefacts of the same root cause.
Root cause
internal/scheduler/scheduler.go:881 hardcodes MonthlyCost: 0 when it persists Cost Explorer recommendations:
records=append(records, config.RecommendationRecord{
...UpfrontCost: rec.CommitmentCost,
MonthlyCost: 0, // Cost Explorer doesn't always provide monthly breakdownSavings: rec.EstimatedSavings,
...
})
Two separate problems compound here:
MonthlyCost: 0 is hardcoded. The comment is stale — pkg/common.Recommendation doesn't carry a MonthlyCost field at all (it has OnDemandCost, CommitmentCost, EstimatedSavings), so even if the provider has the data, it's discarded at the provider→common boundary. The scheduler then writes the literal 0.
UpfrontCost: rec.CommitmentCost is mislabeled.CommitmentCost is the total commitment over the term (recurring + upfront). For a no-upfront SP it's the full hourly-rate × hours-in-term and the actual upfront is $0. So the column the frontend reads as "Upfront" is really "Total committed spend" for some payment options.
onDemand = savings + amortized — denominator no longer reflects real on-demand monthly cost.
When upfront_cost is also 0 (e.g. no-upfront recs where the backend mistakenly set CommitmentCost=0): onDemand = savings, eff = savings, ratio = 100% regardless of magnitude → the "$3 savings, infinite ROI" rows.
When upfront_cost is a 1y total cost stuffed into the upfront column: amortized > savings → negative percentages.
"$0 monthly" is shown literally because the field is literally 0, not null/undefined, so the frontend falls through to formatCurrency(0).
Suggested fix shape
Two-layer fix; both pieces are needed.
Backend (pkg/common.Recommendation + scheduler):
Add separate UpfrontCost and RecurringMonthlyCost fields to pkg/common.Recommendation (or a derived helper on the struct based on PaymentOption + CommitmentCost + Term). The provider parsers already have the raw breakdown (AWS CE returns EstimatedMonthlyOnDemandCost, EstimatedSavingsAmount, plus the SP hourly commitment); we're throwing it away.
In scheduler.go::buildRecommendationRecords, pass the proper split through instead of hardcoding MonthlyCost: 0 and stuffing total into UpfrontCost.
Frontend (recommendations.ts):
When monthly_cost == null (genuinely unknown vs zero), render — not $0.
Sanity-check effectiveSavingsPct for the 100% corner: if onDemand <= |effectiveSav|, surface as anomaly (or —) — silently rendering 100% hides the underlying data gap.
Add a unit test for a no-upfront variant where the backend correctly reports monthly_cost > 0 and assert the percentage matches (savings - amortized) / on_demand_monthly.
Repro / verification
Open /recommendations on the deployed app. Every row in the table I sampled has $0 in the Monthly column.
Filter to AWS RDS db.t4g.micro: same SKU appears with both negative and 100% Effective % depending on which broken branch of the formula it lands in.
Why this matters / priority
The Effective Savings % column was intended to be the user's primary "is this rec worth it" signal. With the current data it's actively misleading — recommending a 1y no-upfront RDS as 100% effective while the same SKU's 1y partial-upfront variant shows -35% will push users toward objectively worse picks.
cc: closes-rec comments I posted on #220 / #221 a few minutes ago should be treated as retracted — the columns ship pixels, but the numbers are wrong.
#252 still broken after PR #254 — verified against deployed app
PR #254 fixed the Go struct types (pkg/common.Recommendation.RecurringMonthlyCost *float64, RecommendationRecord.MonthlyCost *float64) and the AWS RI / SP parsers. But the deployed app still returns monthly_cost: 0 for every recommendation (17/17 AWS, 16/16 Azure). Two compounding gaps:
Gap 1 — Cached payloads still hold the literal 0
The recommendations table stores each record as a JSONB payload. Pre-deploy rows were inserted with the old struct (MonthlyCost float64 = 0), so the JSONB literally contains "monthly_cost": 0. After the deploy, the new code unmarshals that into *float64 → &0 (pointer to zero), not nil. The API then re-emits 0 — indistinguishable from a real zero from the user's perspective.
Fix needed: cache invalidation. Either (a) drop and re-collect on next scheduler tick, (b) add a one-shot migration that nulls out payload->monthly_cost (forcing the next collection to write the new shape), or (c) make RecurringMonthlyCost non-nullable on read with a sentinel (less clean).
Sample from the deployed /api/recommendations GET (n=33, all rows):
A db.t4g.micro 1y rec is still showing upfront: 0, monthly: 0, savings: 3.21 — the original "infinite ROI" symptom from the bug report.
Gap 2 — Azure and GCP parsers never updated
PR #254's diff only touched providers/aws/recommendations/parser_ri.go and parser_sp.go. providers/azure/... and providers/gcp/... were never updated to populate RecurringMonthlyCost. So even after a fresh collection, all 16 Azure recs (and any GCP recs) will still produce monthly_cost: nil → JSON null → frontend — for a column that could show real data (Azure Reservations API exposes BillingCurrencyTotal / RecurringMonthlyCost-equivalent in Reservations.GetCatalog and the Pricing API; GCP CUD recommendations expose cost_per_period similarly).
Fix needed: extend Azure and GCP parsers to fill RecurringMonthlyCost from the provider responses. For payment options where the cloud genuinely doesn't expose a recurring breakdown, leave nil (which is now correctly distinguished in the frontend).
Reopening scope
I'm leaving #252 open. PR #254 was a partial fix (Go types + AWS parser). The remaining work is:
Cache invalidation strategy (one-shot migration OR scheduler force-refresh on first read after a struct-shape change).
Azure parser fills RecurringMonthlyCost.
GCP parser fills RecurringMonthlyCost.
Smoke test against the deployed /api/recommendations endpoint that at least one row per provider has monthly_cost != 0 after collection (currently 0/33).
Closing — fixed end-to-end across PRs #256 (cache invalidation + Azure parsers), #259 (Azure parallelization), #260 (async refresh architecture), and #265 (async self-invoke payload shape). The auto-link from #256's title didn't fire because the closing keyword was in a parenthetical at the end. Verified the deployed Lambda env now has SCHEDULER_LAMBDA_ARN set and the IAM policy in place; collection runs end-to-end after #265 merged.
Activity
fix(frontend/scheduler): monthly cost shows $0 and effective % shows …
cristim commentedon May 4, 2026
#252 still broken after PR #254 — verified against deployed app
PR #254 fixed the Go struct types (
pkg/common.Recommendation.RecurringMonthlyCost *float64,RecommendationRecord.MonthlyCost *float64) and the AWS RI / SP parsers. But the deployed app still returnsmonthly_cost: 0for every recommendation (17/17 AWS, 16/16 Azure). Two compounding gaps:Gap 1 — Cached payloads still hold the literal
0The
recommendationstable stores each record as a JSONBpayload. Pre-deploy rows were inserted with the old struct (MonthlyCost float64 = 0), so the JSONB literally contains"monthly_cost": 0. After the deploy, the new code unmarshals that into*float64 → &0(pointer to zero), notnil. The API then re-emits0— indistinguishable from a real zero from the user's perspective.Fix needed: cache invalidation. Either (a) drop and re-collect on next scheduler tick, (b) add a one-shot migration that nulls out
payload->monthly_cost(forcing the next collection to write the new shape), or (c) makeRecurringMonthlyCostnon-nullable on read with a sentinel (less clean).Sample from the deployed
/api/recommendationsGET (n=33, all rows):A
db.t4g.micro1y rec is still showingupfront: 0, monthly: 0, savings: 3.21— the original "infinite ROI" symptom from the bug report.Gap 2 — Azure and GCP parsers never updated
PR #254's diff only touched
providers/aws/recommendations/parser_ri.goandparser_sp.go.providers/azure/...andproviders/gcp/...were never updated to populateRecurringMonthlyCost. So even after a fresh collection, all 16 Azure recs (and any GCP recs) will still producemonthly_cost: nil→ JSONnull→ frontend—for a column that could show real data (Azure Reservations API exposesBillingCurrencyTotal/RecurringMonthlyCost-equivalent inReservations.GetCatalogand the Pricing API; GCP CUD recommendations exposecost_per_periodsimilarly).Fix needed: extend Azure and GCP parsers to fill
RecurringMonthlyCostfrom the provider responses. For payment options where the cloud genuinely doesn't expose a recurring breakdown, leavenil(which is now correctly distinguished in the frontend).Reopening scope
I'm leaving #252 open. PR #254 was a partial fix (Go types + AWS parser). The remaining work is:
RecurringMonthlyCost.RecurringMonthlyCost./api/recommendationsendpoint that at least one row per provider hasmonthly_cost != 0after collection (currently 0/33).Spawning a follow-up agent to drive (1)–(4).
fix(db/azure): flush stale monthly_cost cache + Azure recurring cost (c…
cristim commentedon May 4, 2026
Closing — fixed end-to-end across PRs #256 (cache invalidation + Azure parsers), #259 (Azure parallelization), #260 (async refresh architecture), and #265 (async self-invoke payload shape). The auto-link from #256's title didn't fire because the closing keyword was in a parenthetical at the end. Verified the deployed Lambda env now has SCHEDULER_LAMBDA_ARN set and the IAM policy in place; collection runs end-to-end after #265 merged.