server: free-pool admission control — free creates pause at red pressure, fail-open, dry-run gated (#881)
Slice 2 of docs/free-pool.md: at pressure >= 1 on the latest gauge reading, new FREE creates answer 503 free_pool_full with the honest member-facing message (home's existing 503 passthrough relays it verbatim). The gate fails open on any uncertainty (pool off, no reading, ledger error), demos are exempt, paid creates are never gated, existing spaces are never touched. LCHAT_POOL_DRY_RUN=1 logs every would-refuse without refusing — the reaper's gauge -> dry-run -> arm rollout. Budget/pressure now derive from revenue smoothed over the trailing ~90d of pool_meter history (rows keep the instantaneous figure), closing the deviation noted on #871.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+214
−13
cloud.go+17 −2cloud_db.go+19 −0docs/cloud-provisioner.md+14 −3pool.go+66 −8pool_test.go+98 −0
diff --git a/cloud.go b/cloud.go| index f147f4e..3b154f9 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -197,6 +197,15 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| + // The free pool's admission gate (pool.go): new free creates pause while |
| + // the last gauge reading shows red pressure. Fails open on any |
| + // uncertainty; paid creates are never gated (they fund the pool), and |
| + // existing spaces are never touched. |
| + if !s.poolAdmitFree() { |
| + writeControlErr(w, http.StatusServiceUnavailable, "free_pool_full", freePoolFullMsg) |
| + return |
| + } |
| + |
| // Reserve the id (fails closed if over quota). The row is pending (NULL host) |
| // until the instance is up; on any failure below we delete it so it doesn't |
| // hold a quota slot. |
| @@ -475,6 +484,7 @@ func runCloud(args []string) { |
| 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") |
| + poolDryRun := fs.Bool("pool-dry-run", os.Getenv("LCHAT_POOL_DRY_RUN") != "", "compute and log the pool's admission decisions without refusing anything") |
| _ = fs.Parse(args) |
| if *baseDomain == "" || !validHost("x."+*baseDomain) { |
| @@ -539,6 +549,7 @@ func runCloud(args []string) { |
| revenueShare: *poolRevenueShare, |
| baseEURDay: *poolBaseEURDay, |
| demoShare: *poolDemoShare, |
| + dryRun: *poolDryRun, |
| costs: poolCosts, |
| }, |
| } |
| @@ -563,8 +574,12 @@ func runCloud(args []string) { |
| log.Printf("cloud: demo spaces on (TTL %s, max %d live, discarded on expiry)", s.demoTTL, s.demoMaxLive) |
| } |
| 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) |
| + mode := "admission armed" |
| + if s.pool.dryRun { |
| + mode = "dry-run, gates nothing" |
| + } |
| + log.Printf("cloud: free pool on (%s — ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)", |
| + mode, s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare) |
| if billing != nil { |
| for _, t := range billing.tiers { |
| if t.NetEURYear <= 0 { |
diff --git a/cloud_db.go b/cloud_db.go| index 7eadfc7..8c52704 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -808,6 +808,25 @@ func (db *cloudDB) insertPoolMeter(ts int64, r poolReading) error { |
| return err |
| } |
| +// poolRevenueHistory reads trailing sweeps' instantaneous revenue readings — |
| +// the smoothing window's raw material (pool.go smoothRevenue). |
| +func (db *cloudDB) poolRevenueHistory(since int64) ([]float64, error) { |
| + rows, err := db.sql.Query(`SELECT net_revenue_day FROM pool_meter WHERE ts >= ? ORDER BY ts`, since) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []float64 |
| + for rows.Next() { |
| + var v float64 |
| + if err := rows.Scan(&v); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, v) |
| + } |
| + return out, rows.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( |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 8b5c6a6..7e01afd 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -469,9 +469,20 @@ 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. |
| +not metered. |
| + |
| +**Slice 2 — admission control.** At `pressure ≥ 1` on the latest reading, |
| +new FREE creates answer `503 free_pool_full` with the honest member-facing |
| +message (home's 503 passthrough relays it verbatim). The gate **fails |
| +open** on any uncertainty — pool off, no reading yet, ledger error — and |
| +never touches anything else: demos are exempt (their carve-out + TTL bound |
| +them), paid creates are never gated (they fund the pool), existing spaces |
| +are never touched, and deleting your own space still works at red. |
| +`LCHAT_POOL_DRY_RUN=1` computes and logs every would-refuse without |
| +refusing — arm it the reaper's way: gauge first, dry-run second, live |
| +third. Budget + pressure now use revenue **smoothed over the trailing ~90 |
| +days** of stored readings (one big churn shrinks the pool gradually); each |
| +`pool_meter` row still records the instantaneous figure. |
| ## Billing (Stripe) — tiers, quota exemption, no metering |
diff --git a/pool.go b/pool.go| index 68175b0..3fc9b3f 100644 |
| --- a/pool.go |
| +++ b/pool.go |
| @@ -89,7 +89,8 @@ 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 |
| + demoShare int // D: % of the pool reserved for demos |
| + dryRun bool // slice-2 rollout gate: compute + log, refuse nothing |
| costs poolCosts |
| } |
| @@ -148,16 +149,37 @@ func computePool(cfg poolConfig, rows []poolRow) poolReading { |
| } |
| } |
| 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 |
| - } |
| + r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(cfg, r.TotalCostDay, r.FreeCostDay, r.NetRevenueDay) |
| return r |
| } |
| +// poolBudget derives the budget and pressure from a cost picture and a |
| +// revenue figure — split out so the sweep can re-derive with SMOOTHED revenue |
| +// while computePool stays pure over instantaneous inputs. |
| +func poolBudget(cfg poolConfig, totalCostDay, freeCostDay, revenueDay float64) (budget, demoBudget, pressure float64) { |
| + invariantCap := float64(cfg.maxShare) / 100 * totalCostDay |
| + revenuePool := float64(cfg.revenueShare) / 100 * revenueDay |
| + budget = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool)) |
| + demoBudget = float64(cfg.demoShare) / 100 * budget |
| + if budget > 0 { |
| + pressure = freeCostDay / budget |
| + } |
| + return budget, demoBudget, pressure |
| +} |
| + |
| +// smoothRevenue averages the current instantaneous revenue with the trailing |
| +// sweeps' stored readings (~90 days at the 6h cadence), so one big customer |
| +// churning shrinks the budget gradually — and a shrink only ever gates |
| +// admissions, never existing spaces (docs/free-pool.md § the budget formula). |
| +func smoothRevenue(current float64, history []float64) float64 { |
| + sum, n := current, 1 |
| + for _, v := range history { |
| + sum += v |
| + n++ |
| + } |
| + return sum / float64(n) |
| +} |
| + |
| // 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. |
| @@ -201,6 +223,14 @@ func (s *cloudServer) poolSweep(now time.Time) { |
| } |
| } |
| r := computePool(s.pool, rows) |
| + // Budget + pressure use SMOOTHED revenue (the trailing ~90d of stored |
| + // readings); the row keeps the instantaneous net_revenue_day so history |
| + // stays honest raw material for future smoothing. |
| + if hist, err := s.db.poolRevenueHistory(now.Add(-90 * 24 * time.Hour).Unix()); err == nil { |
| + r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(s.pool, r.TotalCostDay, r.FreeCostDay, smoothRevenue(r.NetRevenueDay, hist)) |
| + } else { |
| + log.Printf("cloud: pool: revenue history: %v (using instantaneous)", err) |
| + } |
| if r.BudgetDay <= 0 { |
| log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless") |
| } |
| @@ -212,6 +242,33 @@ func (s *cloudServer) poolSweep(now time.Time) { |
| poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount) |
| } |
| +// freePoolFullMsg is the honest register for a paused free create — relayed |
| +// verbatim by home (its 503 passthrough) and shown by clients. |
| +const freePoolFullMsg = "The free pool is full right now. Paying supports free spaces for those who can't — as more people upgrade, the pool grows. Check back soon, or upgrade to create a space now." |
| + |
| +// poolAdmitFree answers whether a new FREE space may be created right now — |
| +// the pool's only actuator (docs/free-pool.md § the control loop). Fails OPEN |
| +// on any uncertainty: pool off, dry-run, no reading yet, or a read error |
| +// never blocks a create — refusing must be a deliberate, measured state. |
| +// Demos are exempt (their carve-out + TTL bound them; docs/demo-spaces.md |
| +// wants the try-it path alive even at red); paid creates are never gated |
| +// (they fund the pool); existing spaces are never touched, ever. |
| +func (s *cloudServer) poolAdmitFree() bool { |
| + if !s.pool.enabled() { |
| + return true |
| + } |
| + _, r, ok, err := s.db.latestPoolMeter() |
| + if err != nil || !ok || r.Pressure < 1 { |
| + return true |
| + } |
| + if s.pool.dryRun { |
| + log.Printf("cloud: pool: WOULD refuse a free create (pressure %.2f) — dry-run", r.Pressure) |
| + return true |
| + } |
| + log.Printf("cloud: pool: refusing free create (pressure %.2f)", r.Pressure) |
| + return false |
| +} |
| + |
| // 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. |
| @@ -249,6 +306,7 @@ func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) { |
| "revenue_share": s.pool.revenueShare, |
| "base_eur_day": s.pool.baseEURDay, |
| "demo_share": s.pool.demoShare, |
| + "dry_run": s.pool.dryRun, |
| }, |
| }) |
| } |
diff --git a/pool_test.go b/pool_test.go| index e7154f0..9a75358 100644 |
| --- a/pool_test.go |
| +++ b/pool_test.go |
| @@ -256,3 +256,101 @@ func TestPoolHandler(t *testing.T) { |
| t.Fatalf("pool off: HTTP %d body %v", resp.StatusCode, body) |
| } |
| } |
| + |
| +// ---- slice 2: admission control --------------------------------------------- |
| + |
| +// seedPressure plants a gauge reading with a chosen pressure. |
| +func seedPressure(t *testing.T, db *cloudDB, pressure float64) { |
| + t.Helper() |
| + if err := db.insertPoolMeter(1_999_000_000, poolReading{BudgetDay: 1, FreeCostDay: pressure, Pressure: pressure}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestSmoothRevenue(t *testing.T) { |
| + near(t, "no history", smoothRevenue(10, nil), 10) |
| + near(t, "averaged", smoothRevenue(0, []float64{10, 10, 10}), 7.5) |
| +} |
| + |
| +func TestPoolAdmissionRefusesAtRed(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.pool = testPoolCfg() |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + token := mintCaller(t, srv, "home") |
| + seedPressure(t, s.db, 1.3) |
| + |
| + resp := createKind(t, srv, token, "alice", "") |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("free create at red: HTTP %d, want 503", resp.StatusCode) |
| + } |
| + body := decodeBody(t, resp) |
| + if body["error"] != "free_pool_full" { |
| + t.Fatalf("error = %v", body["error"]) |
| + } |
| + if msg, _ := body["message"].(string); msg != freePoolFullMsg { |
| + t.Fatalf("message = %q", msg) |
| + } |
| + // Demos stay available at red (the carve-out keeps the try-it path alive). |
| + if resp := createKind(t, srv, token, "alice", "demo"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo at red: HTTP %d, want 201", resp.StatusCode) |
| + } |
| + // The refusal held no quota slot: at green the same requester creates fine. |
| + seedPressure(t, s.db, 0.2) |
| + if resp := createKind(t, srv, token, "alice", ""); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("free create at green: HTTP %d, want 201", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestPoolAdmissionFailsOpen(t *testing.T) { |
| + // Dry-run: red pressure refuses nothing. |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.pool = testPoolCfg() |
| + s.pool.dryRun = true |
| + token := mintCaller(t, srv, "home") |
| + seedPressure(t, s.db, 2.0) |
| + if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("dry-run at red: HTTP %d, want 201", resp.StatusCode) |
| + } |
| + |
| + // No reading yet: never refuse. |
| + srv2, s2 := newTestCloud(t, okControl(t)) |
| + s2.pool = testPoolCfg() |
| + token2 := mintCaller(t, srv2, "home") |
| + if resp := createKind(t, srv2, token2, "carol", ""); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("no reading: HTTP %d, want 201", resp.StatusCode) |
| + } |
| + |
| + // Pool off: a stale red reading in the table gates nothing. |
| + srv3, s3 := newTestCloud(t, okControl(t)) |
| + token3 := mintCaller(t, srv3, "home") |
| + seedPressure(t, s3.db, 5.0) |
| + if resp := createKind(t, srv3, token3, "dave", ""); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("pool off: HTTP %d, want 201", resp.StatusCode) |
| + } |
| +} |
| + |
| +// The sweep's budget uses smoothed revenue: with fat historical readings and |
| +// zero current revenue, the recorded budget sits between floor and history. |
| +func TestPoolSweepSmoothsRevenue(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + insertSpaceRow(t, db, "a.example.com", 1000) |
| + // Three past sweeps at €10/day revenue each. |
| + for i, ts := range []int64{1_990_000_000, 1_990_100_000, 1_990_200_000} { |
| + _ = i |
| + if err := db.insertPoolMeter(ts, poolReading{NetRevenueDay: 10}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + s := &cloudServer{db: db, pool: testPoolCfg()} |
| + s.poolSweep(time.Unix(1_990_300_000, 0)) |
| + _, r, ok, err := db.latestPoolMeter() |
| + if err != nil || !ok { |
| + t.Fatalf("latestPoolMeter: ok=%v err=%v", ok, err) |
| + } |
| + // Instantaneous revenue is 0 (no paid rows) and is what the row stores... |
| + near(t, "NetRevenueDay", r.NetRevenueDay, 0) |
| + // ...but the budget was derived from smoothed (0+10+10+10)/4 = 7.5€/day: |
| + // revenue pool = 30% x 7.5 = 2.25, clamped by the X ceiling (25% of total). |
| + wantBudget := math.Min(0.25*r.TotalCostDay, 2.25) |
| + near(t, "BudgetDay", r.BudgetDay, wantBudget) |
| +} |