console/economics: fix round — storage/census in the view, registry degrade, defensive copy
MAJOR: buildEconomicsView never saw MeterStorageGBMonth/MeterAppMonths — sweepUsage deliberately excludes them and statementsPass was the only caller folding them back in. Extracts that fold into a new shared foldStorageAndCensus (economicspass.go), called by both statementsPass and buildEconomicsView, converting the prior double-read waste into correctness: the view now prices the storage line and can clip on Apps/StorageGB free tier. Denominator ruling applied as specified — each day divides by days-in-THAT-day's-own-month, byte-for-byte identical to the old single-daysInMonth constant for statementsPass's whole-month range, and correct for LoadEconomics' month-straddling 7d period. New golden assertions cover storage_gb_month and app_months against the exact computed fraction. MINOR 1: LoadEconomics now returns a defensive shallow copy (copyEconomicsView) — fresh Accounts backing array + top-level Meters map — with a read-only contract documented on the method, so Task 14's sort.Slice(view.Accounts) can't race the shared cache. MINOR 2: sweepUsage no longer opens the registry itself — callers resolve routes and pass them in. statementsPass keeps the WRITE pass's hard refusal (openPassRegistry); LoadEconomics's new loadRoutesForEconomics degrades instead (nil routes, everything attributes to release.OpsAccount) and surfaces a new EconomicsView.Warning string, precedent commit 0958fa3. MINOR 3: economics.BoxLoad/BoxAccountLoad get additive snake_case json tags so DataJSON is one naming convention throughout. MINOR 4: account sort is now CostUSD desc, Account asc on ties, so the zero-cost tail doesn't reshuffle every background sweep. All pre-existing economicspass tests still pass unmodified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+299
−80
internal/console/economicspass.go+105 −63internal/console/economicsview.go+88 −12internal/console/economicsview_test.go+96 −0internal/economics/compute.go+10 −5
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 71c0edd91..5d599d346 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -216,7 +216,7 @@ func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool) |
| // accumulated by reading metrics and edge days over [from, to], plus enough |
| // coverage bookkeeping for callers to label a total honestly. It deliberately |
| // does NOT include storage or census meters — see sweepUsage's doc comment — |
| -// so a caller that needs those (statementsPass) adds them itself afterward. |
| +// every caller folds those in afterward via foldStorageAndCensus. |
| type usageSweep struct { |
| PerAccount map[string]map[string]float64 // account -> meter -> value |
| PerBox map[string]economics.BoxLoad // box -> period load (Hours, per-account CPU/mem) |
| @@ -243,36 +243,38 @@ type usageSweep struct { |
| // by statementsPass (whole month to date, create-or-replace statements) and |
| // Task 11's LoadEconomics (arbitrary period, priced live under a scenario). |
| // |
| +// routes is the registry snapshot unitOwner attributes sampled units |
| +// against — the CALLER resolves it, deliberately not sweepUsage itself, |
| +// because the two callers need opposite failure behaviour on a |
| +// missing/unmounted registry: statementsPass (a WRITE pass) must refuse |
| +// outright rather than write a permanent all-zero day off an empty |
| +// registry.Open (openPassRegistry's own doc comment), while LoadEconomics |
| +// (a READ path) degrades instead — see loadRoutesForEconomics |
| +// (economicsview.go). A nil/empty routes therefore isn't an error here: |
| +// every sampled unit simply falls through unitOwner's unmatched-unit case |
| +// to release.OpsAccount, same as any other unmatched unit. |
| +// |
| // Storage and census are deliberately NOT accumulated here: storage's |
| // account attribution needs a ValidAccount guard with pass-scoped dedup |
| -// logging (statementsPass's badStorageKeys) that only makes sense once per |
| -// whole pass, and census never gates coverage at all — both stay in |
| -// statementsPass, added on top of this sweep's result. Storage's day |
| -// objects ARE still consulted here, for CoveredDays only: a day with a |
| -// valid storage sweep and nothing else is still a covered day, exactly as |
| -// before this function existed, even though the bytes themselves are read |
| -// again downstream. |
| +// logging that only makes sense once per whole call, and census never gates |
| +// coverage at all — both stay in foldStorageAndCensus, called separately by |
| +// every caller on top of this sweep's result. Storage's day objects ARE |
| +// still consulted here, for CoveredDays only: a day with a valid storage |
| +// sweep and nothing else is still a covered day, exactly as before this |
| +// function existed, even though the bytes themselves are read again by |
| +// foldStorageAndCensus. |
| // |
| -// unitOwner attributes a sampled unit against s.cfg.Registry, which is this |
| -// CONSOLE's own box-local registry, while this sweep iterates units |
| -// published by every box under metrics/boxes/. On a multi-box deployment a |
| -// unit sampled on a box other than this console's own can fail to match any |
| -// route in this registry and silently fall to release.OpsAccount, |
| -// indistinguishable from the intended platform-overhead fallback. Both |
| -// production deployments are single-box today, so this is currently inert; |
| -// the structural fix is routes-as-bucket-records (PR #137, unmerged), which |
| -// would give every pass a registry view that spans boxes instead of one |
| -// box's local file. |
| -func (s *Server) sweepUsage(ctx context.Context, from, to time.Time) (*usageSweep, error) { |
| - reg, err := s.openPassRegistry() |
| - if err != nil { |
| - return nil, fmt.Errorf("console: sweep usage: %w", err) |
| - } |
| - routes, err := reg.List() |
| - if err != nil { |
| - return nil, fmt.Errorf("console: sweep usage: %w", err) |
| - } |
| - |
| +// unitOwner attributes a sampled unit against routes, which — for both |
| +// current callers — is this CONSOLE's own box-local registry, while this |
| +// sweep iterates units published by every box under metrics/boxes/. On a |
| +// multi-box deployment a unit sampled on a box other than this console's own |
| +// can fail to match any route in this registry and silently fall to |
| +// release.OpsAccount, indistinguishable from the intended platform-overhead |
| +// fallback. Both production deployments are single-box today, so this is |
| +// currently inert; the structural fix is routes-as-bucket-records (PR #137, |
| +// unmerged), which would give every pass a registry view that spans boxes |
| +// instead of one box's local file. |
| +func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []registry.Route) (*usageSweep, error) { |
| metricsBoxes, err := s.cfg.Releases.ListDirs(ctx, metricsBoxesPrefix) |
| if err != nil { |
| return nil, fmt.Errorf("console: sweep usage: %w", err) |
| @@ -407,52 +409,52 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time) (*usageSwee |
| return sweep, nil |
| } |
| -// statementsPass folds the month's metrics, edge and storage days (plus |
| -// census, for app_months) into one usage.Statement per account, create-or- |
| -// replace via usage.UpsertStatement — safe to run repeatedly as new days' |
| -// objects appear. It is a thin caller over sweepUsage (below), which does |
| -// the actual per-day metrics/edge/storage reading and accumulation shared |
| -// with Task 11's LoadEconomics: sweep the whole month to date, then fold in |
| -// the two meter families sweepUsage deliberately leaves out — storage |
| -// (needs the ValidAccount guard below) and census (doesn't gate coverage at |
| -// all) — before upserting. |
| +// foldStorageAndCensus reads each UTC day's storage and census objects in |
| +// [from, to] (both truncated to midnight, inclusive) and folds their meters |
| +// into dst (account -> meter -> value) — MeterStorageGBMonth and |
| +// MeterAppMonths, the two families sweepUsage deliberately leaves out (see |
| +// its doc comment). Shared by statementsPass and buildEconomicsView |
| +// (economicsview.go) so the two never disagree about a period's storage/ |
| +// app-existence numbers — the bug this fixes: the view path used to call |
| +// sweepUsage alone and never see these meters at all. |
| // |
| -// Known shortfall, recorded rather than fixed at modelling grade: an account |
| -// deleted mid-month drops out of s.store.Accounts() (its query filters |
| -// deleted_at), so this pass stops upserting its statement from the day of |
| -// deletion onward — the account's last statement object simply stops moving |
| -// forward, rather than the month closing it out explicitly. |
| -func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| - nowUTC := now.UTC() |
| - y, mo, today := nowUTC.Date() |
| - daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| - month := usage.MonthOf(nowUTC) |
| - monthStart := time.Date(y, mo, 1, 0, 0, 0, 0, time.UTC) |
| - |
| - sweep, err := s.sweepUsage(ctx, monthStart, nowUTC) |
| - if err != nil { |
| - return fmt.Errorf("console: statements pass: %w", err) |
| - } |
| - meters := sweep.PerAccount // account -> meter -> value; sweepUsage's map, extended below |
| +// Denominator ruling (review, Task 11 fix round): each day's contribution |
| +// divides by the number of days in THAT DAY's own calendar month — not one |
| +// daysInMonth computed once for the whole range. For statementsPass, whose |
| +// range is always "whole month to date", every day shares one month, so |
| +// this is identical byte-for-byte to the pre-refactor single daysInMonth |
| +// constant (proven by its unmodified tests). For LoadEconomics's "7d" |
| +// period, which can straddle a month boundary, a day still accrues |
| +// (bytes/1e9)/daysInMonth(that day) and apps/daysInMonth(that day) — meters |
| +// stay "accrued GB-months/app-months in the period", pricing correctly |
| +// under monthly rates regardless of which days the period spans. |
| +// |
| +// badStorageKeys dedups a malformed storage-day key's log line at most once |
| +// per key for this ONE CALL — matching the pre-split pass-scoped behaviour, |
| +// since each of statementsPass and buildEconomicsView calls this exactly |
| +// once per invocation. |
| +func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, dst map[string]map[string]float64) error { |
| touch := func(acct string) map[string]float64 { |
| - mm := meters[acct] |
| + mm := dst[acct] |
| if mm == nil { |
| mm = map[string]float64{} |
| - meters[acct] = mm |
| + dst[acct] = mm |
| } |
| return mm |
| } |
| - // badStorageKeys logs a malformed storage-day key at most once per key |
| - // for this whole pass, not once per day it recurs within the month. |
| badStorageKeys := map[string]bool{} |
| - for day := 1; day <= today; day++ { |
| - date := time.Date(y, mo, day, 0, 0, 0, 0, time.UTC).Format("2006-01-02") |
| + fromDay := from.UTC().Truncate(24 * time.Hour) |
| + toDay := to.UTC().Truncate(24 * time.Hour) |
| + for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { |
| + date := usage.DayOf(d) |
| + y, mo, _ := d.Date() |
| + daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil { |
| var sd usage.StorageDay |
| if jerr := json.Unmarshal(raw, &sd); jerr != nil { |
| - log.Printf("console: statements pass: parse %s: %v", usage.StorageDayKey(date), jerr) |
| + log.Printf("console: fold storage/census: parse %s: %v", usage.StorageDayKey(date), jerr) |
| } else { |
| for key, ap := range sd.Apps { |
| acct := key |
| @@ -471,7 +473,7 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| if !release.ValidAccount(acct) { |
| if !badStorageKeys[key] { |
| badStorageKeys[key] = true |
| - log.Printf("console: statements pass: storage day %s: key %q has an invalid account segment %q; attributing to %s", date, key, acct, release.OpsAccount) |
| + log.Printf("console: fold storage/census: storage day %s: key %q has an invalid account segment %q; attributing to %s", date, key, acct, release.OpsAccount) |
| } |
| acct = release.OpsAccount |
| } |
| @@ -486,7 +488,7 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil { |
| var cd usage.CensusDay |
| if jerr := json.Unmarshal(raw, &cd); jerr != nil { |
| - log.Printf("console: statements pass: parse %s: %v", usage.CensusDayKey(date), jerr) |
| + log.Printf("console: fold storage/census: parse %s: %v", usage.CensusDayKey(date), jerr) |
| } else { |
| for acct, ca := range cd.Accounts { |
| touch(acct)[usage.MeterAppMonths] += float64(ca.Apps) / float64(daysInMonth) |
| @@ -494,6 +496,46 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| } |
| } |
| } |
| + return nil |
| +} |
| + |
| +// statementsPass folds the month's metrics, edge and storage days (plus |
| +// census, for app_months) into one usage.Statement per account, create-or- |
| +// replace via usage.UpsertStatement — safe to run repeatedly as new days' |
| +// objects appear. It is a thin caller: open the registry (refusing early — |
| +// see openPassRegistry — since this is the WRITE pass), sweep the whole |
| +// month to date (sweepUsage), fold in storage/census meters |
| +// (foldStorageAndCensus), then upsert. |
| +// |
| +// Known shortfall, recorded rather than fixed at modelling grade: an account |
| +// deleted mid-month drops out of s.store.Accounts() (its query filters |
| +// deleted_at), so this pass stops upserting its statement from the day of |
| +// deletion onward — the account's last statement object simply stops moving |
| +// forward, rather than the month closing it out explicitly. |
| +func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| + reg, err := s.openPassRegistry() |
| + if err != nil { |
| + return fmt.Errorf("console: statements pass: %w", err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return fmt.Errorf("console: statements pass: %w", err) |
| + } |
| + |
| + nowUTC := now.UTC() |
| + y, mo, _ := nowUTC.Date() |
| + daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| + month := usage.MonthOf(nowUTC) |
| + monthStart := time.Date(y, mo, 1, 0, 0, 0, 0, time.UTC) |
| + |
| + sweep, err := s.sweepUsage(ctx, monthStart, nowUTC, routes) |
| + if err != nil { |
| + return fmt.Errorf("console: statements pass: %w", err) |
| + } |
| + meters := sweep.PerAccount // account -> meter -> value; folded into below |
| + if err := s.foldStorageAndCensus(ctx, monthStart, nowUTC, meters); err != nil { |
| + return fmt.Errorf("console: statements pass: %w", err) |
| + } |
| coveredDays := sweep.CoveredDays |
| // Every account gets a statement this pass, even one with zero usage — |
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go| index 09b71eddc..86e83e997 100644 |
| --- a/internal/console/economicsview.go |
| +++ b/internal/console/economicsview.go |
| @@ -20,6 +20,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| @@ -44,9 +45,15 @@ type EconomicsView struct { |
| // ScenarioWarning is set when active.json named a scenario that could |
| // not be loaded/parsed/validated — never set when active.json is simply |
| // absent (a fresh deployment, not a fault; see loadActiveScenario). |
| - ScenarioWarning string |
| + ScenarioWarning string |
| + // Warning is set when the SWEEP itself degraded — currently only "no |
| + // registry configured/reachable", which makes every sampled unit |
| + // attribute to release.OpsAccount instead of its real account (see |
| + // loadRoutesForEconomics). Distinct from ScenarioWarning: the two can |
| + // both be set at once and Task 13 renders them as separate notices. |
| + Warning string |
| Scenarios []string // names under economics/scenarios/ |
| - Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc |
| + Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc, Account asc on ties |
| Meters map[string]map[string]float64 // account -> meter (for embedding) |
| Boxes []BoxEconomics |
| Bills []economics.Bill // this month + previous, if present |
| @@ -69,11 +76,14 @@ type BoxEconomics struct { |
| // needs to redo AllocateBox/PriceAccount client-side when an operator tweaks |
| // a scenario knob live, without a server round trip. Field names are frozen |
| // by that JS — change both together (economics.go's PriceAccount doc carries |
| -// the same warning for the pricing formulas themselves). json.Marshal |
| -// HTML-escapes '<', '>' and '&' by default (unlike an Encoder with |
| -// SetEscapeHTML(false)) — that default is exactly what keeps this safe to |
| -// embed inside a literal <script> block, so it must stay in effect: never |
| -// swap this for an Encoder with escaping turned off. |
| +// the same warning for the pricing formulas themselves). Every field, |
| +// including the nested economics.BoxLoad/BoxAccountLoad values inside |
| +// BoxLoads, carries an explicit snake_case json tag (compute.go) — one |
| +// convention for the whole payload, not a mix of Go field names and |
| +// snake_case. json.Marshal HTML-escapes '<', '>' and '&' by default (unlike |
| +// an Encoder with SetEscapeHTML(false)) — that default is exactly what |
| +// keeps this safe to embed inside a literal <script> block, so it must stay |
| +// in effect: never swap this for an Encoder with escaping turned off. |
| type economicsPayload struct { |
| Scenario economics.Scenario `json:"scenario"` |
| Meters map[string]map[string]float64 `json:"meters"` |
| @@ -168,6 +178,17 @@ func economicsPeriodBounds(period string, now time.Time) (from, to time.Time) { |
| // blocks for a real sweep and surfaces its error. See LoadMetrics's doc |
| // comment for the full reasoning — it applies here verbatim, just against a |
| // per-period cache entry instead of one shared cache. |
| +// |
| +// The returned *EconomicsView is otherwise READ-ONLY: it (or its unexported |
| +// twin) is shared with the in-process cache and any other concurrent caller |
| +// of the same period. copyEconomicsView gives each caller its own Accounts |
| +// backing array and top-level Meters map specifically so a caller may |
| +// freely sort.Slice(view.Accounts, ...) or add/remove top-level Meters |
| +// entries without racing the cache or another caller doing the same — |
| +// Task 13's dashboard sort is the motivating case. Every OTHER field |
| +// (Scenario, Boxes, Bills, Scenarios, DataJSON, ...) is still shared: treat |
| +// those, and the inner per-account meter maps Meters' values point at, as |
| +// immutable. |
| func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error) { |
| period = normalizeEconomicsPeriod(period) |
| c := s.economicsCacheEntry(period) |
| @@ -176,7 +197,7 @@ func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsVi |
| if !c.at.IsZero() && time.Since(c.at) < economicsCacheTTL { |
| view := c.view |
| c.mu.Unlock() |
| - return view, nil |
| + return copyEconomicsView(view), nil |
| } |
| if !c.at.IsZero() && time.Since(c.at) <= economicsStaleServeCeiling { |
| view := c.view |
| @@ -184,7 +205,7 @@ func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsVi |
| s.startBackgroundEconomicsSweep(c, period) |
| } |
| c.mu.Unlock() |
| - return view, nil |
| + return copyEconomicsView(view), nil |
| } |
| if c.sweeping { |
| wait := c.done |
| @@ -222,10 +243,27 @@ func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsVi |
| if err != nil { |
| return nil, err |
| } |
| - return view, nil |
| + return copyEconomicsView(view), nil |
| } |
| } |
| +// copyEconomicsView returns a shallow copy of v safe for a caller to sort or |
| +// otherwise rearrange in place without racing the cache: Accounts gets a |
| +// fresh backing array (Task 14's dashboard sorts it) and Meters gets a fresh |
| +// top-level map (inner per-account maps are still shared by reference — |
| +// cheap at this size, and no known caller rewrites a meter value in place |
| +// rather than reading it). See LoadEconomics's doc comment for the full |
| +// read-only contract this exists to enforce. |
| +func copyEconomicsView(v *EconomicsView) *EconomicsView { |
| + cp := *v |
| + cp.Accounts = append([]economics.AccountEconomics(nil), v.Accounts...) |
| + cp.Meters = make(map[string]map[string]float64, len(v.Meters)) |
| + for acct, mm := range v.Meters { |
| + cp.Meters[acct] = mm |
| + } |
| + return &cp |
| +} |
| + |
| // startBackgroundEconomicsSweep mirrors startBackgroundSweep |
| // (metricsview.go:361-391): launches buildEconomicsView on its own |
| // goroutine, detached (context.Background(), since there is no caller |
| @@ -276,10 +314,15 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| now := time.Now().UTC() |
| from, to := economicsPeriodBounds(period, now) |
| - sweep, err := s.sweepUsage(sweepCtx, from, to) |
| + routes, warning := s.loadRoutesForEconomics(sweepCtx) |
| + |
| + sweep, err := s.sweepUsage(sweepCtx, from, to, routes) |
| if err != nil { |
| return nil, fmt.Errorf("console: load economics: %w", err) |
| } |
| + if err := s.foldStorageAndCensus(sweepCtx, from, to, sweep.PerAccount); err != nil { |
| + return nil, fmt.Errorf("console: load economics: %w", err) |
| + } |
| sc, scenarioWarning := s.loadActiveScenario(sweepCtx) |
| @@ -332,7 +375,15 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| meters[acct] = mm |
| accountRows = append(accountRows, economics.PriceAccount(sc, acct, mm, totalAllocated[acct])) |
| } |
| - sort.Slice(accountRows, func(i, j int) bool { return accountRows[i].CostUSD > accountRows[j].CostUSD }) |
| + // CostUSD desc, Account asc on ties — a stable tie-break so the |
| + // zero-cost tail (every account with no usage this period, all tied at |
| + // 0) doesn't visibly reshuffle on every 60s background sweep. |
| + sort.Slice(accountRows, func(i, j int) bool { |
| + if accountRows[i].CostUSD != accountRows[j].CostUSD { |
| + return accountRows[i].CostUSD > accountRows[j].CostUSD |
| + } |
| + return accountRows[i].Account < accountRows[j].Account |
| + }) |
| // Revenue attribution to boxes: for each account, boxShare = |
| // allocated[box][acct] / totalAllocated[acct] (0 when the total is 0), |
| @@ -389,6 +440,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| Period: period, |
| Scenario: sc, |
| ScenarioWarning: scenarioWarning, |
| + Warning: warning, |
| Scenarios: scenarioNames, |
| Accounts: accountRows, |
| Meters: meters, |
| @@ -400,6 +452,30 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| }, nil |
| } |
| +// loadRoutesForEconomics resolves the routes sweepUsage needs to attribute |
| +// sampled units to accounts, DEGRADING rather than failing when the |
| +// registry is missing or unreadable — the opposite of openPassRegistry's |
| +// refusal, and deliberately so: that refusal protects a WRITE pass from |
| +// reading registry.Open's freshly-created empty sqlite as "zero routes" and |
| +// writing a permanent, uncorrectable all-zero day. This is a READ path — |
| +// there is nothing to protect by refusing. A nil routes list simply means |
| +// unitOwner falls every sampled unit through to release.OpsAccount |
| +// (indistinguishable from platform overhead, same as any other unmatched |
| +// unit), and the dashboard still renders with a visible warning rather than |
| +// a blank 500 — precedent: commit 0958fa3, instances health degrading on a |
| +// console with no registry rather than failing the page. |
| +func (s *Server) loadRoutesForEconomics(ctx context.Context) (routes []registry.Route, warning string) { |
| + reg, err := s.openPassRegistry() |
| + if err != nil { |
| + return nil, fmt.Sprintf("no registry available (%v); usage cannot be attributed to accounts by route this sweep", err) |
| + } |
| + routes, err = reg.List() |
| + if err != nil { |
| + return nil, fmt.Sprintf("registry: %v; usage cannot be attributed to accounts by route this sweep", err) |
| + } |
| + return routes, "" |
| +} |
| + |
| // loadActiveScenario resolves economics.ActiveKey -> named scenario -> |
| // Validate, per the spec's fallback rule: economics/active.json simply not |
| // existing yet is a FRESH DEPLOYMENT, not a fault — DefaultScenario with no |
diff --git a/internal/console/economicsview_test.go b/internal/console/economicsview_test.go| index 26151a0e0..dba360bc3 100644 |
| --- a/internal/console/economicsview_test.go |
| +++ b/internal/console/economicsview_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "math" |
| + "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| @@ -57,6 +58,26 @@ func TestLoadEconomicsGolden(t *testing.T) { |
| }}, |
| }) |
| + // One day of storage (31 GB) and census (2 apps) for today, to prove |
| + // buildEconomicsView folds MeterStorageGBMonth/MeterAppMonths in — the |
| + // MAJOR fix-round finding: the view path used to call sweepUsage alone |
| + // and never see these meters at all. Expected contribution uses the |
| + // controller's denominator ruling: divide by days in THIS day's own |
| + // calendar month, computed the same way production code does, so the |
| + // assertion holds regardless of which real month the test runs in. |
| + now := time.Now().UTC() |
| + daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| + putStoreJSON(t, fx.rel, usage.StorageDayKey(date), usage.StorageDay{ |
| + V: 1, Date: date, Apps: map[string]usage.AppStorage{ |
| + fixtureAcct + "/" + fixtureApp: {BucketBytes: 31 * 1e9}, |
| + }, |
| + }) |
| + putStoreJSON(t, fx.rel, usage.CensusDayKey(date), usage.CensusDay{ |
| + V: 1, Date: date, Accounts: map[string]usage.CensusAccount{ |
| + fixtureAcct: {Apps: 2, Routes: 3}, |
| + }, |
| + }) |
| + |
| sc := economics.DefaultScenario() |
| sc.Name = "test" |
| putStoreJSON(t, fx.rel, economics.ScenarioKey("test"), sc) |
| @@ -126,6 +147,21 @@ func TestLoadEconomicsGolden(t *testing.T) { |
| if !strings.Contains(string(view.DataJSON), "meters") { |
| t.Fatalf("DataJSON missing %q: %s", "meters", view.DataJSON) |
| } |
| + |
| + wantStorage := 31.0 / float64(daysInMonth) |
| + if got := view.Meters[fixtureAcct][usage.MeterStorageGBMonth]; math.Abs(got-wantStorage) > 1e-9 { |
| + t.Fatalf("Meters[%q][storage_gb_month] = %v, want %v (31 GB / %d days in month)", fixtureAcct, got, wantStorage, daysInMonth) |
| + } |
| + wantAppMonths := 2.0 / float64(daysInMonth) |
| + if got := view.Meters[fixtureAcct][usage.MeterAppMonths]; math.Abs(got-wantAppMonths) > 1e-9 { |
| + t.Fatalf("Meters[%q][app_months] = %v, want %v (2 apps / %d days in month)", fixtureAcct, got, wantAppMonths, daysInMonth) |
| + } |
| + // AccountEconomics.CostUSD is derived from Meters, so the storage line |
| + // must be priced in too — the MAJOR finding's other symptom (cost |
| + // dropping the S3-storage line entirely). |
| + if got, want := acctRow.CostUSD, wantStorage*sc.RateCard.StorageGBMonthUSD; got < want { |
| + t.Fatalf("acctRow.CostUSD = %v, want >= %v (must include the storage line)", got, want) |
| + } |
| } |
| // TestLoadEconomicsWarnsOnMissingNamedScenario: active.json naming a |
| @@ -169,3 +205,63 @@ func TestLoadEconomicsDefaultsSilentlyWithNoActiveFile(t *testing.T) { |
| t.Fatalf("ScenarioWarning = %q, want empty (missing active.json is a fresh deployment, not a fault)", view.ScenarioWarning) |
| } |
| } |
| + |
| +// TestLoadEconomicsDegradesWithoutRegistry: a console with no registry |
| +// configured/mounted (MINOR 2, fix round) must still render the dashboard — |
| +// sweepUsage simply attributes every sampled unit to release.OpsAccount, |
| +// same as any other unmatched unit — with a visible Warning, never a hard |
| +// error. This is the opposite of censusPass/statementsPass's own registry |
| +// guard (TestCensusPassRefusesMissingRegistry): those are WRITE passes |
| +// protecting a create-only day object from a bogus all-zero write; this is |
| +// a READ path with nothing to protect by refusing. |
| +func TestLoadEconomicsDegradesWithoutRegistry(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "nonexistent-registry.db") |
| + |
| + view, err := fx.srv.LoadEconomics(ctx, "mtd") |
| + if err != nil { |
| + t.Fatalf("LoadEconomics with no registry returned an error, want a degraded view: %v", err) |
| + } |
| + if view.Warning == "" { |
| + t.Fatal("Warning is empty, want a notice that the sweep degraded (no registry)") |
| + } |
| +} |
| + |
| +// TestLoadEconomicsAccountsSortStableOnTies: CostUSD-desc sorting must |
| +// tie-break on Account ascending (MINOR 4, fix round) — otherwise the |
| +// zero-cost tail (every account with no usage this period, all tied at 0) |
| +// has no defined order and can visibly reshuffle on every background sweep. |
| +func TestLoadEconomicsAccountsSortStableOnTies(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + |
| + if err := fx.st.CreateAccount(testAccountID(), "zzzsqid", "Zedco", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.CreateAccount(testAccountID(), "aaasqid", "Aardco", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + view, err := fx.srv.LoadEconomics(ctx, "mtd") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var order []string |
| + for _, a := range view.Accounts { |
| + if a.Account == "zzzsqid" || a.Account == "aaasqid" || a.Account == fixtureAcct { |
| + order = append(order, a.Account) |
| + } |
| + } |
| + want := []string{"aaasqid", fixtureAcct, "zzzsqid"} |
| + if len(order) != len(want) { |
| + t.Fatalf("order = %v, want %v", order, want) |
| + } |
| + for i := range want { |
| + if order[i] != want[i] { |
| + t.Fatalf("order = %v, want %v (Account ascending among CostUSD==0 ties)", order, want) |
| + } |
| + } |
| +} |
diff --git a/internal/economics/compute.go b/internal/economics/compute.go| index d72adc3ee..35912fc55 100644 |
| --- a/internal/economics/compute.go |
| +++ b/internal/economics/compute.go |
| @@ -6,16 +6,21 @@ import "github.com/carlosframework/platform/internal/usage" |
| const HoursPerMonth = 730.0 |
| // BoxAccountLoad is one account's consumption on one box over the period. |
| +// json tags are additive: nothing currently persists this shape to a |
| +// bucket, but console/economicsview.go embeds it (inside BoxLoad, keyed |
| +// under "boxLoads") in the dashboard's DataJSON payload, so it carries the |
| +// same snake_case convention as every other field in that payload. |
| type BoxAccountLoad struct { |
| - CPUCoreHours float64 |
| - MemGBHours float64 |
| + CPUCoreHours float64 `json:"cpu_core_hours"` |
| + MemGBHours float64 `json:"mem_gb_hours"` |
| } |
| // BoxLoad is one box over the period: metered hours and per-account load. |
| +// See BoxAccountLoad's doc comment for why it carries json tags. |
| type BoxLoad struct { |
| - Box string |
| - Hours float64 |
| - ByAccount map[string]BoxAccountLoad |
| + Box string `json:"box"` |
| + Hours float64 `json:"hours"` |
| + ByAccount map[string]BoxAccountLoad `json:"by_account"` |
| } |
| // AllocateBox splits a box's period cost across accounts by resource share. |