@@ -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.
153153type 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
198200func collectBoundaries (events []clippedEvent , bucketStart , bucketEnd time.Time ) []time.Time {
@@ -216,9 +218,10 @@ func collectBoundaries(events []clippedEvent, bucketStart, bucketEnd time.Time)
216218}
217219
218220type 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
224227func 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 ,
0 commit comments