usage: census, storage and statement shapes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+195
−0
internal/usage/census.go+23 −0internal/usage/passes_test.go+70 −0internal/usage/statement.go+78 −0internal/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/" |