carlos / platform Public

usage: census, storage and statement shapes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8bba0d647d4e71786280be5a18ddd56036c8d5d1 parent 238c17a
4 files changed, +195 −0
  • internal/usage/census.go +23 −0
  • internal/usage/passes_test.go +70 −0
  • internal/usage/statement.go +78 −0
  • internal/usage/storage.go +24 −0
diff --git a/internal/usage/census.go b/internal/usage/census.go
new file mode 100644
index 000000000..b39564817
--- /dev/null
+++ b/internal/usage/census.go
@@ -0,0 +1,23 @@
+package usage
+
+// CensusAccount is one account's existence-count on one day.
+type CensusAccount struct {
+ Apps int `json:"apps"`
+ Routes int `json:"routes"`
+}
+
+// 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
+// state.
+type CensusDay struct {
+ V int `json:"v"` // 1
+ Date string `json:"date"`
+ Accounts map[string]CensusAccount `json:"accounts"` // account name ("" normalized to "ops")
+}
+
+// CensusDayKey is the bucket key for a day's census.
+func CensusDayKey(date string) string { return "usage/census/" + date + ".json" }
+
+// CensusPrefix is the List prefix for census days.
+const CensusPrefix = "usage/census/"
diff --git a/internal/usage/passes_test.go b/internal/usage/passes_test.go
new file mode 100644
index 000000000..2beb0d303
--- /dev/null
+++ b/internal/usage/passes_test.go
@@ -0,0 +1,70 @@
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKeys(t *testing.T) {
+ if got := CensusDayKey("2026-08-11"); got != "usage/census/2026-08-11.json" {
+ t.Fatalf("census key = %q", got)
+ }
+ if got := StorageDayKey("2026-08-11"); got != "usage/storage/2026-08-11.json" {
+ t.Fatalf("storage key = %q", got)
+ }
+ if got := StatementKey("bab", "2026-08"); got != "usage/accounts/bab/2026-08.json" {
+ t.Fatalf("statement key = %q", got)
+ }
+}
+
+func TestCreateDayObjectIsCreateOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ day := CensusDay{V: 1, Date: "2026-08-11", Accounts: map[string]CensusAccount{"bab": {Apps: 2, Routes: 3}}}
+ created, err := CreateDayObject(ctx, s, CensusDayKey(day.Date), day)
+ if err != nil || !created {
+ t.Fatalf("first create = %v %v", created, err)
+ }
+ // Second console racing the same day: no error, no overwrite.
+ loser := CensusDay{V: 1, Date: "2026-08-11", Accounts: map[string]CensusAccount{"bab": {Apps: 99}}}
+ created, err = CreateDayObject(ctx, s, CensusDayKey(day.Date), loser)
+ if err != nil || created {
+ t.Fatalf("second create = %v %v", created, err)
+ }
+ raw, _, _ := s.Get(ctx, CensusDayKey(day.Date))
+ var got CensusDay
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Accounts["bab"].Apps != 2 {
+ t.Fatalf("winner overwritten: %+v", got)
+ }
+}
+
+func TestUpsertStatementReplaces(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ st := Statement{V: 1, Account: "bab", Month: "2026-08",
+ Meters: map[string]float64{MeterEgressGB: 1.5}, CoveredDays: 10, DaysInMonth: 31,
+ UpdatedAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC)}
+ if err := UpsertStatement(ctx, s, st); err != nil {
+ t.Fatal(err)
+ }
+ st.Meters[MeterEgressGB] = 2.5
+ st.CoveredDays = 11
+ if err := UpsertStatement(ctx, s, st); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, _ := s.Get(ctx, StatementKey("bab", "2026-08"))
+ var got Statement
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Meters[MeterEgressGB] != 2.5 || got.CoveredDays != 11 {
+ t.Fatalf("statement = %+v", got)
+ }
+}
diff --git a/internal/usage/statement.go b/internal/usage/statement.go
new file mode 100644
index 000000000..7822bb876
--- /dev/null
+++ b/internal/usage/statement.go
@@ -0,0 +1,78 @@
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Statement is usage/accounts/<acct>/<month>.json — one account's
+// month-to-date totals per meter, the durable input a future billing engine
+// consumes (statement × price sheet = invoice; spec: billing readiness).
+// Meters is keyed by the Meter* vocabulary. CoveredDays/DaysInMonth carry
+// the honesty label every rendered total must show.
+type Statement struct {
+ V int `json:"v"` // 1
+ Account string `json:"account"`
+ Month string `json:"month"` // "2006-01"
+ Meters map[string]float64 `json:"meters"`
+ CoveredDays int `json:"covered_days"`
+ DaysInMonth int `json:"days_in_month"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// StatementKey is the bucket key for an account's month statement.
+func StatementKey(acct, month string) string {
+ return "usage/accounts/" + acct + "/" + month + ".json"
+}
+
+// StatementsPrefix lists an account's statements.
+func StatementsPrefix(acct string) string { return "usage/accounts/" + acct + "/" }
+
+// AccountsPrefix discovers accounts that have statements.
+const AccountsPrefix = "usage/accounts/"
+
+// UpsertStatement CAS-replaces the account's month statement (3 attempts).
+func UpsertStatement(ctx context.Context, s release.Store, st Statement) error {
+ key := StatementKey(st.Account, st.Month)
+ body, err := json.Marshal(st)
+ if err != nil {
+ return err
+ }
+ for attempt := 0; attempt < 3; attempt++ {
+ _, etag, err := s.Get(ctx, key)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ etag = ""
+ }
+ if _, err := s.Put(ctx, key, body, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("usage: %s: lost CAS race three times", key)
+}
+
+// CreateDayObject writes key create-only. (false, nil) means another writer
+// won today — the daily-pass idempotence rule (spec: error handling).
+func CreateDayObject(ctx context.Context, s release.Store, key string, v any) (bool, error) {
+ body, err := json.Marshal(v)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
diff --git a/internal/usage/storage.go b/internal/usage/storage.go
new file mode 100644
index 000000000..33f67a756
--- /dev/null
+++ b/internal/usage/storage.go
@@ -0,0 +1,24 @@
+package usage
+
+// AppStorage is one app's occupancy on one day: deployment-bucket bytes
+// under the app's prefix, plus its tenant object-store bucket when one is
+// provisioned. StoreUnswept marks a store the pass could not size (bad
+// credentials, endpoint down) — the dashboard must show "unswept", never 0.
+type AppStorage struct {
+ BucketBytes int64 `json:"bucket_bytes"`
+ StoreBytes int64 `json:"store_bytes,omitempty"`
+ StoreUnswept bool `json:"store_unswept,omitempty"`
+}
+
+// StorageDay is usage/storage/<date>.json, keyed "<acct>/<app>".
+type StorageDay struct {
+ V int `json:"v"` // 1
+ Date string `json:"date"`
+ Apps map[string]AppStorage `json:"apps"`
+}
+
+// StorageDayKey is the bucket key for a day's storage sweep.
+func StorageDayKey(date string) string { return "usage/storage/" + date + ".json" }
+
+// StoragePrefix is the List prefix for storage days.
+const StoragePrefix = "usage/storage/"