console: hourly economics pass with create-only daily census
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+170
−0
cmd/console/main.go+5 −0internal/console/economicspass.go+87 −0internal/console/economicspass_test.go+54 −0internal/console/store.go+24 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 0de48d4a1..dd004fffc 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -259,6 +259,11 @@ func serve() { |
| // here rather than in NewServer so constructing a Server — which every |
| // console test does — never starts a goroutine or sends mail. |
| go srv.StartAlertWatcher(context.Background()) |
| + // The economics pass writes the daily usage/census/storage/statement |
| + // objects and pulls the AWS bill (internal/console/economicspass.go). |
| + // Same placement rule as the alert watcher: started here, never in |
| + // NewServer, so constructing a Server stays goroutine-free. |
| + go srv.StartEconomicsPass(context.Background()) |
| addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090") |
| log.Printf("console: %s listening on %s", origin, addr) |
| log.Fatal(http.ListenAndServe(addr, srv)) |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| new file mode 100644 |
| index 000000000..b99896192 |
| --- /dev/null |
| +++ b/internal/console/economicspass.go |
| @@ -0,0 +1,87 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// economicsPassInterval is how often the pass wakes. Each day's objects are |
| +// create-only, so waking hourly costs one conditional GET per family once |
| +// the day is written — and catches up promptly after a console restart. |
| +const economicsPassInterval = time.Hour |
| + |
| +// StartEconomicsPass runs economicsPass immediately and then hourly until |
| +// ctx is done. Started from cmd/console's serve(), deliberately NOT from |
| +// NewServer — the StartAlertWatcher rule: test servers never grow |
| +// goroutines (alertwatch.go:44). |
| +func (s *Server) StartEconomicsPass(ctx context.Context) { |
| + t := time.NewTicker(economicsPassInterval) |
| + defer t.Stop() |
| + for { |
| + if err := s.economicsPass(ctx, time.Now()); err != nil { |
| + log.Printf("console: economics pass: %v", err) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// economicsPass runs the daily economics writes for now's UTC day: census, |
| +// storage sweep, statements, bill pull. Each stage degrades independently — |
| +// a failed stage logs and the next still runs, because a missing census day |
| +// must not cost the month's statements (spec: error handling). |
| +func (s *Server) economicsPass(ctx context.Context, now time.Time) error { |
| + date := usage.DayOf(now) |
| + if err := s.censusPass(ctx, date); err != nil { |
| + log.Printf("console: census pass %s: %v", date, err) |
| + } |
| + // Task 9 adds: s.storagePass(ctx, date) |
| + // Task 10 adds: s.statementsPass(ctx, now) |
| + // Task 12 adds: s.billPass(ctx, now) |
| + return nil |
| +} |
| + |
| +// censusPass snapshots per-account app/route counts, create-only. Counted by |
| +// account NAME: release.RouteAccount normalizes a route with no account |
| +// (Route.Account == "") to release.OpsAccount, the deployment operator's |
| +// built-in account — so pre-accounts routes land in one place instead of |
| +// scattering under "". |
| +func (s *Server) censusPass(ctx context.Context, date string) error { |
| + key := usage.CensusDayKey(date) |
| + if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil { |
| + return nil // today already written (by us or a sibling console) |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + accounts := map[string]usage.CensusAccount{} |
| + apps := map[string]map[string]bool{} |
| + for _, rt := range routes { |
| + acct := release.RouteAccount(rt) |
| + c := accounts[acct] |
| + c.Routes++ |
| + if apps[acct] == nil { |
| + apps[acct] = map[string]bool{} |
| + } |
| + if rt.App != "" && !apps[acct][rt.App] { |
| + apps[acct][rt.App] = true |
| + c.Apps++ |
| + } |
| + accounts[acct] = c |
| + } |
| + day := usage.CensusDay{V: 1, Date: date, Accounts: accounts} |
| + _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day) |
| + return err |
| +} |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| new file mode 100644 |
| index 000000000..94732bb8d |
| --- /dev/null |
| +++ b/internal/console/economicspass_test.go |
| @@ -0,0 +1,54 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func TestEconomicsPassWritesCensusOnce(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| + |
| + if err := fx.srv.economicsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var day usage.CensusDay |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(day.Accounts) == 0 { |
| + t.Fatal("census counted nothing — fixture routes should appear") |
| + } |
| + // Idempotence: a second run the same day must not error or rewrite. |
| + before := string(raw) |
| + if err := fx.srv.economicsPass(ctx, now.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _, _ := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")) |
| + if string(after) != before { |
| + t.Fatal("second pass rewrote the census") |
| + } |
| +} |
| + |
| +func TestStoreAccountsListsAll(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + accts, err := fx.st.Accounts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The fixture mints at least one account; every row must carry id+sqid+name. |
| + for _, a := range accts { |
| + if a.ID == 0 || a.Sqid == "" || a.Name == "" { |
| + t.Fatalf("incomplete account row: %+v", a) |
| + } |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index dfef5c1b1..eddf67286 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -754,6 +754,30 @@ WHERE m.address = ? AND a.deleted_at = 0 ORDER BY a.created_at, a.id`, address) |
| return out, rows.Err() |
| } |
| +// Accounts lists every live account — the economics pass's iteration set (an |
| +// account with no statements yet must still appear in the census). Filtered |
| +// by deleted_at like every other account reader (AccountBySqid, AccountsFor): |
| +// a retired account stops accruing usage the same tick it stops billing. |
| +func (s *Store) Accounts() ([]Account, error) { |
| + rows, err := s.db.Query(`SELECT id, sqid, name, created_at FROM accounts |
| +WHERE deleted_at = 0 ORDER BY name`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Account |
| + for rows.Next() { |
| + var a Account |
| + var created int64 |
| + if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created); err != nil { |
| + return nil, err |
| + } |
| + a.CreatedAt = time.Unix(created, 0).UTC() |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // Role is address's role in the account, "" when they are not a member. It is |
| // the one permission check every account-scoped route makes. |
| func (s *Store) Role(accountID int64, address string) (string, error) { |