carlos / platform Public

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>
Paul Campbell d543b1d0688c886af4a52b88a3048e694886ecea parent 845d4e8
4 files changed, +299 −80
  • internal/console/economicspass.go +105 −63
  • internal/console/economicsview.go +88 −12
  • internal/console/economicsview_test.go +96 −0
  • internal/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.