carlos / platform Public

pricing: completeness must not vouch for attribution it never verified

The whole-branch review found one composition bug across the three tasks'
seams. Unit attribution is only correct on days whose census carries a
Units map, but the completeness signal set censusOK from a successful
Unmarshal alone — and every census written before this branch unmarshals
perfectly while answering nothing about ownership. The first tick after
shipping past the 3rd would have swept the previous month against those
censuses, closed it, and labelled the wrong number complete. Permanently,
since a closed statement refuses every later write.

dayCensus/readDayCensus now carry Decoded and HaveUnits; censusOK requires
both; MeterComplete requires a full census month of the SourceMetrics
meters. Not of the edge meters — EdgeRow.Account is stamped into the day
object at collection time, so they satisfy ownership by construction, and
MeterSource's doc comment now names that asymmetry instead of leaving it
invisible.

While in there: the same census object was fetched three times per day per
period, unconditionally, on a path that includes a live economics render.
Now read once and shared, foldStorageAndCensus included. UpsertStatement's
doc names the only recovery from a month frozen with a bad number (delete
the object); statementsPass's names the month a console down for a calendar
month leaves permanently un-billable. censusPass stops writing the two bogus
keys every hostless route produced and goes first-wins like routeForUnit. A
malformed census is logged rather than silently reverting a day to the
pre-branch attribution. The three line citations that rotted three times in
one branch now cite functions by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cEe5te3cyEcdF3JL2rN5M
Paul Campbell pushed by paul@keymail.dev 852290c1184c2a9ea557e430f76b9af761bd7736 parent 1775c91
7 files changed, +382 −58
  • internal/console/economicspass.go +154 −52
  • internal/console/economicspass_test.go +134 −3
  • internal/console/economicsview.go +1 −1
  • internal/usage/census.go +11 −0
  • internal/usage/passes_test.go +41 −1
  • internal/usage/statement.go +29 −1
  • internal/usage/usage.go +12 −0

CI passed — run details

diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index 60b2c8713..157178df0 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -125,13 +125,34 @@ func (s *Server) censusPass(ctx context.Context, date string) error {
}
accounts[acct] = c
- for _, name := range []string{rt.Unit, activator.UnitName(rt.Host), activator.ExecName(rt.Host)} {
+ // The names recorded here must be exactly the names routeForUnit
+ // would have matched, or the census answers with keys nothing ever
+ // looks up and stays silent on the ones that matter.
+ //
+ // Hence the rt.Host guard, mirroring routeForUnit's: the derived
+ // names are NOT empty for an empty host — activator.UnitName("") is
+ // "carlos-app@.service" and cgroup.ExecName("") is non-empty too,
+ // while cgroup.IsExecName explicitly rejects an empty host — so
+ // without it every hostless route writes the same two bogus keys,
+ // last writer winning across unrelated accounts.
+ names := []string{rt.Unit}
+ if rt.Host != "" {
+ names = append(names, activator.UnitName(rt.Host), activator.ExecName(rt.Host))
+ }
+ for _, name := range names {
if name == "" {
continue
}
if units == nil {
units = map[string]usage.CensusUnit{}
}
+ // First route wins, as routeForUnit's loop does: on an ambiguous
+ // unit name the census and the live fallback must name the same
+ // owner, or which one a day gets depends on whether its census
+ // happened to be written.
+ if _, seen := units[name]; seen {
+ continue
+ }
units[name] = usage.CensusUnit{Account: acct, App: rt.App}
}
}
@@ -205,43 +226,84 @@ func (s *Server) storagePass(ctx context.Context, date string) error {
return err
}
-// dayOwnerFunc returns a lookup answering who owned a unit on one UTC day,
-// from that day's census, reading the census object once per day rather than
-// once per unit.
+// dayCensus is one UTC day's census in the shape sweepUsage's consumers
+// need: the decoded object, whether it decoded at all, and whether it can
+// answer ownership. Read ONCE per day by readDayCensus and shared, because
+// the same object was previously fetched three times per day per period —
+// and sweepUsage is on the live economics page's render path, where each
+// extra Get is another sequential round trip to the bucket under a request
+// timeout, roughly sixty of them for a month-long period.
+type dayCensus struct {
+ Day usage.CensusDay
+ // Decoded is true only when the object both existed and parsed. A
+ // census that did not decode is not a complete census day.
+ Decoded bool
+ // HaveUnits is true only when the decoded census carried a Units map,
+ // i.e. when it can answer "who owned this unit that day". A day that
+ // cannot answer that falls back to the live registry — the pre-branch
+ // behaviour, and the bug this branch exists to fix — so it must not
+ // count towards census completeness either (usage.MeterComplete).
+ HaveUnits bool
+}
+
+// readDayCensus reads and decodes one day's census, once.
+//
+// Absence stays quiet: it is the normal state of the current day before its
+// census is written, and of every day predating this pass. A malformed
+// census IS logged — it silently reverts a whole day to attributing against
+// the live registry, which is precisely the wrong-owner behaviour this
+// branch removes, and that is not a thing to do quietly. The metrics arm of
+// the same loop logs its parse failures for the same reason.
+func (s *Server) readDayCensus(ctx context.Context, date string) dayCensus {
+ raw, _, err := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date))
+ if err != nil {
+ return dayCensus{}
+ }
+ var cd usage.CensusDay
+ if jerr := json.Unmarshal(raw, &cd); jerr != nil {
+ log.Printf("console: sweep usage: parse %s: %v", usage.CensusDayKey(date), jerr)
+ return dayCensus{}
+ }
+ return dayCensus{Day: cd, Decoded: true, HaveUnits: cd.Units != nil}
+}
+
+// dayOwnerFunc returns a lookup answering which ACCOUNT owned a unit on one
+// UTC day, from that day's already-read census (readDayCensus).
//
// It falls back to the live registry when the day has no census or no Units
// map — days written before Units existed, and the current day before its
// census is written. That fallback IS the old behaviour, so this is strictly
-// an improvement rather than a new failure mode.
+// an improvement rather than a new failure mode. It is also why dc.HaveUnits
+// gates the census source count: a day answered by the fallback is a day
+// whose attribution nothing has verified, and a statement must not call such
+// a month complete.
+//
+// The lookup returns the account alone. It used to return the app too and
+// no non-test caller ever read it. usage.CensusUnit.App stays in the
+// PERSISTED object regardless: it costs one field, it is what per-app
+// statements will need, and unlike the account it cannot be re-derived from
+// anything once the route is gone.
//
// Granularity is one day: the census is create-only and written by the first
// pass of each day, so a route created or deleted later that day carries its
// first-pass owner for the whole day. That is the honest bound, and it is
// what lets a closed month stop moving.
-func (s *Server) dayOwnerFunc(ctx context.Context, date string, routes []registry.Route) func(string) (string, string) {
- var cd usage.CensusDay
- haveUnits := false
- if raw, _, err := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); err == nil {
- if json.Unmarshal(raw, &cd) == nil && cd.Units != nil {
- haveUnits = true
- }
- }
- return func(unit string) (string, string) {
- if haveUnits {
- if cu, ok := cd.Units[unit]; ok {
- return cu.Account, cu.App
+func dayOwnerFunc(dc dayCensus, routes []registry.Route) func(string) string {
+ return func(unit string) string {
+ if dc.HaveUnits {
+ if cu, ok := dc.Day.Units[unit]; ok {
+ return cu.Account
}
}
// Absence from the census is NOT evidence of platform ownership
// (codex 2). censusPass writes once per day and returns early
- // afterwards (economicspass.go:99), so a route created after that
- // day's snapshot is legitimately missing from it while the live
- // registry knows exactly who owns it. Falling through to the
- // registry keeps the win — a DELETED route is present in the
- // census and keeps its owner — without inventing a regression for
- // a CREATED one.
- acct, app, _ := unitOwner(routes, unit)
- return acct, app
+ // afterwards, so a route created after that day's snapshot is
+ // legitimately missing from it while the live registry knows
+ // exactly who owns it. Falling through to the registry keeps the
+ // win — a DELETED route is present in the census and keeps its
+ // owner — without inventing a regression for a CREATED one.
+ acct, _, _ := unitOwner(routes, unit)
+ return acct
}
}
@@ -293,6 +355,12 @@ 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)
BoxDays map[string]int // box -> days with any metrics rollup
+ // Census carries each swept day's census, read once here (readDayCensus)
+ // and handed to foldStorageAndCensus so the same object is not fetched
+ // twice per day. Both callers fold over exactly the range they swept, so
+ // every date it needs is in here; a date that somehow is not is read on
+ // demand there. Keyed by usage.DayOf's label.
+ Census map[string]dayCensus
// CoveredDays counts UTC days in [from, to] with at least one source
// object — a metrics day rollup on any box, an edge day on any box, or
// the day's storage sweep (checked here for validity only, never
@@ -342,16 +410,28 @@ type usageSweep struct {
// function existed, even though the bytes themselves are read again by
// foldStorageAndCensus.
//
+// Attribution is per day, and the DAY'S CENSUS is the attributor:
+// dayOwnerFunc looks each sampled unit up in that day's usage/census object,
+// which is why a route deleted since keeps the usage it already ran up.
+// unitOwner is only the FALLBACK, for a day with no census or one written
+// before Units existed. That ordering matters for the caveat below, because
+// the caveat is about the fallback, and how often the fallback runs is
+// exactly what usage.MeterComplete now refuses to guess at.
+//
// 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.
+// fallback. The census inherits the same limit from the other end: censusPass
+// writes create-only, so two consoles racing it means the WINNER's box-local
+// registry becomes that day's census for every reader — and closure now makes
+// that permanent, where before it self-corrected on the next hour's pass
+// against whichever registry that pass read. Both production deployments are
+// single-box today, so both are currently inert; the structural fix for both
+// 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 {
@@ -368,6 +448,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
BoxDays: map[string]int{},
SourceDays: map[string]int{},
EgressMetered: map[string]bool{},
+ Census: map[string]dayCensus{},
}
// A box is metered for egress the moment it has EVER written a day —
// independent of the swept period, matching the struct's "wrote any
@@ -397,7 +478,14 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
toDay := to.UTC().Truncate(24 * time.Hour)
for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
date := usage.DayOf(d)
- owner := s.dayOwnerFunc(ctx, date, routes)
+ // One read of this day's census, shared by the owner lookup, the
+ // census source count below, and foldStorageAndCensus. All three
+ // used to Get and decode the same object separately — three
+ // sequential bucket round trips per day, on a path that includes a
+ // live page render.
+ census := s.readDayCensus(ctx, date)
+ sweep.Census[date] = census
+ owner := dayOwnerFunc(census, routes)
covered := false
metricsDays, edgeDays := 0, 0
storageOK, censusOK := false, false
@@ -420,7 +508,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
bl.Hours += float64(len(roll.Hours))
for _, hour := range roll.Hours {
for unit, um := range hour.Units {
- acct, _ := owner(unit)
+ acct := owner(unit)
mm := touch(acct)
var instN int
var cpuCoreHours, memGBHours float64
@@ -505,17 +593,19 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
}
// Census deliberately does NOT set `covered`: the legacy covered-day
- // sources are metrics/edge/storage only (foldStorageAndCensus says so
- // at economicspass.go:565-566), and that number reaches the live
- // economics view. A month of census snapshots alone must not turn
- // zero covered days into full coverage. It must also DECODE, not
- // merely exist — a malformed census is not a complete one.
- 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 {
- censusOK = true
- }
- }
+ // sources are metrics/edge/storage only (foldStorageAndCensus's doc
+ // comment says so), and that number reaches the live economics view.
+ // A month of census snapshots alone must not turn zero covered days
+ // into full coverage.
+ //
+ // It must also DECODE, not merely exist — a malformed census is not
+ // a complete one — and it must carry a Units map. A census that
+ // cannot say who owned a unit is not a complete census day, however
+ // cleanly it parses: every census written before this branch parses
+ // perfectly and answers nothing, and the metrics meters that lean on
+ // this count (usage.MeterComplete) would otherwise be labelled
+ // complete while their attribution came from today's live registry.
+ censusOK = census.Decoded && census.HaveUnits
// Strict: every discovered box must have reported this day.
if len(metricsBoxes) > 0 && metricsDays == len(metricsBoxes) {
@@ -564,7 +654,12 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
// 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 {
+//
+// census is the sweep's already-read censuses (usageSweep.Census), keyed by
+// day, so a day's census object is fetched once for the whole pass instead
+// of once here and once in sweepUsage. Nil, or a day it does not hold, falls
+// back to reading that day here — the function stays correct standing alone.
+func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, dst map[string]map[string]float64, census map[string]dayCensus) error {
touch := func(acct string) map[string]float64 {
mm := dst[acct]
if mm == nil {
@@ -616,14 +711,13 @@ func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, d
// Census does not gate coverage (sweepUsage's covered-day sources are
// metrics/edge/storage only) but still feeds app_months when present.
- 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: 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)
- }
+ dc, ok := census[date]
+ if !ok {
+ dc = s.readDayCensus(ctx, date)
+ }
+ if dc.Decoded {
+ for acct, ca := range dc.Day.Accounts {
+ touch(acct)[usage.MeterAppMonths] += float64(ca.Apps) / float64(daysInMonth)
}
}
}
@@ -827,6 +921,14 @@ const statementCloseDayGrace = 3
// refuses every write to a statement once it is closed. Each period's
// failure is logged and does not skip the other, matching billPass and
// economicsPass's stage-degrades-independently posture.
+//
+// Closing has no catch-up: a month is only ever closed by a tick landing in
+// the window between day 4 and the end of the FOLLOWING month, because the
+// previous-month period is the only one that ever closes. A console down for
+// a whole calendar month leaves the month before it permanently
+// Closed: false — still correct, still recomputed, but silently un-billable,
+// since billing reads only closed statements. Nothing notices; someone has
+// to.
func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
reg, err := s.openPassRegistry()
if err != nil {
@@ -902,7 +1004,7 @@ func (s *Server) statementsForMonth(ctx context.Context, month string, start, en
return fmt.Errorf("console: statements pass: %w", err)
}
meters := sweep.PerAccount // account -> meter -> value; folded into below
- if err := s.foldStorageAndCensus(ctx, start, end, meters); err != nil {
+ if err := s.foldStorageAndCensus(ctx, start, end, meters, sweep.Census); err != nil {
return fmt.Errorf("console: statements pass: %w", err)
}
fleetMM, err := s.fleetMeters(ctx, start, end)
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go
index 64089e6f0..fd5d6a88f 100644
--- a/internal/console/economicspass_test.go
+++ b/internal/console/economicspass_test.go
@@ -848,17 +848,26 @@ func TestStatementsPassCleanStorageDayCounts(t *testing.T) {
}
// A census snapshot is not a measurement. Census must feed its own source
-// count without touching CoveredDays, which foldStorageAndCensus documents
-// as metrics/edge/storage only (economicspass.go:516-517) and which reaches
-// the live economics view.
+// count without touching CoveredDays, which foldStorageAndCensus's doc
+// comment documents as metrics/edge/storage only and which reaches the live
+// economics view. (Cited by name, not by line: this citation has rotted
+// three times in one branch.)
func TestStatementsPassCensusDoesNotCountAsCoverage(t *testing.T) {
fx := newConsoleFixture(t)
ctx := context.Background()
now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)
+ // Units present (and non-empty — the field is omitempty, so an empty map
+ // does not survive the round trip): without it this day would not count
+ // for census either, which is
+ // TestStatementsPassCensusWithoutUnitsIsNotComplete's subject, not this
+ // one's.
putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{
V: 1, Date: "2026-08-01",
Accounts: map[string]usage.CensusAccount{fixtureAcct: {Apps: 1, Routes: 1}},
+ Units: map[string]usage.CensusUnit{
+ "carlos-app@a.example.com.service": {Account: fixtureAcct, App: fixtureApp},
+ },
})
if err := fx.srv.statementsPass(ctx, now); err != nil {
@@ -973,6 +982,128 @@ func TestCensusPassRecordsUnitOwners(t *testing.T) {
}
}
+// A route with no host must not contribute the activator's derived names.
+// They are not empty for an empty host — activator.UnitName("") is
+// "carlos-app@.service" — so an unguarded census writes those two keys once
+// per hostless route, last writer winning across accounts, and routeForUnit
+// (which does guard) would never match either of them anyway.
+func TestCensusPassSkipsDerivedNamesForHostlessRoute(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "", Kind: "service", Status: "ready",
+ Unit: "carlos-bespoke.service", App: fixtureApp, Account: "acme", Version: "v1",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ if err := fx.srv.censusPass(ctx, "2026-08-01"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-01"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cd usage.CensusDay
+ if err := json.Unmarshal(raw, &cd); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := cd.Units["carlos-bespoke.service"]; !ok {
+ t.Errorf("census lost the route's own Unit; units = %v", cd.Units)
+ }
+ for _, bogus := range []string{activator.UnitName(""), activator.ExecName("")} {
+ if _, ok := cd.Units[bogus]; ok {
+ t.Errorf("census recorded %q, which routeForUnit would never match", bogus)
+ }
+ }
+}
+
+// Two routes claiming the same unit name must resolve the same way in the
+// census as in routeForUnit, which returns the FIRST match. A census that
+// kept the last one would hand a day a different owner depending only on
+// whether its census had been written yet.
+func TestCensusPassKeepsFirstRouteForAmbiguousUnit(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "one.example.com", Kind: "service", Status: "ready",
+ Unit: "carlos-shared.service", App: "first", Account: "acme", Version: "v1"},
+ {Host: "two.example.com", Kind: "service", Status: "ready",
+ Unit: "carlos-shared.service", App: "second", Account: "acme", Version: "v1"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ if err := fx.srv.censusPass(ctx, "2026-08-01"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-01"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cd usage.CensusDay
+ if err := json.Unmarshal(raw, &cd); err != nil {
+ t.Fatal(err)
+ }
+ live, _ := routeForUnit(routes, "carlos-shared.service")
+ if got := cd.Units["carlos-shared.service"].App; got != live.App {
+ t.Errorf("census owner app = %q, routeForUnit says %q — census and fallback must agree", got, live.App)
+ }
+}
+
+// A census that parses cleanly but carries no Units map cannot answer who
+// owned a unit, so the sweep attributed that day against the LIVE registry —
+// the very bug this branch removes. Such a day must not count as a census
+// day, or a statement vouches for attribution nothing verified. Every census
+// written before this branch is exactly this shape.
+func TestStatementsPassCensusWithoutUnitsIsNotComplete(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)
+
+ putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{
+ V: 1, Date: "2026-08-01",
+ Accounts: map[string]usage.CensusAccount{fixtureAcct: {Apps: 1, Routes: 1}},
+ })
+ putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{})
+
+ if err := fx.srv.statementsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st usage.Statement
+ if err := json.Unmarshal(raw, &st); err != nil {
+ t.Fatal(err)
+ }
+ if got := st.SourceDays[usage.SourceCensus]; got != 0 {
+ t.Errorf("census source days = %d, want 0 — a census with no Units answers no ownership question", got)
+ }
+ if got := st.SourceDays[usage.SourceMetrics]; got != 1 {
+ t.Errorf("metrics source days = %d, want 1 — the metrics day itself did land", got)
+ }
+}
+
// A route created after the day's census was written must still be
// attributed to its owner, not to ops.
func TestSweepFallsBackForRouteMissingFromCensus(t *testing.T) {
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go
index bb6c0ac25..44a00c210 100644
--- a/internal/console/economicsview.go
+++ b/internal/console/economicsview.go
@@ -415,7 +415,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
if err != nil {
return nil, fmt.Errorf("console: load economics: %w", err)
}
- if err := s.foldStorageAndCensus(sweepCtx, from, to, sweep.PerAccount); err != nil {
+ if err := s.foldStorageAndCensus(sweepCtx, from, to, sweep.PerAccount, sweep.Census); err != nil {
return nil, fmt.Errorf("console: load economics: %w", err)
}
diff --git a/internal/usage/census.go b/internal/usage/census.go
index 061f55e40..4440cd4f9 100644
--- a/internal/usage/census.go
+++ b/internal/usage/census.go
@@ -26,6 +26,17 @@ type CensusDay struct {
// activator's derived names — to that route's owner, as of the moment
// this day's census was written. Absent on days written before this
// field existed; callers fall back to the live registry for those.
+ //
+ // A nil Units therefore conflates two different days: one written
+ // before this field existed, where the owners are unknown, and one
+ // whose deployment genuinely had zero routes, where the owners are
+ // known and there are none. The schema version is deliberately NOT
+ // bumped over this — a v2 would buy nothing any reader acts on today —
+ // but the conflation is written down so whoever does bump it knows what
+ // it would be worth. Consumers that must not guess (MeterComplete, via
+ // the census source count) read nil as "this day cannot answer
+ // ownership" and refuse to call the month complete, which is the safe
+ // reading of both cases.
Units map[string]CensusUnit `json:"units,omitempty"`
}
diff --git a/internal/usage/passes_test.go b/internal/usage/passes_test.go
index 6ddd33976..cb51dc555 100644
--- a/internal/usage/passes_test.go
+++ b/internal/usage/passes_test.go
@@ -97,7 +97,10 @@ func TestMeterSource(t *testing.T) {
func TestMeterCompleteNeedsEveryDay(t *testing.T) {
full := Statement{
DaysInMonth: 31,
- SourceDays: map[string]int{SourceMetrics: 31, SourceEdge: 30},
+ // Census is full here so this test stays about its own subject —
+ // every DAY of a meter's own source. The metrics meters' extra
+ // census requirement is TestMeterCompleteMetricsNeedsCensus's.
+ SourceDays: map[string]int{SourceMetrics: 31, SourceEdge: 30, SourceCensus: 31},
}
if !full.MeterComplete(MeterInstanceHours) {
t.Error("instance_hours with 31/31 metrics days should be complete")
@@ -116,6 +119,43 @@ func TestMeterCompleteNeedsEveryDay(t *testing.T) {
}
}
+// The metrics meters are attributed by unit name, and the day's census is
+// what answers who owned that unit. Complete metrics days say the numbers
+// arrived; they say nothing about whose they are. So a month whose census is
+// short cannot report a metrics meter complete, however many metrics days
+// landed — while the edge meters, which carry their account inside the edge
+// day object, are untouched by the census count.
+func TestMeterCompleteMetricsNeedsCensus(t *testing.T) {
+ short := Statement{
+ DaysInMonth: 31,
+ SourceDays: map[string]int{SourceMetrics: 31, SourceEdge: 31, SourceCensus: 30},
+ }
+ for _, m := range []string{MeterCPUCoreHours, MeterMemGBHours, MeterInstanceHours} {
+ if short.MeterComplete(m) {
+ t.Errorf("%s with 30/31 census days must NOT be complete: its attribution is unverified", m)
+ }
+ }
+ for _, m := range []string{MeterEgressGB, MeterRequests1k} {
+ if !short.MeterComplete(m) {
+ t.Errorf("%s must not be gated on census: EdgeRow carries its own account", m)
+ }
+ }
+
+ // No census at all is the pre-branch statement, and the strongest case.
+ none := Statement{DaysInMonth: 31, SourceDays: map[string]int{SourceMetrics: 31}}
+ if none.MeterComplete(MeterCPUCoreHours) {
+ t.Error("cpu_core_hours with no census days at all must NOT be complete")
+ }
+
+ full := Statement{
+ DaysInMonth: 31,
+ SourceDays: map[string]int{SourceMetrics: 31, SourceCensus: 31},
+ }
+ if !full.MeterComplete(MeterCPUCoreHours) {
+ t.Error("cpu_core_hours with 31/31 of both sources should be complete")
+ }
+}
+
func TestUpsertStatementRefusesClosedMonth(t *testing.T) {
s := release.NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/usage/statement.go b/internal/usage/statement.go
index 2ff60cd82..5e104874a 100644
--- a/internal/usage/statement.go
+++ b/internal/usage/statement.go
@@ -50,6 +50,16 @@ func StatementsPrefix(acct string) string { return "usage/accounts/" + acct + "/
const AccountsPrefix = "usage/accounts/"
// UpsertStatement CAS-replaces the account's month statement (3 attempts).
+//
+// A write to a CLOSED statement returns nil and changes nothing: closure is
+// the point, and a caller that keeps upserting every hour must not see an
+// error for the normal case. Closure is also irreversible in this code —
+// nothing here reopens a month — so the only way to recompute a month that
+// closed with a bad number is to DELETE the statement object,
+// usage/accounts/<acct>/<month>.json, and let the next tick's statementsPass
+// rebuild it from the day objects, which are all still there. Recorded here
+// because that recipe is wanted under pressure, by someone who has just
+// found a wrong number in a month that will not move.
func UpsertStatement(ctx context.Context, s release.Store, st Statement) error {
key := StatementKey(st.Account, st.Month)
body, err := json.Marshal(st)
@@ -104,10 +114,28 @@ func CreateDayObject(ctx context.Context, s release.Store, key string, v any) (b
// the source this meter derives from. False when the source is unknown, when
// the statement predates SourceDays, or when any day is missing — the
// package's "a missing source is never a zero" posture, applied to billing.
+//
+// A SourceMetrics meter additionally requires a complete CENSUS month. Those
+// meters are attributed by unit name, and the day's census is what answers
+// "who owned this unit" — a day whose census is missing or carries no Units
+// map falls back to attributing against the LIVE registry, which is exactly
+// the wrong-owner bug this branch exists to fix. Metrics days landing every
+// day of the month says the numbers arrived; it says nothing about whose
+// they are, so completeness for those meters is the AND of both sources.
+// The other meters do not need this: edge, storage and census meters each
+// carry their account inside the day object they are folded from, so
+// ownership arrives with the value rather than from a second lookup — see
+// MeterSource's doc comment for that asymmetry.
func (st Statement) MeterComplete(meter string) bool {
src := MeterSource(meter)
if src == "" || st.DaysInMonth <= 0 || st.SourceDays == nil {
return false
}
- return st.SourceDays[src] >= st.DaysInMonth
+ if st.SourceDays[src] < st.DaysInMonth {
+ return false
+ }
+ if src == SourceMetrics && st.SourceDays[SourceCensus] < st.DaysInMonth {
+ return false
+ }
+ return true
}
diff --git a/internal/usage/usage.go b/internal/usage/usage.go
index 3b3d2455a..063242509 100644
--- a/internal/usage/usage.go
+++ b/internal/usage/usage.go
@@ -51,6 +51,18 @@ const (
// MeterSource names the collection source a meter derives from, or "" for a
// meter whose completeness this sweep cannot vouch for.
//
+// The source named here is the meter's VALUE source, and for the metrics
+// meters that is not the whole story: cpu/mem/instance hours are attributed
+// to an account by unit name, and the answer to "who owned this unit that
+// day" comes from the day's census, not from the metrics rollup. So a
+// metrics meter needs BOTH sources complete, which is why MeterComplete
+// asks census of them and not of the others. The edge meters are the
+// asymmetry worth naming: egress and requests attribute from
+// usage.EdgeRow.Account, stamped into the edge day object by the box at
+// collection time, so the day object carries its own ownership and a
+// complete edge day is complete on its own terms. Storage and app-months
+// likewise carry their account in the day object they come from.
+//
// The fleet box-hour meters return "" deliberately: fleetMeters
// (console/economicspass.go) derives them from per-fleet objects that
// sweepUsage never reads, so platform metrics coverage says nothing about