server: free-pool gauge — spend/budget/pressure recorded each meter sweep, no behavior change (#871)
Slice 1 of docs/free-pool.md (PR #854): the cloud service estimates free+demo daily cost from a declared cost model (LCHAT_POOL_COSTS), derives the pool budget from the X%-of-total-cost ceiling + Y% of net revenue + an operator floor, and records one pool_meter row per sweep with a log line. GET /api/pool (admin token) serves the latest reading, labelled an estimate. Revenue input is an additive net_eur_year field per price-book tier. Ships dark: off unless LCHAT_POOL_MAX_SHARE is set; nothing gates, nothing actuates — admission control is the next slice, armed only after the gauge has been watched (the reaper's dry-run-then-arm pattern).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+670
−10
billing.go+5 −0cloud.go+38 −9cloud_db.go+74 −1docs/cloud-provisioner.md+26 −0meter.go+5 −0pool.go+264 −0pool_test.go+258 −0
diff --git a/billing.go b/billing.go| index a5239f0..278774f 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -49,6 +49,11 @@ type billingTier struct { |
| Name string `json:"name"` |
| Label string `json:"label"` |
| Price string `json:"price"` |
| + // NetEURYear is what one subscription nets per year after fees — the free |
| + // pool's revenue input (docs/free-pool.md), keeping the standing property |
| + // that the server never asks Stripe what a price costs. Optional and |
| + // additive: entries without it contribute nothing to the pool budget. |
| + NetEURYear float64 `json:"net_eur_year"` |
| } |
| // stripeBilling holds the cloud service's Stripe configuration. nil on a |
diff --git a/cloud.go b/cloud.go| index 8ca6b1f..1c5bb0e 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -91,6 +91,7 @@ type cloudServer struct { |
| // instance's env (LCHAT_METER_URL) alongside a minted meter token so |
| // sidecar usage reports can flow (#857). Empty = the usage pipe is off. |
| usageURL string |
| + pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off |
| } |
| func (s *cloudServer) handler() http.Handler { |
| @@ -112,6 +113,8 @@ func (s *cloudServer) handler() http.Handler { |
| mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler)) |
| mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listTokensHandler)) |
| mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeTokenHandler)) |
| + // The free-pool gauge (pool.go) — read-only, operator-facing. |
| + mux.HandleFunc("GET /api/pool", s.admin(s.poolHandler)) |
| mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) |
| return mux |
| } |
| @@ -430,6 +433,10 @@ func runCloud(args []string) { |
| reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)") |
| meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)") |
| createBudget := fs.Int("max-creates-per-day", envInt("LCHAT_CLOUD_MAX_CREATES_PER_DAY", 0), "fleet-wide creation circuit breaker: max spaces per trailing 24h (0 = uncapped)") |
| + poolMaxShare := fs.Int("pool-max-share", envInt("LCHAT_POOL_MAX_SHARE", 0), "free-pool ceiling as % of total cost (0 = pool gauge off)") |
| + poolRevenueShare := fs.Int("pool-revenue-share", envInt("LCHAT_POOL_REVENUE_SHARE", 30), "% of net revenue that funds the free pool") |
| + poolBaseEURDay := fs.Float64("pool-base-eur-day", envFloat("LCHAT_POOL_BASE_EUR_DAY", 0.20), "operator floor: pool budget in EUR/day at zero revenue") |
| + poolDemoShare := fs.Int("pool-demo-share", envInt("LCHAT_POOL_DEMO_SHARE", 20), "% of the pool reserved for demo spaces") |
| _ = fs.Parse(args) |
| if *baseDomain == "" || !validHost("x."+*baseDomain) { |
| @@ -467,22 +474,33 @@ func runCloud(args []string) { |
| if billing != nil { |
| billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG") |
| } |
| + poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS")) |
| + if err != nil { |
| + die("cloud: LCHAT_POOL_COSTS: %v", err) |
| + } |
| s := &cloudServer{ |
| - db: db, |
| - adminHash: hashToken(admin), |
| - controlToken: controlToken, |
| - control: controlClient(*controlSocket), |
| - baseDomain: *baseDomain, |
| - quota: *quota, |
| - reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour, |
| - reapDryRun: *reapDryRun, |
| - reapMax: *reapMax, |
| + db: db, |
| + adminHash: hashToken(admin), |
| + controlToken: controlToken, |
| + control: controlClient(*controlSocket), |
| + baseDomain: *baseDomain, |
| + quota: *quota, |
| + reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour, |
| + reapDryRun: *reapDryRun, |
| + reapMax: *reapMax, |
| billing: billing, |
| storageCaps: storageCaps, |
| replicationClasses: replicationClasses, |
| createBudget: *createBudget, |
| usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")), |
| + pool: poolConfig{ |
| + maxShare: *poolMaxShare, |
| + revenueShare: *poolRevenueShare, |
| + baseEURDay: *poolBaseEURDay, |
| + demoShare: *poolDemoShare, |
| + costs: poolCosts, |
| + }, |
| } |
| if s.usageURL != "" { |
| log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL) |
| @@ -490,6 +508,17 @@ func runCloud(args []string) { |
| if *createBudget > 0 { |
| log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget) |
| } |
| + if s.pool.enabled() { |
| + log.Printf("cloud: free-pool gauge on (ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)", |
| + s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare) |
| + if billing != nil { |
| + for _, t := range billing.tiers { |
| + if t.NetEURYear <= 0 { |
| + log.Printf("cloud: pool: tier %q has no net_eur_year in LCHAT_STRIPE_PRICES — its spaces fund the pool as €0", t.Key) |
| + } |
| + } |
| + } |
| + } |
| if len(storageCaps) > 0 { |
| log.Printf("cloud: storage caps on (%d entries)", len(storageCaps)) |
| } |
diff --git a/cloud_db.go b/cloud_db.go| index bed2a39..f668d70 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -76,7 +76,10 @@ CREATE TABLE IF NOT EXISTS creates_log ( |
| if err := db.migrateMeter(); err != nil { |
| return err |
| } |
| - return db.migrateUsage() |
| + if err := db.migrateUsage(); err != nil { |
| + return err |
| + } |
| + return db.migratePool() |
| } |
| // migrateUsage (#857): the per-space usage ledger — quantities only (token |
| @@ -663,6 +666,76 @@ func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) { |
| return out, rows.Err() |
| } |
| +// migratePool adds the free-pool gauge's history table (docs/free-pool.md): |
| +// one row per meter sweep, enough for the control loop, the admin API, and |
| +// eventually the status page. Additive like every other migration here. |
| +func (db *cloudDB) migratePool() error { |
| + _, err := db.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS pool_meter ( |
| + ts INTEGER PRIMARY KEY, |
| + free_count INTEGER NOT NULL, |
| + demo_count INTEGER NOT NULL, |
| + paid_count INTEGER NOT NULL, |
| + free_cost_day REAL NOT NULL, |
| + total_cost_day REAL NOT NULL, |
| + net_revenue_day REAL NOT NULL, |
| + budget_day REAL NOT NULL, |
| + pressure REAL NOT NULL |
| +);`) |
| + return err |
| +} |
| + |
| +// poolRows is the pool gauge's sweep set: what the budget math needs to know |
| +// about every finalized space. NetEURYear stays zero here — the DB layer |
| +// doesn't know the price book; the sweep resolves it. |
| +func (db *cloudDB) poolRows() ([]poolRow, error) { |
| + rows, err := db.sql.Query( |
| + `SELECT entitlement, tier, storage_bytes FROM spaces WHERE host IS NOT NULL`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []poolRow |
| + for rows.Next() { |
| + var r poolRow |
| + if err := rows.Scan(&r.Entitlement, &r.Tier, &r.StorageBytes); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, r) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// insertPoolMeter records one gauge reading and prunes history past ~400 days |
| +// (a year of context plus slack; the table stays a few thousand rows). |
| +func (db *cloudDB) insertPoolMeter(ts int64, r poolReading) error { |
| + if _, err := db.sql.Exec( |
| + `INSERT OR REPLACE INTO pool_meter |
| + (ts, free_count, demo_count, paid_count, free_cost_day, total_cost_day, net_revenue_day, budget_day, pressure) |
| + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| + ts, r.FreeCount, r.DemoCount, r.PaidCount, r.FreeCostDay, r.TotalCostDay, r.NetRevenueDay, r.BudgetDay, r.Pressure, |
| + ); err != nil { |
| + return err |
| + } |
| + _, err := db.sql.Exec(`DELETE FROM pool_meter WHERE ts < ?`, ts-400*24*3600) |
| + return err |
| +} |
| + |
| +// latestPoolMeter reads the newest gauge reading (ok=false: never swept). |
| +func (db *cloudDB) latestPoolMeter() (ts int64, r poolReading, ok bool, err error) { |
| + err = db.sql.QueryRow( |
| + `SELECT ts, free_count, demo_count, paid_count, free_cost_day, total_cost_day, net_revenue_day, budget_day, pressure |
| + FROM pool_meter ORDER BY ts DESC LIMIT 1`, |
| + ).Scan(&ts, &r.FreeCount, &r.DemoCount, &r.PaidCount, &r.FreeCostDay, &r.TotalCostDay, &r.NetRevenueDay, &r.BudgetDay, &r.Pressure) |
| + if err == sql.ErrNoRows { |
| + return 0, r, false, nil |
| + } |
| + if err != nil { |
| + return 0, r, false, err |
| + } |
| + return ts, r, true, nil |
| +} |
| + |
| // spaceBillingByID reads the billing state a quota push needs. |
| func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) { |
| m.ID = id |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index a8590ed..5caee7c 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -405,6 +405,32 @@ every space streams (the pre-ladder status quo). What the classes mean — |
| sync cadence, loss windows, and the graceful-flush guarantee — lives with |
| the mechanism in `registry.go` and the ops guide. |
| +## The free-pool gauge (docs/free-pool.md slice 1) |
| + |
| +The free pool runs the free tier as a budget: free + demo spend must never |
| +exceed a configured share of total cost, funded by paid revenue with an |
| +operator floor. **Slice 1 is a gauge only** — it computes and records, gates |
| +nothing, and ships off by default. At the end of each meter sweep the cloud |
| +service estimates every ledger space's daily cost from a declared cost model |
| +(`LCHAT_POOL_COSTS`, defaults = the docs/free-pool.md snapshot), derives |
| + |
| +``` |
| +budget_day = min( X% × total_cost_day, max(floor, Y% × net_revenue_day) ) |
| +pressure = free_cost_day / budget_day |
| +``` |
| + |
| +and records one `pool_meter` row (pruned past ~400 days) plus a log line. |
| +Knobs: `LCHAT_POOL_MAX_SHARE` (X — **0/unset = gauge off**), |
| +`LCHAT_POOL_REVENUE_SHARE` (Y, default 30), `LCHAT_POOL_BASE_EUR_DAY` |
| +(default 0.20), `LCHAT_POOL_DEMO_SHARE` (default 20, reserved for the demo |
| +tier). Revenue comes from an additive `net_eur_year` field per tier in |
| +`LCHAT_STRIPE_PRICES` (entries without it fund the pool as €0 and log a |
| +startup warning). `GET /api/pool` (admin token) serves the latest reading — |
| +labelled an estimate, because it is one: derived from declared constants, |
| +not metered. Admission control (`free_pool_full`) and demo coupling are |
| +later slices, armed only after the gauge has been watched — the reaper's |
| +dry-run-then-arm pattern. |
| + |
| ## Billing (Stripe) — tiers, quota exemption, no metering |
| Optional space upgrades (`billing.go`): a person who created a space can pay a |
diff --git a/meter.go b/meter.go| index b56a654..fcb5fc8 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -126,6 +126,11 @@ func (s *cloudServer) meterOnce() (metered int) { |
| if metered > 0 { |
| log.Printf("cloud: meter: refreshed %d space(s)", metered) |
| } |
| + // The free-pool gauge rides the same sweep (pool.go): the storage figures |
| + // it reads from the ledger were refreshed moments ago. |
| + if s.pool.enabled() { |
| + s.poolSweep(time.Now()) |
| + } |
| return metered |
| } |
diff --git a/pool.go b/pool.go| new file mode 100644 |
| index 0000000..68175b0 |
| --- /dev/null |
| +++ b/pool.go |
| @@ -0,0 +1,264 @@ |
| +package main |
| + |
| +// The free pool, slice 1 (docs/free-pool.md): a GAUGE, no behavior change. |
| +// Every meter sweep estimates what the free tier costs per day (from a tiny |
| +// config-declared cost model), derives the pool's daily budget from the |
| +// percent-of-total-cost ceiling + net revenue + an operator floor, and records |
| +// one pressure reading. Nothing here gates a create or touches an instance — |
| +// admission control and demo coupling are later slices, taken only once this |
| +// gauge has been watched for a while (the reaper's dry-run-then-arm pattern). |
| +// |
| +// The readings are ESTIMATES by construction: per-instance cost is derived |
| +// from declared constants (awake fractions, slot/storage rates), not metered. |
| +// That's deliberate — real cost attribution is heavy machinery for cents — |
| +// but every surface that shows a reading must say so. |
| + |
| +import ( |
| + "fmt" |
| + "log" |
| + "math" |
| + "net/http" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// poolCosts is the declared cost model (LCHAT_POOL_COSTS): euro rates and the |
| +// assumed awake fractions. Defaults are the 2026-07 snapshot from |
| +// docs/free-pool.md; formulas are the contract, these numbers drift and get |
| +// re-measured at tier-economics re-derivations. |
| +type poolCosts struct { |
| + fixedDay float64 // whole cloud plane per day (box + router/home/push/cloud) |
| + slotDay float64 // one materialized instance slot per day |
| + reqDay float64 // S3 request tail of one always-awake instance per day |
| + storageGBDay float64 // one stored GB per day (replica x retention) |
| + awakeFree float64 // assumed awake fraction of a free/paid space |
| + awakeDemo float64 // assumed awake fraction of a demo (short-lived, poked) |
| +} |
| + |
| +func defaultPoolCosts() poolCosts { |
| + return poolCosts{ |
| + fixedDay: 1.17, |
| + slotDay: 0.027, |
| + reqDay: 0.002, |
| + storageGBDay: 0.0014, |
| + awakeFree: 0.05, |
| + awakeDemo: 0.30, |
| + } |
| +} |
| + |
| +// parsePoolCosts reads LCHAT_POOL_COSTS ("fixed_day=1.17,slot_day=0.027,…"). |
| +// Absent keys keep their defaults; an unknown key or bad value is a deployment |
| +// mistake and errors rather than silently mismeasuring. |
| +func parsePoolCosts(v string) (poolCosts, error) { |
| + c := defaultPoolCosts() |
| + if strings.TrimSpace(v) == "" { |
| + return c, nil |
| + } |
| + fields := map[string]*float64{ |
| + "fixed_day": &c.fixedDay, |
| + "slot_day": &c.slotDay, |
| + "req_day": &c.reqDay, |
| + "storage_gb_day": &c.storageGBDay, |
| + "awake_free": &c.awakeFree, |
| + "awake_demo": &c.awakeDemo, |
| + } |
| + for _, pair := range strings.Split(v, ",") { |
| + k, val, ok := strings.Cut(strings.TrimSpace(pair), "=") |
| + if !ok { |
| + return c, fmt.Errorf("bad entry %q (want key=value)", pair) |
| + } |
| + dst, known := fields[strings.TrimSpace(k)] |
| + if !known { |
| + return c, fmt.Errorf("unknown cost %q", k) |
| + } |
| + n, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| + if err != nil || n < 0 { |
| + return c, fmt.Errorf("cost %q: bad value %q", k, val) |
| + } |
| + *dst = n |
| + } |
| + return c, nil |
| +} |
| + |
| +// poolConfig is the budget's shape: X caps the subsidy as a share of total |
| +// cost, Y indexes it to revenue, the floor keeps a pool alive at zero revenue, |
| +// and D reserves a slice for demos (unused until the demo tier exists). |
| +type poolConfig struct { |
| + maxShare int // X: free-spend ceiling as % of total cost; 0 = pool off |
| + revenueShare int // Y: % of net revenue that funds the pool |
| + baseEURDay float64 |
| + demoShare int // D: % of the pool reserved for demos |
| + costs poolCosts |
| +} |
| + |
| +func (c poolConfig) enabled() bool { return c.maxShare > 0 } |
| + |
| +// poolRow is one ledger space as the budget math sees it. NetEURYear is |
| +// resolved from the price book by the caller (0 for free/demo rows and for |
| +// tiers whose price-book entry carries no net_eur_year). |
| +type poolRow struct { |
| + Entitlement string |
| + Tier string |
| + StorageBytes int64 |
| + NetEURYear float64 |
| +} |
| + |
| +// poolReading is one sweep's gauge output — what pool_meter stores. |
| +type poolReading struct { |
| + FreeCount int |
| + DemoCount int |
| + PaidCount int |
| + FreeCostDay float64 |
| + TotalCostDay float64 |
| + NetRevenueDay float64 |
| + BudgetDay float64 |
| + DemoBudgetDay float64 |
| + Pressure float64 |
| +} |
| + |
| +// computePool is the budget math (docs/free-pool.md § The budget formula), |
| +// pure for testing: |
| +// |
| +// budget_day = min( X% x total_cost_day, max(floor, Y% x net_revenue_day) ) |
| +// pressure = free_cost_day / budget_day |
| +func computePool(cfg poolConfig, rows []poolRow) poolReading { |
| + var r poolReading |
| + for _, row := range rows { |
| + awake := cfg.costs.awakeFree |
| + // "demo" is the entitlement the demo tier will use (docs/demo-spaces.md); |
| + // counting it here means the gauge is demo-ready the day that lands. |
| + if row.Entitlement == "demo" { |
| + awake = cfg.costs.awakeDemo |
| + } |
| + cost := awake*(cfg.costs.slotDay+cfg.costs.reqDay) + |
| + float64(row.StorageBytes)/1e9*cfg.costs.storageGBDay |
| + r.TotalCostDay += cost |
| + switch row.Entitlement { |
| + case entPaid: |
| + r.PaidCount++ |
| + r.NetRevenueDay += row.NetEURYear / 365 |
| + case "demo": |
| + r.DemoCount++ |
| + r.FreeCostDay += cost |
| + default: |
| + r.FreeCount++ |
| + r.FreeCostDay += cost |
| + } |
| + } |
| + r.TotalCostDay += cfg.costs.fixedDay |
| + invariantCap := float64(cfg.maxShare) / 100 * r.TotalCostDay |
| + revenuePool := float64(cfg.revenueShare) / 100 * r.NetRevenueDay |
| + r.BudgetDay = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool)) |
| + r.DemoBudgetDay = float64(cfg.demoShare) / 100 * r.BudgetDay |
| + if r.BudgetDay > 0 { |
| + r.Pressure = r.FreeCostDay / r.BudgetDay |
| + } |
| + return r |
| +} |
| + |
| +// poolState names a pressure for humans. The thresholds are the pre-agreed |
| +// policy from docs/free-pool.md; in this slice they label the gauge and |
| +// nothing else — no threshold actuates anything yet. |
| +func poolState(pressure float64) string { |
| + switch { |
| + case pressure < 0.8: |
| + return "green" |
| + case pressure < 1.0: |
| + return "amber" |
| + case pressure < 1.5: |
| + return "red" |
| + default: |
| + return "deep_red" |
| + } |
| +} |
| + |
| +// netForTier resolves a paid space's net revenue from the price book's |
| +// additive net_eur_year field (0 when billing is off or the entry has none). |
| +func (s *cloudServer) netForTier(tier string) float64 { |
| + if s.billing == nil { |
| + return 0 |
| + } |
| + if t, ok := s.billing.tier(tier); ok { |
| + return t.NetEURYear |
| + } |
| + return 0 |
| +} |
| + |
| +// poolSweep computes and records one gauge reading from the ledger. Called at |
| +// the end of each meter sweep (the storage figures it reads were just |
| +// refreshed); reads only the ledger — never controld, never an instance. |
| +func (s *cloudServer) poolSweep(now time.Time) { |
| + rows, err := s.db.poolRows() |
| + if err != nil { |
| + log.Printf("cloud: pool: ledger query: %v", err) |
| + return |
| + } |
| + for i := range rows { |
| + if rows[i].Entitlement == entPaid { |
| + rows[i].NetEURYear = s.netForTier(rows[i].Tier) |
| + } |
| + } |
| + r := computePool(s.pool, rows) |
| + if r.BudgetDay <= 0 { |
| + log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless") |
| + } |
| + if err := s.db.insertPoolMeter(now.Unix(), r); err != nil { |
| + log.Printf("cloud: pool: record: %v", err) |
| + return |
| + } |
| + log.Printf("cloud: pool %s — free spend ~€%.3f/day of €%.3f/day budget (pressure %.2f; %d free, %d demo, %d paid; est.)", |
| + poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount) |
| +} |
| + |
| +// poolHandler: GET /api/pool (admin token) — the latest gauge reading, for the |
| +// operator and, later, the status page. Everything in it is an estimate from |
| +// the declared cost model, and the payload says so. |
| +func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.pool.enabled() { |
| + writeJSON(w, http.StatusOK, map[string]any{"enabled": false}) |
| + return |
| + } |
| + ts, reading, ok, err := s.db.latestPoolMeter() |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "pool_failed", "ledger error") |
| + return |
| + } |
| + if !ok { |
| + writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "measured": false}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "enabled": true, |
| + "measured": true, |
| + "estimate": true, // derived from the declared cost model, not metered |
| + "ts": ts, |
| + "state": poolState(reading.Pressure), |
| + "pressure": reading.Pressure, |
| + "free_count": reading.FreeCount, |
| + "demo_count": reading.DemoCount, |
| + "paid_count": reading.PaidCount, |
| + "free_cost_day": reading.FreeCostDay, |
| + "total_cost_day": reading.TotalCostDay, |
| + "net_revenue_day": reading.NetRevenueDay, |
| + "budget_day": reading.BudgetDay, |
| + "demo_budget_day": reading.DemoBudgetDay, |
| + "config": map[string]any{ |
| + "max_share": s.pool.maxShare, |
| + "revenue_share": s.pool.revenueShare, |
| + "base_eur_day": s.pool.baseEURDay, |
| + "demo_share": s.pool.demoShare, |
| + }, |
| + }) |
| +} |
| + |
| +// envFloat mirrors envInt for fractional knobs. |
| +func envFloat(key string, def float64) float64 { |
| + if v := os.Getenv(key); v != "" { |
| + if n, err := strconv.ParseFloat(v, 64); err == nil { |
| + return n |
| + } |
| + } |
| + return def |
| +} |
diff --git a/pool_test.go b/pool_test.go| new file mode 100644 |
| index 0000000..e7154f0 |
| --- /dev/null |
| +++ b/pool_test.go |
| @@ -0,0 +1,258 @@ |
| +package main |
| + |
| +// The free-pool gauge (pool.go): pure budget math, the cost-model config |
| +// parser, the sweep's ledger round-trip, and the admin endpoint. No test here |
| +// exercises admission control — slice 1 is a gauge and these tests pin that |
| +// (nothing refuses, nothing actuates). |
| + |
| +import ( |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func near(t *testing.T, name string, got, want float64) { |
| + t.Helper() |
| + if math.Abs(got-want) > 1e-9 { |
| + t.Fatalf("%s = %v, want %v", name, got, want) |
| + } |
| +} |
| + |
| +// Easy-arithmetic cost model for the math tests. |
| +func testPoolCfg() poolConfig { |
| + return poolConfig{ |
| + maxShare: 25, |
| + revenueShare: 30, |
| + baseEURDay: 0.20, |
| + demoShare: 20, |
| + costs: poolCosts{ |
| + fixedDay: 1.0, |
| + slotDay: 0.03, |
| + reqDay: 0.01, |
| + storageGBDay: 0.001, |
| + awakeFree: 0.5, |
| + awakeDemo: 1.0, |
| + }, |
| + } |
| +} |
| + |
| +func TestParsePoolCostsDefaults(t *testing.T) { |
| + c, err := parsePoolCosts("") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c != defaultPoolCosts() { |
| + t.Fatalf("empty config should yield defaults, got %+v", c) |
| + } |
| +} |
| + |
| +func TestParsePoolCostsOverrides(t *testing.T) { |
| + c, err := parsePoolCosts("fixed_day=2.5, awake_demo=0.6") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + near(t, "fixedDay", c.fixedDay, 2.5) |
| + near(t, "awakeDemo", c.awakeDemo, 0.6) |
| + // Untouched keys keep their defaults. |
| + near(t, "slotDay", c.slotDay, defaultPoolCosts().slotDay) |
| +} |
| + |
| +func TestParsePoolCostsRejectsMistakes(t *testing.T) { |
| + for _, bad := range []string{"fixed=1.0", "fixed_day=abc", "slot_day=-1", "fixed_day"} { |
| + if _, err := parsePoolCosts(bad); err == nil { |
| + t.Fatalf("parsePoolCosts(%q) should error", bad) |
| + } |
| + } |
| +} |
| + |
| +// Zero revenue: the operator floor carries the pool, clamped by the X ceiling. |
| +func TestComputePoolZeroRevenue(t *testing.T) { |
| + cfg := testPoolCfg() |
| + rows := []poolRow{ |
| + {Entitlement: "free", StorageBytes: 1e9}, |
| + {Entitlement: "free", StorageBytes: 1e9}, |
| + } |
| + r := computePool(cfg, rows) |
| + // Each free space: 0.5*(0.03+0.01) + 1GB*0.001 = 0.021. |
| + near(t, "FreeCostDay", r.FreeCostDay, 0.042) |
| + near(t, "TotalCostDay", r.TotalCostDay, 1.042) |
| + // budget = min(25% x 1.042, max(0.20, 0)) = 0.20 — the floor wins at zero revenue. |
| + near(t, "BudgetDay", r.BudgetDay, 0.20) |
| + near(t, "Pressure", r.Pressure, 0.042/0.20) |
| + near(t, "DemoBudgetDay", r.DemoBudgetDay, 0.04) |
| + if r.FreeCount != 2 || r.PaidCount != 0 || r.DemoCount != 0 { |
| + t.Fatalf("counts = %d/%d/%d, want 2 free / 0 demo / 0 paid", r.FreeCount, r.DemoCount, r.PaidCount) |
| + } |
| +} |
| + |
| +// With revenue, the Y-term takes over — and the X ceiling clamps it. |
| +func TestComputePoolRevenueClampedByCeiling(t *testing.T) { |
| + cfg := testPoolCfg() |
| + rows := []poolRow{ |
| + {Entitlement: "free", StorageBytes: 1e9}, |
| + {Entitlement: "free", StorageBytes: 1e9}, |
| + {Entitlement: entPaid, Tier: "business", NetEURYear: 3650}, // €10/day net |
| + } |
| + r := computePool(cfg, rows) |
| + near(t, "NetRevenueDay", r.NetRevenueDay, 10) |
| + // Paid space still costs (0.5*0.04 = 0.02): total = 0.042+0.02+1.0 = 1.062. |
| + near(t, "TotalCostDay", r.TotalCostDay, 1.062) |
| + // revenue pool = 3.0 but the invariant cap = 25% x 1.062 = 0.2655 clamps. |
| + near(t, "BudgetDay", r.BudgetDay, 0.2655) |
| + if r.PaidCount != 1 { |
| + t.Fatalf("PaidCount = %d, want 1", r.PaidCount) |
| + } |
| +} |
| + |
| +// Demos cost at the demo awake fraction and count into free spend. |
| +func TestComputePoolDemo(t *testing.T) { |
| + cfg := testPoolCfg() |
| + r := computePool(cfg, []poolRow{{Entitlement: "demo"}}) |
| + // 1.0*(0.03+0.01) = 0.04, no storage. |
| + near(t, "FreeCostDay", r.FreeCostDay, 0.04) |
| + if r.DemoCount != 1 || r.FreeCount != 0 { |
| + t.Fatalf("counts = %d free / %d demo, want 0/1", r.FreeCount, r.DemoCount) |
| + } |
| +} |
| + |
| +func TestPoolStateThresholds(t *testing.T) { |
| + for _, tc := range []struct { |
| + pressure float64 |
| + want string |
| + }{{0, "green"}, {0.79, "green"}, {0.8, "amber"}, {0.99, "amber"}, {1.0, "red"}, {1.49, "red"}, {1.5, "deep_red"}} { |
| + if got := poolState(tc.pressure); got != tc.want { |
| + t.Fatalf("poolState(%v) = %q, want %q", tc.pressure, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// The sweep reads the ledger, resolves net revenue from the price book, and |
| +// records a reading that round-trips through pool_meter. |
| +func TestPoolSweepRecordsReading(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + free := insertSpaceRow(t, db, "free.example.com", 1000) |
| + paid := insertSpaceRow(t, db, "paid.example.com", 1000) |
| + if _, err := db.sql.Exec(`UPDATE spaces SET storage_bytes = 2000000000 WHERE id = ?`, free); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.sql.Exec(`UPDATE spaces SET entitlement = 'paid', tier = 'friends' WHERE id = ?`, paid); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := &cloudServer{ |
| + db: db, |
| + pool: testPoolCfg(), |
| + billing: &stripeBilling{tiers: []billingTier{ |
| + {Key: "friends", Name: "Friends", Label: "€40/year", Price: "price_f", NetEURYear: 36.5}, |
| + }}, |
| + } |
| + s.poolSweep(time.Unix(2_000_000_000, 0)) |
| + |
| + ts, r, ok, err := db.latestPoolMeter() |
| + if err != nil || !ok { |
| + t.Fatalf("latestPoolMeter: ok=%v err=%v", ok, err) |
| + } |
| + if ts != 2_000_000_000 { |
| + t.Fatalf("ts = %d", ts) |
| + } |
| + if r.FreeCount != 1 || r.PaidCount != 1 || r.DemoCount != 0 { |
| + t.Fatalf("counts = %d/%d/%d", r.FreeCount, r.DemoCount, r.PaidCount) |
| + } |
| + near(t, "NetRevenueDay", r.NetRevenueDay, 36.5/365) // 0.1 — from the price book |
| + // free: 0.5*0.04 + 2GB*0.001 = 0.022; paid adds 0.02; fixed 1.0. |
| + near(t, "FreeCostDay", r.FreeCostDay, 0.022) |
| + near(t, "TotalCostDay", r.TotalCostDay, 1.042) |
| + if r.Pressure <= 0 { |
| + t.Fatalf("pressure = %v, want > 0", r.Pressure) |
| + } |
| +} |
| + |
| +// The gauge rides the meter sweep when enabled — and stays silent when off. |
| +func TestMeterOnceRunsPoolSweep(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + insertSpaceRow(t, db, "a.example.com", 1000) |
| + sock := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "members": 1, "setup_complete": true, "has_content": true, "storage_bytes": 1000, |
| + }) |
| + }) |
| + s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", pool: testPoolCfg()} |
| + s.meterOnce() |
| + if _, _, ok, _ := db.latestPoolMeter(); !ok { |
| + t.Fatal("meterOnce with the pool enabled should record a gauge reading") |
| + } |
| + |
| + // Pool off (the shipped default): same sweep, no reading. |
| + db2 := openTestCloudDB(t) |
| + insertSpaceRow(t, db2, "b.example.com", 1000) |
| + s2 := &cloudServer{db: db2, control: controlClient(sock), controlToken: "t"} |
| + s2.meterOnce() |
| + if _, _, ok, _ := db2.latestPoolMeter(); ok { |
| + t.Fatal("meterOnce with the pool off must not record a reading") |
| + } |
| +} |
| + |
| +func poolGET(t *testing.T, url, token string) (*http.Response, map[string]any) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", url+"/api/pool", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if token != "" { |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var body map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&body) |
| + return resp, body |
| +} |
| + |
| +func TestPoolHandler(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + s := &cloudServer{db: db, adminHash: hashToken("admin-secret"), pool: testPoolCfg()} |
| + srv := httptest.NewServer(s.handler()) |
| + defer srv.Close() |
| + |
| + // Admin-gated like the token surface. |
| + if resp, _ := poolGET(t, srv.URL, ""); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no token: HTTP %d, want 401", resp.StatusCode) |
| + } |
| + if resp, _ := poolGET(t, srv.URL, "wrong"); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("bad token: HTTP %d, want 401", resp.StatusCode) |
| + } |
| + |
| + // Enabled but never swept. |
| + resp, body := poolGET(t, srv.URL, "admin-secret") |
| + if resp.StatusCode != http.StatusOK || body["enabled"] != true || body["measured"] != false { |
| + t.Fatalf("pre-sweep: HTTP %d body %v", resp.StatusCode, body) |
| + } |
| + |
| + // After a sweep: the reading, labelled an estimate. |
| + insertSpaceRow(t, db, "c.example.com", 1000) |
| + s.poolSweep(time.Unix(2_000_000_000, 0)) |
| + resp, body = poolGET(t, srv.URL, "admin-secret") |
| + if resp.StatusCode != http.StatusOK || body["measured"] != true || body["estimate"] != true { |
| + t.Fatalf("post-sweep: HTTP %d body %v", resp.StatusCode, body) |
| + } |
| + if body["state"] != "green" { |
| + t.Fatalf("state = %v, want green (one tiny free space)", body["state"]) |
| + } |
| + if _, ok := body["pressure"].(float64); !ok { |
| + t.Fatalf("pressure missing: %v", body) |
| + } |
| + |
| + // Pool off: the endpoint says so instead of 404ing (status pages consume this). |
| + sOff := &cloudServer{db: db, adminHash: hashToken("admin-secret")} |
| + srvOff := httptest.NewServer(sOff.handler()) |
| + defer srvOff.Close() |
| + resp, body = poolGET(t, srvOff.URL, "admin-secret") |
| + if resp.StatusCode != http.StatusOK || body["enabled"] != false { |
| + t.Fatalf("pool off: HTTP %d body %v", resp.StatusCode, body) |
| + } |
| +} |