eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The free pool, slice 1 (docs/free-pool.md): a GAUGE, no behavior change.
4// Every meter sweep estimates what the free tier costs per day (from a tiny
5// config-declared cost model), derives the pool's daily budget from the
6// percent-of-total-cost ceiling + net revenue + an operator floor, and records
7// one pressure reading. Nothing here gates a create or touches an instance —
8// admission control and demo coupling are later slices, taken only once this
9// gauge has been watched for a while (the reaper's dry-run-then-arm pattern).
10//
11// The readings are ESTIMATES by construction: per-instance cost is derived
12// from declared constants (awake fractions, slot/storage rates), not metered.
13// That's deliberate — real cost attribution is heavy machinery for cents —
14// but every surface that shows a reading must say so.
15
16import (
17 "fmt"
18 "log"
19 "math"
20 "net/http"
21 "os"
22 "strconv"
23 "strings"
24 "time"
25)
26
27// poolCosts is the declared cost model (LCHAT_POOL_COSTS): euro rates and the
28// assumed awake fractions. Defaults are the 2026-07 snapshot from
29// docs/free-pool.md; formulas are the contract, these numbers drift and get
30// re-measured at tier-economics re-derivations.
31type poolCosts struct {
32 fixedDay float64 // whole cloud plane per day (box + router/home/push/cloud)
33 slotDay float64 // one materialized instance slot per day
34 reqDay float64 // S3 request tail of one always-awake instance per day
35 storageGBDay float64 // one stored GB per day (replica x retention)
36 awakeFree float64 // assumed awake fraction of a free/paid space
37 awakeDemo float64 // assumed awake fraction of a demo (short-lived, poked)
38}
39
40func defaultPoolCosts() poolCosts {
41 return poolCosts{
42 fixedDay: 1.17,
43 slotDay: 0.027,
44 reqDay: 0.002,
45 storageGBDay: 0.0014,
46 awakeFree: 0.05,
47 awakeDemo: 0.30,
48 }
49}
50
51// parsePoolCosts reads LCHAT_POOL_COSTS ("fixed_day=1.17,slot_day=0.027,…").
52// Absent keys keep their defaults; an unknown key or bad value is a deployment
53// mistake and errors rather than silently mismeasuring.
54func parsePoolCosts(v string) (poolCosts, error) {
55 c := defaultPoolCosts()
56 if strings.TrimSpace(v) == "" {
57 return c, nil
58 }
59 fields := map[string]*float64{
60 "fixed_day": &c.fixedDay,
61 "slot_day": &c.slotDay,
62 "req_day": &c.reqDay,
63 "storage_gb_day": &c.storageGBDay,
64 "awake_free": &c.awakeFree,
65 "awake_demo": &c.awakeDemo,
66 }
67 for _, pair := range strings.Split(v, ",") {
68 k, val, ok := strings.Cut(strings.TrimSpace(pair), "=")
69 if !ok {
70 return c, fmt.Errorf("bad entry %q (want key=value)", pair)
71 }
72 dst, known := fields[strings.TrimSpace(k)]
73 if !known {
74 return c, fmt.Errorf("unknown cost %q", k)
75 }
76 n, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
77 if err != nil || n < 0 {
78 return c, fmt.Errorf("cost %q: bad value %q", k, val)
79 }
80 *dst = n
81 }
82 return c, nil
83}
84
85// poolConfig is the budget's shape: X caps the subsidy as a share of total
86// cost, Y indexes it to revenue, the floor keeps a pool alive at zero revenue,
87// and D reserves a slice for demos (unused until the demo tier exists).
88type poolConfig struct {
89 maxShare int // X: free-spend ceiling as % of total cost; 0 = pool off
90 revenueShare int // Y: % of net revenue that funds the pool
91 baseEURDay float64
92 demoShare int // D: % of the pool reserved for demos
93 dryRun bool // slice-2 rollout gate: compute + log, refuse nothing
94 costs poolCosts
95}
96
97func (c poolConfig) enabled() bool { return c.maxShare > 0 }
98
99// poolRow is one ledger space as the budget math sees it. NetEURYear is
100// resolved from the price book by the caller (0 for free/demo rows and for
101// tiers whose price-book entry carries no net_eur_year); Band picks which of
102// the tier's honor prices the subscription actually pays.
103type poolRow struct {
104 Entitlement string
105 Tier string
106 Band string
107 Seats int // billed head count on per-seat tiers; 0 on flat ones
108 StorageBytes int64
109 NetEURYear float64
110}
111
112// poolReading is one sweep's gauge output — what pool_meter stores.
113type poolReading struct {
114 FreeCount int
115 DemoCount int
116 PaidCount int
117 FreeCostDay float64
118 TotalCostDay float64
119 NetRevenueDay float64
120 BudgetDay float64
121 DemoBudgetDay float64
122 Pressure float64
123}
124
125// computePool is the budget math (docs/free-pool.md § The budget formula),
126// pure for testing:
127//
128// budget_day = min( X% x total_cost_day, max(floor, Y% x net_revenue_day) )
129// pressure = free_cost_day / budget_day
130func computePool(cfg poolConfig, rows []poolRow) poolReading {
131 var r poolReading
132 for _, row := range rows {
133 awake := cfg.costs.awakeFree
134 // "demo" is the entitlement the demo tier will use (docs/demo-spaces.md);
135 // counting it here means the gauge is demo-ready the day that lands.
136 if row.Entitlement == "demo" {
137 awake = cfg.costs.awakeDemo
138 }
139 cost := awake*(cfg.costs.slotDay+cfg.costs.reqDay) +
140 float64(row.StorageBytes)/1e9*cfg.costs.storageGBDay
141 r.TotalCostDay += cost
142 switch row.Entitlement {
143 case entPaid:
144 r.PaidCount++
145 r.NetRevenueDay += row.NetEURYear / 365
146 case "demo":
147 r.DemoCount++
148 r.FreeCostDay += cost
149 default:
150 r.FreeCount++
151 r.FreeCostDay += cost
152 }
153 }
154 r.TotalCostDay += cfg.costs.fixedDay
155 r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(cfg, r.TotalCostDay, r.FreeCostDay, r.NetRevenueDay)
156 return r
157}
158
159// poolBudget derives the budget and pressure from a cost picture and a
160// revenue figure — split out so the sweep can re-derive with SMOOTHED revenue
161// while computePool stays pure over instantaneous inputs.
162func poolBudget(cfg poolConfig, totalCostDay, freeCostDay, revenueDay float64) (budget, demoBudget, pressure float64) {
163 invariantCap := float64(cfg.maxShare) / 100 * totalCostDay
164 revenuePool := float64(cfg.revenueShare) / 100 * revenueDay
165 budget = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool))
166 demoBudget = float64(cfg.demoShare) / 100 * budget
167 if budget > 0 {
168 pressure = freeCostDay / budget
169 }
170 return budget, demoBudget, pressure
171}
172
173// smoothRevenue averages the current instantaneous revenue with the trailing
174// sweeps' stored readings (~90 days at the 6h cadence), so one big customer
175// churning shrinks the budget gradually — and a shrink only ever gates
176// admissions, never existing spaces (docs/free-pool.md § the budget formula).
177func smoothRevenue(current float64, history []float64) float64 {
178 sum, n := current, 1
179 for _, v := range history {
180 sum += v
181 n++
182 }
183 return sum / float64(n)
184}
185
186// poolState names a pressure for humans. The thresholds are the pre-agreed
187// policy from docs/free-pool.md; in this slice they label the gauge and
188// nothing else — no threshold actuates anything yet.
189func poolState(pressure float64) string {
190 switch {
191 case pressure < 0.8:
192 return "green"
193 case pressure < 1.0:
194 return "amber"
195 case pressure < 1.5:
196 return "red"
197 default:
198 return "deep_red"
199 }
200}
201
202// netForTier resolves a paid space's net revenue from the price book's
203// additive net_eur_year field (0 when billing is off or the entry has none).
204// The band picks the honor price actually paid — a Reduced subscription must
205// feed the pool budget its real net, not base's (docs/pricing-bands.md).
206// On per-seat tiers net_eur_year is per seat, so the figure scales by the
207// billed head count (docs/tier-economics.md § Per-seat).
208func (s *cloudServer) netForTier(tier, band string, seats int) float64 {
209 if s.billing == nil {
210 return 0
211 }
212 t, ok := s.billing.tier(tier)
213 if !ok {
214 return 0
215 }
216 net := t.NetEURYear
217 if _, n, ok := bandPrice(t, band); ok {
218 net = n
219 }
220 // else: a recorded band the book no longer names — base's figure beats zero.
221 if t.PerSeat {
222 net *= float64(max(seats, 1))
223 }
224 return net
225}
226
227// poolSweep computes and records one gauge reading from the ledger. Called at
228// the end of each meter sweep (the storage figures it reads were just
229// refreshed); reads only the ledger — never controld, never an instance.
230func (s *cloudServer) poolSweep(now time.Time) {
231 rows, err := s.db.poolRows()
232 if err != nil {
233 log.Printf("cloud: pool: ledger query: %v", err)
234 return
235 }
236 for i := range rows {
237 if rows[i].Entitlement == entPaid {
238 rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band, rows[i].Seats)
239 }
240 }
241 r := computePool(s.pool, rows)
242 // Budget + pressure use SMOOTHED revenue (the trailing ~90d of stored
243 // readings); the row keeps the instantaneous net_revenue_day so history
244 // stays honest raw material for future smoothing.
245 if hist, err := s.db.poolRevenueHistory(now.Add(-90 * 24 * time.Hour).Unix()); err == nil {
246 r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(s.pool, r.TotalCostDay, r.FreeCostDay, smoothRevenue(r.NetRevenueDay, hist))
247 } else {
248 log.Printf("cloud: pool: revenue history: %v (using instantaneous)", err)
249 }
250 if r.BudgetDay <= 0 {
251 log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless")
252 }
253 if err := s.db.insertPoolMeter(now.Unix(), r); err != nil {
254 log.Printf("cloud: pool: record: %v", err)
255 return
256 }
257 log.Printf("cloud: pool %s — free spend ~€%.3f/day of €%.3f/day budget (pressure %.2f; %d free, %d demo, %d paid; est.)",
258 poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount)
259}
260
261// freePoolFullMsg is the honest register for a paused free create — relayed
262// verbatim by home (its 503 passthrough) and shown by clients.
263const 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."
264
265// poolAdmitFree answers whether a new FREE space may be created right now —
266// the pool's only actuator (docs/free-pool.md § the control loop). Fails OPEN
267// on any uncertainty: pool off, dry-run, no reading yet, or a read error
268// never blocks a create — refusing must be a deliberate, measured state.
269// Demos are exempt (their carve-out + TTL bound them; docs/demo-spaces.md
270// wants the try-it path alive even at red); paid creates are never gated
271// (they fund the pool); existing spaces are never touched, ever.
272func (s *cloudServer) poolAdmitFree() bool {
273 if !s.pool.enabled() {
274 return true
275 }
276 _, r, ok, err := s.db.latestPoolMeter()
277 if err != nil || !ok || r.Pressure < 1 {
278 return true
279 }
280 if s.pool.dryRun {
281 log.Printf("cloud: pool: WOULD refuse a free create (pressure %.2f) — dry-run", r.Pressure)
282 return true
283 }
284 log.Printf("cloud: pool: refusing free create (pressure %.2f)", r.Pressure)
285 return false
286}
287
288// poolHandler: GET /api/pool (admin token) — the latest gauge reading, for the
289// operator and, later, the status page. Everything in it is an estimate from
290// the declared cost model, and the payload says so.
291func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) {
292 if !s.pool.enabled() {
293 writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
294 return
295 }
296 ts, reading, ok, err := s.db.latestPoolMeter()
297 if err != nil {
298 writeControlErr(w, http.StatusInternalServerError, "pool_failed", "ledger error")
299 return
300 }
301 if !ok {
302 writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "measured": false})
303 return
304 }
305 writeJSON(w, http.StatusOK, map[string]any{
306 "enabled": true,
307 "measured": true,
308 "estimate": true, // derived from the declared cost model, not metered
309 "ts": ts,
310 "state": poolState(reading.Pressure),
311 "pressure": reading.Pressure,
312 "free_count": reading.FreeCount,
313 "demo_count": reading.DemoCount,
314 "paid_count": reading.PaidCount,
315 "free_cost_day": reading.FreeCostDay,
316 "total_cost_day": reading.TotalCostDay,
317 "net_revenue_day": reading.NetRevenueDay,
318 "budget_day": reading.BudgetDay,
319 "demo_budget_day": reading.DemoBudgetDay,
320 "config": map[string]any{
321 "max_share": s.pool.maxShare,
322 "revenue_share": s.pool.revenueShare,
323 "base_eur_day": s.pool.baseEURDay,
324 "demo_share": s.pool.demoShare,
325 "dry_run": s.pool.dryRun,
326 },
327 })
328}
329
330// envFloat mirrors envInt for fractional knobs.
331func envFloat(key string, def float64) float64 {
332 if v := os.Getenv(key); v != "" {
333 if n, err := strconv.ParseFloat(v, 64); err == nil {
334 return n
335 }
336 }
337 return def
338}
339