Skip to content

Commit f69aa39

Browse files
Mohamed HabibMohamed Habib
authored andcommitted
Add burst compute billing meter
1 parent cb10a6a commit f69aa39

13 files changed

Lines changed: 142 additions & 38 deletions

internal/api/capacity.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,11 @@ func (s *Server) getCapacityBillableEvents(c echo.Context) error {
249249

250250
eventType := c.QueryParam("eventType")
251251
switch eventType {
252-
case "", db.BillableEventReservedUsage, db.BillableEventOverageUsage, db.BillableEventDiskOverageUsage:
252+
case "", db.BillableEventReservedUsage, db.BillableEventOverageUsage, db.BillableEventBurstUsage, db.BillableEventDiskOverageUsage:
253253
// ok
254254
default:
255255
return c.JSON(http.StatusBadRequest, map[string]string{
256-
"error": "eventType must be one of reserved_usage, overage_usage, disk_overage_usage (or omitted for all)",
256+
"error": "eventType must be one of reserved_usage, overage_usage, burst_usage, disk_overage_usage (or omitted for all)",
257257
})
258258
}
259259

internal/billing/billable_events_sender.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (s *BillableEventsSender) shipOne(ctx context.Context, p db.PendingBillable
138138
}
139139

140140
// meterEventNameFor maps an outbox event_type → Stripe meter event_name.
141-
// All three event types route to a single meter each — overage is flat
141+
// All event types route to a single meter each — overage and burst are flat
142142
// (the per-tier memory_mb on the outbox row is preserved for analytics
143143
// but ignored when shipping; Stripe sums GB-seconds across rows).
144144
func (s *BillableEventsSender) meterEventNameFor(eventType string, memoryMB int) (string, error) {
@@ -153,6 +153,11 @@ func (s *BillableEventsSender) meterEventNameFor(eventType string, memoryMB int)
153153
return "", fmt.Errorf("overage meter not provisioned (run EnsureProducts)")
154154
}
155155
return s.stripe.OverageMeterEventName, nil
156+
case db.BillableEventBurstUsage:
157+
if s.stripe.BurstMeterEventName == "" {
158+
return "", fmt.Errorf("burst meter not provisioned (run EnsureProducts)")
159+
}
160+
return s.stripe.BurstMeterEventName, nil
156161
case db.BillableEventDiskOverageUsage:
157162
if s.stripe.DiskOverageMeterEventName == "" {
158163
return "", fmt.Errorf("disk overage meter not provisioned")

internal/billing/billable_events_sender_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import (
1010
// meterEventNameFor pins the (event_type, memory_mb) → Stripe meter
1111
// routing. Overage is flat — memory_mb is preserved on the outbox row
1212
// for analytics but ignored at ship time so a 1 GB and 64 GB sandbox
13-
// hit the same meter.
13+
// hit the same meter. Burst is also flat, but routed to its own meter.
1414

1515
func newSenderForTest() *BillableEventsSender {
1616
stripe := &StripeClient{
1717
ReservedMeterEventName: "sandbox_compute_sandbox_reserved",
1818
OverageMeterEventName: "sandbox_compute_sandbox_overage",
19+
BurstMeterEventName: "sandbox_compute_sandbox_burst",
1920
DiskOverageMeterEventName: "sandbox_compute_sandbox_disk_overage",
2021
}
2122
return &BillableEventsSender{stripe: stripe}
@@ -56,6 +57,17 @@ func TestSender_meterEventName_diskOverage(t *testing.T) {
5657
}
5758
}
5859

60+
func TestSender_meterEventName_burstFlat(t *testing.T) {
61+
s := newSenderForTest()
62+
got, err := s.meterEventNameFor(db.BillableEventBurstUsage, 0)
63+
if err != nil {
64+
t.Fatalf("err: %v", err)
65+
}
66+
if got != "sandbox_compute_sandbox_burst" {
67+
t.Errorf("got %q", got)
68+
}
69+
}
70+
5971
func TestSender_meterEventName_unknownTypeRejected(t *testing.T) {
6072
s := newSenderForTest()
6173
_, err := s.meterEventNameFor("totally_made_up", 0)
@@ -74,6 +86,7 @@ func TestSender_meterEventName_missingProvisionRejected(t *testing.T) {
7486
}{
7587
{db.BillableEventReservedUsage, "reserved meter not provisioned"},
7688
{db.BillableEventOverageUsage, "overage meter not provisioned"},
89+
{db.BillableEventBurstUsage, "burst meter not provisioned"},
7790
{db.BillableEventDiskOverageUsage, "disk overage meter not provisioned"},
7891
}
7992
for _, tc := range cases {

internal/billing/capacity_reconciler.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
// it scans for closed 15-min buckets that are at least `settle` past
1515
// their end and haven't been processed yet, runs the per-second
1616
// integration walk for each (org, bucket), and emits the resulting
17-
// reserved_usage / overage_usage / disk_overage_usage rows to the
17+
// reserved_usage / overage_usage / burst_usage / disk_overage_usage rows to the
1818
// `billable_events` outbox.
1919
//
2020
// Phase 2 runs in shadow: the rows are written but not delivered to
@@ -152,6 +152,7 @@ func (r *CapacityReconciler) processBucket(ctx context.Context, orgID uuid.UUID,
152152
// is org-level, summed across all running events at each segment.
153153
type BucketTotals struct {
154154
OverageGBSecondsByTier map[int]float64
155+
BurstGBSeconds float64
155156
DiskOverageGBSeconds float64
156157
ReservedFloorGBSeconds float64 // reservedGb × secs accumulated across segments — for shadow validation only
157158
}
@@ -176,6 +177,7 @@ type clippedEvent struct {
176177
From, To time.Time
177178
TierMB int
178179
DiskMB int
180+
Burst bool
179181
}
180182

181183
// clipEvent restricts a ScaleEvent's lifetime to the bucket window.
@@ -192,7 +194,7 @@ func clipEvent(e db.ScaleEvent, bucketStart, bucketEnd time.Time) (clippedEvent,
192194
if !to.After(from) {
193195
return clippedEvent{}, false
194196
}
195-
return clippedEvent{From: from, To: to, TierMB: e.MemoryMB, DiskMB: e.DiskMB}, true
197+
return clippedEvent{From: from, To: to, TierMB: e.MemoryMB, DiskMB: e.DiskMB, Burst: e.Burst}, true
196198
}
197199

198200
func collectBoundaries(events []clippedEvent, bucketStart, bucketEnd time.Time) []time.Time {
@@ -216,9 +218,10 @@ func collectBoundaries(events []clippedEvent, bucketStart, bucketEnd time.Time)
216218
}
217219

218220
type segment struct {
219-
From, To time.Time
220-
RunningByTier map[int]int // tier_mb → running GB at this tier
221-
DiskOverageMB int // sum of (disk_mb − 20480) over running events
221+
From, To time.Time
222+
RunningByTier map[int]int // non-burst tier_mb → running GB at this tier
223+
BurstGB int
224+
DiskOverageMB int // sum of (disk_mb − 20480) over running events
222225
}
223226

224227
func walkSegments(boundaries []time.Time, events []clippedEvent) []segment {
@@ -229,20 +232,26 @@ func walkSegments(boundaries []time.Time, events []clippedEvent) []segment {
229232
continue
230233
}
231234
tiers := map[int]int{}
235+
burstGB := 0
232236
diskOver := 0
233237
for _, e := range events {
234238
// Event is "running" in [from, to) if it covers the segment
235239
// fully — i.e. e.From <= from AND e.To >= to. Since the
236240
// boundary set includes every event endpoint, every segment
237241
// is fully contained in any event whose span covers `from`.
238242
if !e.From.After(from) && !e.To.Before(to) {
239-
tiers[e.TierMB] += e.TierMB / 1024
243+
gb := e.TierMB / 1024
244+
if e.Burst {
245+
burstGB += gb
246+
} else {
247+
tiers[e.TierMB] += gb
248+
}
240249
if e.DiskMB > 20480 {
241250
diskOver += e.DiskMB - 20480
242251
}
243252
}
244253
}
245-
segs = append(segs, segment{From: from, To: to, RunningByTier: tiers, DiskOverageMB: diskOver})
254+
segs = append(segs, segment{From: from, To: to, RunningByTier: tiers, BurstGB: burstGB, DiskOverageMB: diskOver})
246255
}
247256
return segs
248257
}
@@ -251,6 +260,10 @@ func integrateSegments(segs []segment, reservedGB int) BucketTotals {
251260
out := BucketTotals{OverageGBSecondsByTier: map[int]float64{}}
252261
for _, s := range segs {
253262
secs := s.To.Sub(s.From).Seconds()
263+
if s.BurstGB > 0 {
264+
out.BurstGBSeconds += float64(s.BurstGB) * secs
265+
}
266+
254267
usage := 0
255268
for _, gb := range s.RunningByTier {
256269
usage += gb
@@ -281,7 +294,9 @@ func integrateSegments(segs []segment, reservedGB int) BucketTotals {
281294
// actual usage — the customer paid for the floor whether or not
282295
// they used it).
283296
// - overage_usage — one row per (org, sandbox_tier, bucket) where
284-
// the tier's overage contribution is non-zero.
297+
// the non-burst tier's overage contribution is non-zero.
298+
// - burst_usage — one row per (org, bucket) when burst sandboxes ran.
299+
// Burst is billed independently and does not consume reserved floors.
285300
// - disk_overage_usage — one row per (org, bucket) when any sandbox
286301
// in the bucket exceeded the 20 GB allowance.
287302
//
@@ -319,6 +334,20 @@ func emitBucket(ctx context.Context, store *db.Store, orgID uuid.UUID, bucketSta
319334
}
320335
}
321336

337+
if totals.BurstGBSeconds > 0 {
338+
ev := db.BillableEvent{
339+
OrgID: orgID,
340+
EventType: db.BillableEventBurstUsage,
341+
MemoryMB: 0,
342+
GBSeconds: totals.BurstGBSeconds,
343+
BucketStart: bucketStart,
344+
BucketEnd: bucketEnd,
345+
}
346+
if _, err := store.UpsertBillableEvent(ctx, ev); err != nil {
347+
return err
348+
}
349+
}
350+
322351
if totals.DiskOverageGBSeconds > 0 {
323352
ev := db.BillableEvent{
324353
OrgID: orgID,

internal/billing/capacity_reconciler_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ func TestIntegrateBucket_zeroReservation_replaysLegacy(t *testing.T) {
6666
}
6767
}
6868

69+
func TestIntegrateBucket_burstUsageSeparateFromOverage(t *testing.T) {
70+
bs, be := canonicalBucket()
71+
totals := IntegrateBucket(bs, be, 8, []db.ScaleEvent{
72+
{MemoryMB: 8192, DiskMB: 20480, Burst: true, StartedAt: bs, EndedAt: &be},
73+
{MemoryMB: 8192, DiskMB: 20480, StartedAt: bs, EndedAt: &be},
74+
})
75+
eq(t, "burst", totals.BurstGBSeconds, 8*900)
76+
eq(t, "reserved floor", totals.ReservedFloorGBSeconds, 8*900)
77+
if len(totals.OverageGBSecondsByTier) != 0 {
78+
t.Errorf("expected burst to stay out of overage, got %v", totals.OverageGBSecondsByTier)
79+
}
80+
}
81+
6982
func TestIntegrateBucket_fullCoverage_noOverage(t *testing.T) {
7083
bs, be := canonicalBucket()
7184
// One 8 GB sandbox, reservation covers it.

internal/billing/pricing.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ var TierPriceKey = map[int]string{
5454
// full lifetime of the sandbox (running OR hibernated, since the workspace
5555
// qcow2 still occupies host disk).
5656
const (
57-
DiskFreeAllowanceMB = 20480 // 20GB included with every sandbox
58-
DiskOveragePricePerGBPerSecond = 0.0000001 // ~$0.26 per GB-month
57+
DiskFreeAllowanceMB = 20480 // 20GB included with every sandbox
58+
DiskOveragePricePerGBPerSecond = 0.0000001 // ~$0.26 per GB-month
5959
DiskOverageMetadataKey = "sandbox_disk_overage"
6060
)
6161

@@ -68,6 +68,8 @@ const (
6868
// reserve capacity pay this.
6969
// - Overage: customer-facing name "instant." Flat per-GB rate
7070
// regardless of sandbox tier.
71+
// - Burst: customer-facing name "burst." Flat per-GB rate for
72+
// restartable burst sandboxes, independent of reserved capacity.
7173
//
7274
// **Dollar rates are deliberately not in code.** Stripe Prices are
7375
// configured in the Stripe Dashboard (or via Stripe API by the
@@ -92,6 +94,10 @@ const (
9294
// Instant (overage): single meter, flat across all sandbox sizes.
9395
OverageMeterKey = "sandbox_overage"
9496
OveragePriceKey = "sandbox_overage_v1"
97+
98+
// Burst: single meter, flat across all sandbox sizes.
99+
BurstMeterKey = "sandbox_burst"
100+
BurstPriceKey = "sandbox_burst_v1"
95101
)
96102

97103
// DiskOverageGBSeconds returns the chargeable GB-seconds for one usage summary

internal/billing/stripe.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,19 @@ type StripeClient struct {
3232
MeterEventNames map[int]string
3333

3434
// Disk overage meter / price (single dimension: GB-seconds above 20GB).
35-
DiskOveragePriceID string
35+
DiskOveragePriceID string
3636
DiskOverageMeterEventName string
3737

38-
// Phase-3 unified-pipeline meters and prices. Two flat meters
39-
// (overage + reserved) used by new orgs (`billing_mode='unified'`).
38+
// Phase-3 unified-pipeline meters and prices. Flat meters
39+
// (overage + reserved + burst) used by new orgs (`billing_mode='unified'`).
4040
// Legacy per-tier meters above are untouched and continue to serve
4141
// existing orgs via UsageReporter.
4242
OveragePriceID string // flat overage Price for `overage_usage` events
4343
OverageMeterEventName string // "sandbox_compute_sandbox_overage"
4444
ReservedPriceID string // flat reserved Price for `reserved_usage` events
4545
ReservedMeterEventName string // "sandbox_compute_sandbox_reserved"
46+
BurstPriceID string // flat burst Price for `burst_usage` events
47+
BurstMeterEventName string // "sandbox_compute_sandbox_burst"
4648

4749
// Per-agent paywalled-feature prices. Configured from env and
4850
// referenced by name from the dashboard subscribe handlers. Empty
@@ -236,8 +238,8 @@ func (s *StripeClient) EnsureProducts() error {
236238
log.Printf("billing: created disk overage price (id=%s)", p.ID)
237239
}
238240

239-
// 5. Phase-3 unified-pipeline meters. Two flat meters (overage +
240-
// reserved) at unit GB-seconds. Code creates the *meters* (their
241+
// 5. Phase-3 unified-pipeline meters. Flat meters (overage, reserved,
242+
// and burst) at unit GB-seconds. Code creates the *meters* (their
241243
// event names are stable wire-protocol coupling) but **does not
242244
// create Prices** — those are configured in the Stripe Dashboard
243245
// so pricing changes don't need a code deploy. The Price IDs
@@ -246,6 +248,7 @@ func (s *StripeClient) EnsureProducts() error {
246248
// linked to the meter in Stripe.
247249
s.OverageMeterEventName = ensureMeter(existingMeters, "sandbox_compute_"+OverageMeterKey, "Sandbox Instant Compute (GB-seconds)")
248250
s.ReservedMeterEventName = ensureMeter(existingMeters, "sandbox_compute_"+ReservedMeterKey, "Sandbox Reserved Capacity (GB-seconds)")
251+
s.BurstMeterEventName = ensureMeter(existingMeters, "sandbox_compute_"+BurstMeterKey, "Sandbox Burst Compute (GB-seconds)")
249252

250253
if id, ok := existingPrices[OveragePriceKey]; ok {
251254
s.OveragePriceID = id
@@ -259,6 +262,12 @@ func (s *StripeClient) EnsureProducts() error {
259262
} else {
260263
log.Printf("billing: no reserved price configured for meter %s — meter events will flow but won't appear on invoices until a Price is created in Stripe", s.ReservedMeterEventName)
261264
}
265+
if id, ok := existingPrices[BurstPriceKey]; ok {
266+
s.BurstPriceID = id
267+
log.Printf("billing: found existing burst price (id=%s)", id)
268+
} else {
269+
log.Printf("billing: no burst price configured for meter %s — meter events will flow but won't appear on invoices until a Price is created in Stripe", s.BurstMeterEventName)
270+
}
262271

263272
return nil
264273
}

internal/db/billable_events.go

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import (
1919
// Event types written to billable_events.event_type. Mirror the schema
2020
// CHECK constraint in migration 030.
2121
const (
22-
BillableEventReservedUsage = "reserved_usage"
23-
BillableEventOverageUsage = "overage_usage"
24-
BillableEventDiskOverageUsage = "disk_overage_usage"
22+
BillableEventReservedUsage = "reserved_usage"
23+
BillableEventOverageUsage = "overage_usage"
24+
BillableEventBurstUsage = "burst_usage"
25+
BillableEventDiskOverageUsage = "disk_overage_usage"
2526
)
2627

2728
// Delivery states. Mirror the schema CHECK constraint in migration 030.
@@ -33,22 +34,23 @@ const (
3334

3435
// BillableEvent is one outbox row.
3536
//
36-
// `MemoryMB` is 0 for `reserved_usage` and `disk_overage_usage` (sentinel
37-
// for "not a sandbox tier"), and the running sandbox tier for
37+
// `MemoryMB` is 0 for `reserved_usage`, `burst_usage`, and
38+
// `disk_overage_usage` (sentinel for "not a sandbox tier"), and the
39+
// running sandbox tier for
3840
// `overage_usage` (one row per tier per bucket via the proportional split
3941
// rule — see ws-pricing/work/001 "Per-second integration walk").
4042
type BillableEvent struct {
41-
ID uuid.UUID `json:"id"`
42-
OrgID uuid.UUID `json:"orgId"`
43-
EventType string `json:"eventType"`
44-
MemoryMB int `json:"memoryMB"`
45-
GBSeconds float64 `json:"gbSeconds"`
46-
BucketStart time.Time `json:"bucketStart"`
47-
BucketEnd time.Time `json:"bucketEnd"`
48-
DeliveryState string `json:"deliveryState"`
49-
StripeEventID *string `json:"stripeEventId,omitempty"`
50-
CreatedAt time.Time `json:"createdAt"`
51-
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
43+
ID uuid.UUID `json:"id"`
44+
OrgID uuid.UUID `json:"orgId"`
45+
EventType string `json:"eventType"`
46+
MemoryMB int `json:"memoryMB"`
47+
GBSeconds float64 `json:"gbSeconds"`
48+
BucketStart time.Time `json:"bucketStart"`
49+
BucketEnd time.Time `json:"bucketEnd"`
50+
DeliveryState string `json:"deliveryState"`
51+
StripeEventID *string `json:"stripeEventId,omitempty"`
52+
CreatedAt time.Time `json:"createdAt"`
53+
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
5254
}
5355

5456
// UpsertBillableEvent inserts a new outbox row, or no-ops if a row with
@@ -304,7 +306,18 @@ func (s *Store) GetReservedGBForBucket(ctx context.Context, orgID uuid.UUID, buc
304306
// metering. See the same NOT EXISTS in GetOrgUsage for rationale.
305307
func (s *Store) GetScaleEventsForBucket(ctx context.Context, orgID uuid.UUID, bucketStart, bucketEnd time.Time) ([]ScaleEvent, error) {
306308
rows, err := s.pool.Query(ctx, `
307-
SELECT id, sandbox_id, org_id, memory_mb, cpu_percent, disk_mb, started_at, ended_at
309+
SELECT id, sandbox_id, org_id, memory_mb, cpu_percent, disk_mb,
310+
EXISTS (
311+
SELECT 1
312+
FROM sandbox_sessions ss
313+
WHERE ss.sandbox_id = se.sandbox_id
314+
AND (
315+
COALESCE((ss.config->>'burst')::boolean, false)
316+
OR COALESCE((ss.config->>'resumable')::boolean, false)
317+
OR ss.config->>'sandboxFamily' IN ('spot', 'resumable', 'burst')
318+
)
319+
) AS burst,
320+
started_at, ended_at
308321
FROM sandbox_scale_events se
309322
WHERE org_id = $1
310323
AND started_at < $3
@@ -330,7 +343,7 @@ func (s *Store) GetScaleEventsForBucket(ctx context.Context, orgID uuid.UUID, bu
330343
for rows.Next() {
331344
var e ScaleEvent
332345
var orgUUID uuid.UUID
333-
if err := rows.Scan(&e.ID, &e.SandboxID, &orgUUID, &e.MemoryMB, &e.CPUPct, &e.DiskMB, &e.StartedAt, &e.EndedAt); err != nil {
346+
if err := rows.Scan(&e.ID, &e.SandboxID, &orgUUID, &e.MemoryMB, &e.CPUPct, &e.DiskMB, &e.Burst, &e.StartedAt, &e.EndedAt); err != nil {
334347
return nil, fmt.Errorf("scan scale event: %w", err)
335348
}
336349
e.OrgID = orgUUID.String()

internal/db/migrations/030_billable_events.up.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
-- 'overage_usage' — instant usage above the reserved floor,
1414
-- emitted per running sandbox tier with the
1515
-- tier as memory_mb
16+
-- 'burst_usage' — burst sandbox compute, billed as GB-seconds
17+
-- independent of reserved capacity; memory_mb = 0
1618
-- 'disk_overage_usage' — disk above the 20 GB allowance, org-level
1719
-- per bucket; memory_mb = 0
1820
CREATE TABLE IF NOT EXISTS billable_events (
@@ -28,7 +30,7 @@ CREATE TABLE IF NOT EXISTS billable_events (
2830
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2931
delivered_at TIMESTAMPTZ,
3032
UNIQUE (org_id, event_type, memory_mb, bucket_start),
31-
CHECK (event_type IN ('reserved_usage', 'overage_usage', 'disk_overage_usage')),
33+
CHECK (event_type IN ('reserved_usage', 'overage_usage', 'burst_usage', 'disk_overage_usage')),
3234
CHECK (delivery_state IN ('pending', 'sent', 'failed')),
3335
CHECK (gb_seconds > 0),
3436
CHECK (memory_mb >= 0),

0 commit comments

Comments
 (0)