package usage import ( "context" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) // ScanRow is one account's on-demand scanning work for a UTC day. type ScanRow struct { Objects int64 `json:"objects"` Bytes int64 `json:"bytes"` } // ScanDay is usage/scan/.json, keyed by account. type ScanDay struct { V int `json:"v"` // 1 Date string `json:"date"` Accounts map[string]ScanRow `json:"accounts"` } func ScanDayKey(date string) string { return "usage/scan/" + date + ".json" } const ScanPrefix = "usage/scan/" func (d *ScanDay) AddDeltas(deltas map[string]ScanRow) { if d.Accounts == nil { d.Accounts = map[string]ScanRow{} } for account, delta := range deltas { row := d.Accounts[account] row.Objects += delta.Objects row.Bytes += delta.Bytes d.Accounts[account] = row } } // UpsertScanDay folds one drain tick into its UTC day. Empty ticks write // nothing; a compare-and-swap loss re-reads before adding so concurrent // consoles cannot silently replace each other's counts. func UpsertScanDay(ctx context.Context, s release.Store, now time.Time, deltas map[string]ScanRow) error { if len(deltas) == 0 { return nil } date := DayOf(now) key := ScanDayKey(date) for attempt := 0; attempt < 3; attempt++ { day := ScanDay{V: 1, Date: date} raw, etag, err := s.Get(ctx, key) switch { case err == nil: if err := json.Unmarshal(raw, &day); err != nil { return fmt.Errorf("usage: parse %s: %w", key, err) } case errors.Is(err, os.ErrNotExist): etag = "" default: return err } day.AddDeltas(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) }