package usage import ( "context" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) // EdgeRow is one route's traffic within one hour on one box. type EdgeRow struct { Account string `json:"account"` App string `json:"app"` Requests int64 `json:"requests"` BytesOut int64 `json:"bytes_out"` // HopRequests/HopBytesOut are the SUBSET of Requests/BytesOut that // arrived over a verified backhaul hop (multi-region spec Part 3) — an // annotation inside the totals, never an addition to them: a consumer // that sums BytesOut already includes every hop byte, and must never add // HopBytesOut on top. They exist so the platform can see which traffic // crossed a region (its own egress is doubled by a hop) and so backhaul // can be priced later without a shape change; nothing bills them today. // // Additive and omitempty: a day object written before these existed // parses with both zero, one written after them is ignored field-for- // field by an older binary, and a single-region deployment's objects // stay byte-identical to what it wrote yesterday. HopRequests int64 `json:"hop_requests,omitempty"` HopBytesOut int64 `json:"hop_bytes_out,omitempty"` } // EdgeDay is usage/edge//days/.json — a box's edge traffic for // one UTC day, hour-bucketed like metrics.DayRollup. Unlike metrics there is // no separate raw-chunk family: the 5-minute flush CAS-merges deltas // straight into this object, which is both the durability write and the // rollup. type EdgeDay struct { V int `json:"v"` // 1 Box string `json:"box"` Date string `json:"date"` Hours map[string]map[string]EdgeRow `json:"hours"` // "09" -> host -> row } // EdgeDayKey is the bucket key for a box's day object. func EdgeDayKey(box, date string) string { return "usage/edge/" + box + "/days/" + date + ".json" } // EdgeDaysPrefix is the List prefix for a box's day objects. func EdgeDaysPrefix(box string) string { return "usage/edge/" + box + "/days/" } // EdgeBoxesPrefix is the ListDirs prefix that discovers metering boxes. const EdgeBoxesPrefix = "usage/edge/" // AddDeltas folds drained per-host deltas into the day's hour bucket, // summing counters and refreshing the route identity (a host re-pointed to // a new app mid-hour lands on the later identity — modelling grade). func (d *EdgeDay) AddDeltas(hour string, deltas map[string]EdgeRow) { if d.Hours == nil { d.Hours = map[string]map[string]EdgeRow{} } rows := d.Hours[hour] if rows == nil { rows = map[string]EdgeRow{} d.Hours[hour] = rows } for host, delta := range deltas { row := rows[host] row.Account, row.App = delta.Account, delta.App row.Requests += delta.Requests row.BytesOut += delta.BytesOut row.HopRequests += delta.HopRequests row.HopBytesOut += delta.HopBytesOut rows[host] = row } } // UpsertEdgeDay CAS-merges deltas into the box's day object for now's UTC // day and hour: read (etag), add, conditional put; on ErrCASConflict re-read // and retry, three attempts. Empty deltas write nothing. func UpsertEdgeDay(ctx context.Context, s release.Store, box string, now time.Time, deltas map[string]EdgeRow) error { if len(deltas) == 0 { return nil } date, hour := DayOf(now), HourLabel(now) key := EdgeDayKey(box, date) for attempt := 0; attempt < 3; attempt++ { day := EdgeDay{V: 1, Box: box, Date: date} raw, etag, err := s.Get(ctx, key) switch { case err == nil: if uerr := json.Unmarshal(raw, &day); uerr != nil { return fmt.Errorf("usage: parse %s: %w", key, uerr) } case errors.Is(err, os.ErrNotExist): etag = "" // create-only Put default: return err } day.AddDeltas(hour, deltas) out, err := json.Marshal(day) if err != nil { return err } if _, err := s.Put(ctx, key, out, etag); err == nil { return nil } else if !errors.Is(err, release.ErrCASConflict) { return err } } return fmt.Errorf("usage: %s: lost CAS race three times", key) }