carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package metrics
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// hourFormat is the UTC hour format used both in RawChunk.Hour and in raw
15// chunk keys: 2006-01-02-15.
16const hourFormat = "2006-01-02-15"
17
18// HourOf formats t as the UTC hour bucket a raw chunk belongs to.
19func HourOf(t time.Time) string {
20 return t.UTC().Format(hourFormat)
21}
22
23// RawKey is the bucket key for a box's raw chunk covering the given hour
24// (itself formatted like HourOf's output, e.g. 2026-08-02-21).
25func RawKey(box, hour string) string {
26 return "metrics/boxes/" + box + "/raw/" + hour + ".json"
27}
28
29// DayKey is the bucket key for a box's rolled-up day chunk (date formatted
30// 2006-01-02).
31func DayKey(box, date string) string {
32 return "metrics/boxes/" + box + "/days/" + date + ".json"
33}
34
35// WriteRaw marshals c indented and writes it to its hour-derived key,
36// last-writer-wins. A box appends samples to the same hour's chunk
37// repeatedly as the hour progresses, so a bare create-only Put (ifMatch "")
38// would fail every write after the first with ErrCASConflict — this mirrors
39// status.WriteBox's read-etag-then-Put dance to express "replace whatever is
40// there, or create it" on top of release.Store's CAS-only Put: read the
41// current etag (a not-found just means "absent", so ifMatch stays "" for a
42// create), Put with that etag, and if the Put itself loses the race
43// (something else wrote in between), re-read once and retry with the fresh
44// etag. A second conflict is NOT retried again and is returned to the
45// caller: this box is the only writer that should ever be targeting its own
46// raw key, so two conflicts in a row means something else collided
47// (clock-skewed duplicate machine id, a bug elsewhere) — that deserves a
48// loud failure, not a silent third attempt.
49func WriteRaw(ctx context.Context, s release.Store, c RawChunk) error {
50 body, err := json.MarshalIndent(c, "", " ")
51 if err != nil {
52 return err
53 }
54 key := RawKey(c.Box, c.Hour)
55
56 etag, err := currentRawEtag(ctx, s, key)
57 if err != nil {
58 return err
59 }
60 if _, err := s.Put(ctx, key, body, etag); err != nil {
61 if !errors.Is(err, release.ErrCASConflict) {
62 return err
63 }
64 etag, err = currentRawEtag(ctx, s, key)
65 if err != nil {
66 return err
67 }
68 if _, err := s.Put(ctx, key, body, etag); err != nil {
69 return err
70 }
71 }
72 return nil
73}
74
75// currentRawEtag reads a key's etag for a replace-only Put, folding
76// "doesn't exist yet" into "" (create-only) — the same absent-means-empty
77// convention release.Store's own CAS contract uses.
78func currentRawEtag(ctx context.Context, s release.Store, key string) (string, error) {
79 _, etag, err := s.Get(ctx, key)
80 if err != nil {
81 if errors.Is(err, os.ErrNotExist) {
82 return "", nil
83 }
84 return "", err
85 }
86 return etag, nil
87}
88
89// StaleRawKeys returns the subset of keys (raw chunk keys, as produced by
90// RawKey) whose hour is older than now.Add(-keep) — candidates for pruning.
91// It is pure: no I/O, and it MUST NEVER return a key it cannot parse — never
92// delete what you don't understand. A key is parsed by taking the text
93// between the last "/" and ".json" and parsing it with the same UTC hour
94// format RawKey/HourOf use; anything that doesn't fit that shape (a
95// different key layout, a corrupted or hand-edited name) is silently
96// skipped rather than treated as either stale or fresh.
97func StaleRawKeys(keys []string, now time.Time, keep time.Duration) []string {
98 cutoff := now.Add(-keep)
99 var out []string
100 for _, key := range keys {
101 hour, ok := rawKeyHour(key)
102 if !ok {
103 continue
104 }
105 t, err := time.Parse(hourFormat, hour)
106 if err != nil {
107 continue
108 }
109 if t.Before(cutoff) {
110 out = append(out, key)
111 }
112 }
113 return out
114}
115
116// rawKeyHour extracts the "<hour>" portion of a "…/<hour>.json" key.
117func rawKeyHour(key string) (string, bool) {
118 const suffix = ".json"
119 if !strings.HasSuffix(key, suffix) {
120 return "", false
121 }
122 base := strings.TrimSuffix(key, suffix)
123 idx := strings.LastIndex(base, "/")
124 hour := base[idx+1:]
125 if hour == "" {
126 return "", false
127 }
128 return hour, true
129}
130