carlos / platform Public

console: hourly economics pass with create-only daily census

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2486cb34953310314e782dd4c48430784a21fd4d parent b021b68
4 files changed, +170 −0
  • cmd/console/main.go +5 −0
  • internal/console/economicspass.go +87 −0
  • internal/console/economicspass_test.go +54 −0
  • internal/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) {