metrics: raw chunk shapes, bucket keys, CAS overwrite, prune selection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+295
−0
internal/metrics/chunk.go+129 −0internal/metrics/chunk_test.go+120 −0internal/metrics/sample.go+46 −0
diff --git a/internal/metrics/chunk.go b/internal/metrics/chunk.go| new file mode 100644 |
| index 000000000..bcb72011d |
| --- /dev/null |
| +++ b/internal/metrics/chunk.go |
| @@ -0,0 +1,129 @@ |
| +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 "<hour>" portion of a "…/<hour>.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 |
| +} |
diff --git a/internal/metrics/chunk_test.go b/internal/metrics/chunk_test.go| new file mode 100644 |
| index 000000000..6dafc5268 |
| --- /dev/null |
| +++ b/internal/metrics/chunk_test.go |
| @@ -0,0 +1,120 @@ |
| +package metrics |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestHourOf(t *testing.T) { |
| + got := HourOf(time.Date(2026, 8, 2, 21, 45, 0, 0, time.UTC)) |
| + if got != "2026-08-02-21" { |
| + t.Fatalf("got %q, want 2026-08-02-21", got) |
| + } |
| +} |
| + |
| +func TestRawKeyDayKey(t *testing.T) { |
| + if got, want := RawKey("b", "2026-08-02-21"), "metrics/boxes/b/raw/2026-08-02-21.json"; got != want { |
| + t.Fatalf("RawKey got %q, want %q", got, want) |
| + } |
| + if got, want := DayKey("b", "2026-08-02"), "metrics/boxes/b/days/2026-08-02.json"; got != want { |
| + t.Fatalf("DayKey got %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func sampleFloat(v float64) *float64 { return &v } |
| + |
| +func TestWriteRawRoundTrip(t *testing.T) { |
| + store := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + |
| + c := RawChunk{ |
| + V: 1, |
| + Box: "b", |
| + Hour: "2026-08-02-21", |
| + Samples: []Sample{ |
| + { |
| + At: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), |
| + Host: HostSample{CPUFrac: sampleFloat(0.5)}, |
| + Units: map[string]UnitSample{ |
| + "carlos-app@x.example.com.service": {CPUFrac: sampleFloat(0.1)}, |
| + }, |
| + }, |
| + }, |
| + } |
| + |
| + if err := WriteRaw(ctx, store, c); err != nil { |
| + t.Fatalf("WriteRaw: %v", err) |
| + } |
| + |
| + body, _, err := store.Get(ctx, RawKey(c.Box, c.Hour)) |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + var got RawChunk |
| + if err := json.Unmarshal(body, &got); err != nil { |
| + t.Fatalf("Unmarshal: %v", err) |
| + } |
| + if got.V != c.V || got.Box != c.Box || got.Hour != c.Hour || len(got.Samples) != 1 { |
| + t.Fatalf("got %+v, want %+v", got, c) |
| + } |
| + if got.Samples[0].Host.CPUFrac == nil || *got.Samples[0].Host.CPUFrac != 0.5 { |
| + t.Fatalf("host cpu round-trip mismatch: %+v", got.Samples[0].Host) |
| + } |
| +} |
| + |
| +func TestWriteRawOverwriteSameHour(t *testing.T) { |
| + store := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + |
| + first := RawChunk{V: 1, Box: "b", Hour: "2026-08-02-21", Samples: []Sample{ |
| + {At: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), Host: HostSample{CPUFrac: sampleFloat(0.1)}}, |
| + }} |
| + second := RawChunk{V: 1, Box: "b", Hour: "2026-08-02-21", Samples: []Sample{ |
| + {At: time.Date(2026, 8, 2, 21, 5, 0, 0, time.UTC), Host: HostSample{CPUFrac: sampleFloat(0.2)}}, |
| + {At: time.Date(2026, 8, 2, 21, 10, 0, 0, time.UTC), Host: HostSample{CPUFrac: sampleFloat(0.3)}}, |
| + }} |
| + |
| + if err := WriteRaw(ctx, store, first); err != nil { |
| + t.Fatalf("WriteRaw first: %v", err) |
| + } |
| + if err := WriteRaw(ctx, store, second); err != nil { |
| + t.Fatalf("WriteRaw second: %v", err) |
| + } |
| + |
| + keys, err := store.List(ctx, "metrics/boxes/b/raw/") |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + if len(keys) != 1 { |
| + t.Fatalf("got %d keys, want exactly one (overwrite, not a new object): %v", len(keys), keys) |
| + } |
| + |
| + body, _, err := store.Get(ctx, RawKey("b", "2026-08-02-21")) |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + var got RawChunk |
| + if err := json.Unmarshal(body, &got); err != nil { |
| + t.Fatalf("Unmarshal: %v", err) |
| + } |
| + if len(got.Samples) != 2 { |
| + t.Fatalf("got %d samples, want the latest write's 2 (latest content wins)", len(got.Samples)) |
| + } |
| +} |
| + |
| +func TestStaleRawKeys(t *testing.T) { |
| + now := time.Date(2026, 8, 2, 22, 30, 0, 0, time.UTC) |
| + keys := []string{ |
| + "metrics/boxes/b/raw/2026-07-31-10.json", // stale |
| + "metrics/boxes/b/raw/2026-08-02-21.json", // fresh |
| + "metrics/boxes/b/raw/garbage.json", // unparsable — never returned |
| + } |
| + got := StaleRawKeys(keys, now, 48*time.Hour) |
| + if len(got) != 1 || got[0] != keys[0] { |
| + t.Fatalf("got %v, want only the stale key", got) |
| + } |
| +} |
diff --git a/internal/metrics/sample.go b/internal/metrics/sample.go| new file mode 100644 |
| index 000000000..99dcef827 |
| --- /dev/null |
| +++ b/internal/metrics/sample.go |
| @@ -0,0 +1,46 @@ |
| +package metrics |
| + |
| +import "time" |
| + |
| +// HostSample is one point-in-time reading of whole-host resource usage. |
| +// Every field is a pointer because a missing reading (a parser in parse.go |
| +// returning nil) must stay distinguishable from a real zero all the way out |
| +// to whatever renders or alerts on it. |
| +type HostSample struct { |
| + CPUFrac *float64 `json:"cpu"` |
| + Load1 *float64 `json:"load1"` |
| + Load5 *float64 `json:"load5"` |
| + Load15 *float64 `json:"load15"` |
| + MemFrac *float64 `json:"mem"` |
| + DiskFrac *float64 `json:"disk"` |
| +} |
| + |
| +// UnitSample is one point-in-time reading for a single systemd unit's |
| +// cgroup. ReadBytes and WriteBytes are cumulative counters (not deltas) — |
| +// callers wanting a rate diff consecutive samples themselves. |
| +type UnitSample struct { |
| + CPUFrac *float64 `json:"cpu"` |
| + MemBytes *int64 `json:"mem_bytes"` |
| + MemPeakBytes *int64 `json:"mem_peak_bytes"` |
| + ReadBytes *int64 `json:"read_bytes"` // cumulative |
| + WriteBytes *int64 `json:"write_bytes"` // cumulative |
| +} |
| + |
| +// Sample is one point-in-time reading of a box: the host as a whole plus |
| +// every monitored unit, keyed by full unit name (e.g. |
| +// carlos-app@x.example.com.service). |
| +type Sample struct { |
| + At time.Time `json:"at"` |
| + Host HostSample `json:"host"` |
| + Units map[string]UnitSample `json:"units"` |
| +} |
| + |
| +// RawChunk is an hour's worth of raw samples for one box, the unit of |
| +// storage under metrics/boxes/<box>/raw/. V is the wire-shape version (1 |
| +// today); Hour is UTC, formatted 2006-01-02-15. |
| +type RawChunk struct { |
| + V int `json:"v"` // 1 |
| + Box string `json:"box"` |
| + Hour string `json:"hour"` // UTC, 2006-01-02-15 |
| + Samples []Sample `json:"samples"` |
| +} |