package metrics import ( "context" "encoding/json" "errors" "os" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // hourFormat is the UTC hour format used both in RawChunk.Hour and in raw // chunk keys: 2006-01-02-15. const hourFormat = "2006-01-02-15" // HourOf formats t as the UTC hour bucket a raw chunk belongs to. func HourOf(t time.Time) string { return t.UTC().Format(hourFormat) } // RawKey is the bucket key for a box's raw chunk covering the given hour // (itself formatted like HourOf's output, e.g. 2026-08-02-21). func RawKey(box, hour string) string { return "metrics/boxes/" + box + "/raw/" + hour + ".json" } // DayKey is the bucket key for a box's rolled-up day chunk (date formatted // 2006-01-02). func DayKey(box, date string) string { return "metrics/boxes/" + box + "/days/" + date + ".json" } // WriteRaw marshals c indented and writes it to its hour-derived key, // last-writer-wins. A box appends samples to the same hour's chunk // repeatedly as the hour progresses, so a bare create-only Put (ifMatch "") // would fail every write after the first with ErrCASConflict — this mirrors // status.WriteBox's read-etag-then-Put dance to express "replace whatever is // there, or create it" on top of release.Store's CAS-only Put: read the // current etag (a not-found just means "absent", so ifMatch stays "" for a // create), Put with that etag, and if the Put itself loses the race // (something else wrote in between), re-read once and retry with the fresh // etag. A second conflict is NOT retried again and is returned to the // caller: this box is the only writer that should ever be targeting its own // raw key, so two conflicts in a row means something else collided // (clock-skewed duplicate machine id, a bug elsewhere) — that deserves a // loud failure, not a silent third attempt. func WriteRaw(ctx context.Context, s release.Store, c RawChunk) error { body, err := json.MarshalIndent(c, "", " ") if err != nil { return err } key := RawKey(c.Box, c.Hour) etag, err := currentRawEtag(ctx, s, key) if err != nil { return err } if _, err := s.Put(ctx, key, body, etag); err != nil { if !errors.Is(err, release.ErrCASConflict) { return err } etag, err = currentRawEtag(ctx, s, key) if err != nil { return err } if _, err := s.Put(ctx, key, body, etag); err != nil { return err } } return nil } // currentRawEtag reads a key's etag for a replace-only Put, folding // "doesn't exist yet" into "" (create-only) — the same absent-means-empty // convention release.Store's own CAS contract uses. func currentRawEtag(ctx context.Context, s release.Store, key string) (string, error) { _, etag, err := s.Get(ctx, key) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", nil } return "", err } return etag, nil } // StaleRawKeys returns the subset of keys (raw chunk keys, as produced by // RawKey) whose hour is older than now.Add(-keep) — candidates for pruning. // It is pure: no I/O, and it MUST NEVER return a key it cannot parse — never // delete what you don't understand. A key is parsed by taking the text // between the last "/" and ".json" and parsing it with the same UTC hour // format RawKey/HourOf use; anything that doesn't fit that shape (a // different key layout, a corrupted or hand-edited name) is silently // skipped rather than treated as either stale or fresh. func StaleRawKeys(keys []string, now time.Time, keep time.Duration) []string { cutoff := now.Add(-keep) var out []string for _, key := range keys { hour, ok := rawKeyHour(key) if !ok { continue } t, err := time.Parse(hourFormat, hour) if err != nil { continue } if t.Before(cutoff) { out = append(out, key) } } return out } // rawKeyHour extracts the "" portion of a "…/.json" key. func rawKeyHour(key string) (string, bool) { const suffix = ".json" if !strings.HasSuffix(key, suffix) { return "", false } base := strings.TrimSuffix(key, suffix) idx := strings.LastIndex(base, "/") hour := base[idx+1:] if hour == "" { return "", false } return hour, true }