usage: resolve unit ownership as of the usage day
The sweep re-attributed every day of the month against the live registry on every pass, and retired routes are hard-deleted. So an account that removed an app mid-month stopped being billed for the hours it had already used: its units matched nothing and fell to ops. The daily census already exists so existence-metering has history the live registry cannot give it. It now records unit ownership too, and the sweep asks the day's census. Days with no census fall back to the old behaviour, so nothing regresses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cEe5te3cyEcdF3JL2rN5M
3 files changed,
+210
−2
internal/console/economicspass.go+54 −2internal/console/economicspass_test.go+143 −0internal/usage/census.go+13 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 03e12a34a..25f1eb36d 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -111,6 +111,7 @@ func (s *Server) censusPass(ctx context.Context, date string) error { |
| } |
| accounts := map[string]usage.CensusAccount{} |
| apps := map[string]map[string]bool{} |
| + var units map[string]usage.CensusUnit |
| for _, rt := range routes { |
| acct := release.RouteAccount(rt) |
| c := accounts[acct] |
| @@ -123,8 +124,18 @@ func (s *Server) censusPass(ctx context.Context, date string) error { |
| c.Apps++ |
| } |
| accounts[acct] = c |
| + |
| + for _, name := range []string{rt.Unit, activator.UnitName(rt.Host), activator.ExecName(rt.Host)} { |
| + if name == "" { |
| + continue |
| + } |
| + if units == nil { |
| + units = map[string]usage.CensusUnit{} |
| + } |
| + units[name] = usage.CensusUnit{Account: acct, App: rt.App} |
| + } |
| } |
| - day := usage.CensusDay{V: 1, Date: date, Accounts: accounts} |
| + day := usage.CensusDay{V: 1, Date: date, Accounts: accounts, Units: units} |
| _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day) |
| return err |
| } |
| @@ -194,6 +205,46 @@ 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. |
| +// |
| +// 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. |
| +// |
| +// 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 |
| + } |
| + } |
| + // 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 |
| + } |
| +} |
| + |
| // unitOwner attributes a sampled systemd unit (or exec-child cgroup name) to |
| // its route's account/app. Matching mirrors OwnerAppMetrics (metricsview.go |
| // ~698-730): rather than parse a host back out of the unit string, it builds |
| @@ -346,6 +397,7 @@ 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) |
| covered := false |
| metricsDays, edgeDays := 0, 0 |
| storageOK, censusOK := false, false |
| @@ -368,7 +420,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, _, _ := unitOwner(routes, unit) |
| + acct, _ := owner(unit) |
| mm := touch(acct) |
| var instN int |
| var cpuCoreHours, memGBHours float64 |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| index 2831e035e..97d84e2e9 100644 |
| --- a/internal/console/economicspass_test.go |
| +++ b/internal/console/economicspass_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/metrics" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -878,3 +879,145 @@ func TestStatementsPassCensusDoesNotCountAsCoverage(t *testing.T) { |
| t.Errorf("census source days = %d, want 1", got) |
| } |
| } |
| + |
| +// Usage already recorded against an app must keep its owner after the route |
| +// is deleted. Before this, the next pass reattributed those hours to ops. |
| +func TestSweepKeepsOwnerAfterRouteDeleted(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + // Day 1: the route exists, and the census records who owns its unit. |
| + 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: "acme", App: fixtureApp}, |
| + }, |
| + }) |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{ |
| + V: 1, Box: "box1", Date: "2026-08-01", |
| + Hours: map[string]metrics.HourAgg{ |
| + "00": {Units: map[string]map[string]metrics.Agg{ |
| + "carlos-app@a.example.com.service": {"cpu": {Avg: 0.5, N: 60}}, |
| + }}, |
| + }, |
| + }) |
| + |
| + // Day 2: the route is gone from the registry entirely — no Upsert here. |
| + now := time.Date(2026, 8, 2, 6, 0, 0, 0, time.UTC) |
| + if err := fx.srv.statementsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey("acme", "2026-08")) |
| + if err != nil { |
| + t.Fatalf("statement for acme missing: %v", err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.Meters[usage.MeterCPUCoreHours]; math.Abs(got-0.5) > 1e-9 { |
| + t.Errorf("cpu_core_hours = %v, want 0.5 — the deleted route's day must stay attributed", got) |
| + } |
| + |
| + opsRaw, _, err := fx.rel.Get(ctx, usage.StatementKey(release.OpsAccount, "2026-08")) |
| + if err != nil { |
| + t.Fatalf("ops statement missing: %v", err) |
| + } |
| + var opsSt usage.Statement |
| + if err := json.Unmarshal(opsRaw, &opsSt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := opsSt.Meters[usage.MeterCPUCoreHours]; got != 0 { |
| + t.Errorf("ops cpu_core_hours = %v, want 0 — the deleted route's usage must not fall to ops", got) |
| + } |
| +} |
| + |
| +// censusPass must record the unit names unitOwner would have matched, so |
| +// the sweep can still resolve them after the route is gone. |
| +func TestCensusPassRecordsUnitOwners(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: "a.example.com", Kind: "instance", Status: "ready", |
| + 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) |
| + } |
| + want := activator.UnitName("a.example.com") // carlos-app@a.example.com.service |
| + cu, ok := cd.Units[want] |
| + if !ok { |
| + t.Fatalf("census has no entry for %q; units = %v", want, cd.Units) |
| + } |
| + if cu.Account != "acme" || cu.App != fixtureApp { |
| + t.Errorf("census owner = %+v, want acme/%s", cu, fixtureApp) |
| + } |
| +} |
| + |
| +// 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) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + // The census exists for the day but predates this route. |
| + putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{ |
| + V: 1, Date: "2026-08-01", |
| + Accounts: map[string]usage.CensusAccount{}, |
| + Units: map[string]usage.CensusUnit{"carlos-app@other.example.com.service": {Account: "acme", App: "other"}}, |
| + }) |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{ |
| + V: 1, Box: "box1", Date: "2026-08-01", |
| + Hours: map[string]metrics.HourAgg{ |
| + "00": {Units: map[string]map[string]metrics.Agg{ |
| + "carlos-app@late.example.com.service": {"cpu": {Avg: 0.5, N: 60}}, |
| + }}, |
| + }, |
| + }) |
| + |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "late.example.com", Kind: "instance", Status: "ready", |
| + App: "late", Account: "acme", Version: "v1", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + |
| + if err := fx.srv.statementsPass(ctx, time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey("acme", "2026-08")) |
| + if err != nil { |
| + t.Fatalf("acme statement missing — the late route fell to ops: %v", err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.Meters[usage.MeterCPUCoreHours]; math.Abs(got-0.5) > 1e-9 { |
| + t.Errorf("acme cpu_core_hours = %v, want 0.5", got) |
| + } |
| +} |
diff --git a/internal/usage/census.go b/internal/usage/census.go| index b39564817..061f55e40 100644 |
| --- a/internal/usage/census.go |
| +++ b/internal/usage/census.go |
| @@ -6,6 +6,14 @@ type CensusAccount struct { |
| Routes int `json:"routes"` |
| } |
| +// CensusUnit is the owner of one systemd unit on one day. Recorded because |
| +// the registry holds only live state: a route deleted today would otherwise |
| +// take this month's already-recorded usage with it. |
| +type CensusUnit struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| + |
| // CensusDay is usage/census/<date>.json — the whole deployment's per-account |
| // counts, snapshotted once per UTC day by the console's economics pass so |
| // existence-metering has history even though the registry holds only live |
| @@ -14,6 +22,11 @@ type CensusDay struct { |
| V int `json:"v"` // 1 |
| Date string `json:"date"` |
| Accounts map[string]CensusAccount `json:"accounts"` // account name ("" normalized to "ops") |
| + // Units maps every unit name a route answers to — its own Unit plus the |
| + // 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. |
| + Units map[string]CensusUnit `json:"units,omitempty"` |
| } |
| // CensusDayKey is the bucket key for a day's census. |