| index 29935be43..cd8aff0f7 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "math" |
| "net/http" |
| "os" |
| "strconv" |
| @@ -516,26 +517,50 @@ func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, d |
| // - else — Host itself has no aggs at all for the hour — a flat 60-minute |
| // (1.0h) fallback, since a present-but-metric-less hour key still means |
| // the box was up and dialed in for that hour. |
| +// |
| +// Each hour's contribution is clamped to at most 1.0 (review, fix round 1): |
| +// a mid-hour agent restart can land more than 60 samples in one hour bucket |
| +// — mergeExistingChunk dedups exact-timestamp collisions only, not a |
| +// restart's fresh cadence landing alongside the old one — and an N above 60 |
| +// must read as "fully present that hour", never as more than one hour billed |
| +// for one hour of wall time. |
| func rollupPresenceHours(d metrics.DayRollup) float64 { |
| var total float64 |
| for _, hour := range d.Hours { |
| + var minutes float64 |
| if cpu, ok := hour.Host["cpu"]; ok { |
| - total += float64(cpu.N) / 60 |
| - continue |
| + minutes = float64(cpu.N) |
| + } else if maxN := maxHostAggN(hour.Host); maxN > 0 { |
| + minutes = float64(maxN) |
| + } else { |
| + minutes = 60 |
| } |
| - maxN := 0 |
| - for _, agg := range hour.Host { |
| - if agg.N > maxN { |
| - maxN = agg.N |
| - } |
| + total += math.Min(1.0, minutes/60) |
| + } |
| + return total |
| +} |
| + |
| +// maxHostAggN returns the largest N across host's Agg entries, 0 for an |
| +// empty (or nil) map. |
| +func maxHostAggN(host map[string]metrics.Agg) int { |
| + maxN := 0 |
| + for _, agg := range host { |
| + if agg.N > maxN { |
| + maxN = agg.N |
| } |
| - if maxN > 0 { |
| - total += float64(maxN) / 60 |
| - continue |
| + } |
| + return maxN |
| +} |
| + |
| +// hasDeviceTag reports whether tags (FleetBox.Tags' comma-separated list) |
| +// carries the literal segment "device" — the device-vs-server meter split. |
| +func hasDeviceTag(tags string) bool { |
| + for _, tag := range strings.Split(tags, ",") { |
| + if tag == "device" { |
| + return true |
| } |
| - total += 1.0 |
| } |
| - return total |
| + return false |
| } |
| |
| // fleetMeters derives per-account fleet box-hours for [from, to]: for every |
| @@ -549,19 +574,53 @@ func rollupPresenceHours(d metrics.DayRollup) float64 { |
| // metrics/boxes/ once and attributes by UNIT (unitOwner), which has no |
| // concept of a fleet box at all — a fleet box's day rollup carries no units |
| // most of the time (see rollupPresenceHours's doc comment), only a host |
| -// aggregate, so folding this in there would mean threading a label->owner |
| -// map through sweepUsage's hot loop and its box-vs-fleet-box branching for a |
| -// meter family only two of usageSweep's several callers need at all. This |
| -// keeps sweepUsage and its existing tests completely untouched: the read |
| -// here is bounded to exactly the boxes that ARE fleet boxes (one Get per |
| -// fleet-box label per day in range; a day with no rollup is skipped, same |
| -// missing-means-skip convention sweepUsage itself uses). |
| +// aggregate, so folding this in there would mean threading a |
| +// (fleet,label)->owner map through sweepUsage's hot loop and its |
| +// box-vs-fleet-box branching for a meter family only two of usageSweep's |
| +// several callers need at all. This keeps sweepUsage and its existing tests |
| +// completely untouched: the read here is bounded to exactly the boxes that |
| +// ARE fleet boxes (one Get per (fleet,label) per day in range; a day with no |
| +// rollup is skipped, same missing-means-skip convention sweepUsage itself |
| +// uses). |
| +// |
| +// Fleet box rollups do NOT live at the platform root's metrics.DayKey(label, |
| +// date) — a door-backed fleet box's write ACL admits only |
| +// fleets/<own fleet>/** (internal/edge/storedoor.go's aclRead/aclWrite), and |
| +// resolveStatusPrefix (internal/host/agent.go) defaults every door-backed |
| +// box's publish prefix to "fleets/<fleet>/" for exactly that reason. This |
| +// reads each fleet's rollups through the same |
| +// release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet+"/") rebase the |
| +// console's own fleet page already uses for heartbeats |
| +// (loadFleetPage/fleets.go), with the plain metrics.DayKey(label, date) |
| +// inside it — so the map below is keyed by (fleet, label), not label alone. |
| // |
| -// Label collisions across fleets — the registry enforces per-fleet box-label |
| -// uniqueness only, not a global one — resolve to the alphabetically-first |
| -// fleet name (ListFleets already returns fleets ordered by name, so the |
| -// first owner claimed for a label during that ordered walk wins) with one |
| -// log line per pass per colliding label. |
| +// (fleet, label) keying also means a same-labeled box in two DIFFERENT |
| +// fleets is not a collision at all — each lives under its own fleet prefix |
| +// at a distinct object key, so both are read and billed independently. |
| +// (Review, fix round 1: an earlier version of this function keyed by label |
| +// alone and resolved a same-label clash to the alphabetically-first fleet — |
| +// which, under prefixed reads, would have been silent DATA LOSS for the |
| +// second fleet's box-hours, not a display-only tie-break. Removed entirely, |
| +// not just fixed, since the underlying ambiguity no longer exists.) |
| +// |
| +// Known gaps, recorded rather than fixed at modelling grade (review, fix |
| +// round 1): |
| +// - A fleet box provisioned before this prefixing existed — a grandfathered |
| +// bucket-backed box publishing straight to the platform root, or one an |
| +// operator hand-configured with CARLOS_STATUS_PREFIX="" — is invisible to |
| +// this meter: its rollups never appear under fleets/<fleet>/, so it |
| +// contributes zero fleet box-hours, and its sampled units (if any) fall |
| +// through unitOwner's unmatched case to release.OpsAccount like any other |
| +// unrecognized unit, same as a platform box's stray unit would. This |
| +// closes itself once the box (re)publishes under its fleet prefix — no |
| +// separate migration is needed. |
| +// - fleetMeters failing makes statementsPass itself fail (see |
| +// statementsPass's doc comment for why that's deliberate here). |
| +// - Retagging a box "device" mid-month re-prices the box's ENTIRE current |
| +// month the next time this pass runs, not just days after the retag — |
| +// statements are a create-OR-REPLACE snapshot of the whole month to |
| +// date, not an append-only ledger, so there is no "as of" boundary to |
| +// honor. Modelling-grade, matching every other meter this pass derives. |
| func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[string]map[string]float64, error) { |
| reg, err := s.openPassRegistry() |
| if err != nil { |
| @@ -576,33 +635,16 @@ func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[strin |
| acct string |
| device bool |
| } |
| - labelOwner := map[string]boxOwner{} |
| - labelFleet := map[string]string{} // label -> fleet that claimed it, for the collision log line |
| - loggedCollision := map[string]bool{} |
| + type boxKey struct{ fleet, label string } |
| + boxes := map[boxKey]boxOwner{} |
| |
| for _, f := range fleets { |
| - boxes, err := reg.ListFleetBoxes(f.Name) |
| + fboxes, err := reg.ListFleetBoxes(f.Name) |
| if err != nil { |
| return nil, fmt.Errorf("console: fleet meters: list boxes %s: %w", f.Name, err) |
| } |
| - for _, b := range boxes { |
| - if _, exists := labelOwner[b.Label]; exists { |
| - if !loggedCollision[b.Label] { |
| - loggedCollision[b.Label] = true |
| - log.Printf("console: fleet meters: box label %q claimed by both fleet %q and fleet %q; %q (alphabetically first) wins", |
| - b.Label, labelFleet[b.Label], f.Name, labelFleet[b.Label]) |
| - } |
| - continue |
| - } |
| - device := false |
| - for _, tag := range strings.Split(b.Tags, ",") { |
| - if tag == "device" { |
| - device = true |
| - break |
| - } |
| - } |
| - labelOwner[b.Label] = boxOwner{acct: f.Account, device: device} |
| - labelFleet[b.Label] = f.Name |
| + for _, b := range fboxes { |
| + boxes[boxKey{fleet: f.Name, label: b.Label}] = boxOwner{acct: f.Account, device: hasDeviceTag(b.Tags)} |
| } |
| } |
| |
| @@ -618,21 +660,22 @@ func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[strin |
| |
| fromDay := from.UTC().Truncate(24 * time.Hour) |
| toDay := to.UTC().Truncate(24 * time.Hour) |
| - for label, owner := range labelOwner { |
| + for bk, owner := range boxes { |
| meter := usage.MeterFleetBoxHoursServer |
| if owner.device { |
| meter = usage.MeterFleetBoxHoursDevice |
| } |
| + fleetStore := release.NewPrefixStore(s.cfg.Releases, "fleets/"+bk.fleet+"/") |
| for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { |
| date := usage.DayOf(d) |
| - key := metrics.DayKey(label, date) |
| - raw, _, gerr := s.cfg.Releases.Get(ctx, key) |
| + key := metrics.DayKey(bk.label, date) |
| + raw, _, gerr := fleetStore.Get(ctx, key) |
| if gerr != nil { |
| continue // missing = skip, same convention as sweepUsage |
| } |
| var roll metrics.DayRollup |
| if jerr := json.Unmarshal(raw, &roll); jerr != nil { |
| - log.Printf("console: fleet meters: parse %s: %v", key, jerr) |
| + log.Printf("console: fleet meters: parse fleets/%s/%s: %v", bk.fleet, key, jerr) |
| continue |
| } |
| touch(owner.acct)[meter] += rollupPresenceHours(roll) |
| @@ -657,6 +700,18 @@ func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[strin |
| // deleted_at), so this pass stops upserting its statement from the day of |
| // deletion onward — the account's last statement object simply stops moving |
| // forward, rather than the month closing it out explicitly. |
| +// |
| +// fleetMeters failing (a bad registry read, a bucket hiccup under a fleet |
| +// prefix) makes this WHOLE pass return an error rather than upsert |
| +// statements missing that account's fleet hours — deliberate (review, fix |
| +// round 1): a billing meter silently going to zero for a tick is worse than |
| +// this hourly pass simply not moving the statement forward that tick and |
| +// trying again next hour with the same last-good numbers still standing. |
| +// This is the WRITE side's posture only — Task 5's read side |
| +// (buildEconomicsView) must NOT inherit it: a live view degrading to "could |
| +// not read fleet hours" for one request is fine, refusing the whole |
| +// economics page over it is not, the same asymmetry sweepUsage's own doc |
| +// comment draws between its two callers. |
| func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| reg, err := s.openPassRegistry() |
| if err != nil { |