carlos / platform Public

console: the instances page stops flapping and stops paying the sweep

Two of the three keymail instances-page findings (2026-08-06 diagnosis):

- metricsFreshWindow 5min -> 8min, derived (flush-cadence floor 5 +
  stale-serve ceiling 2 + slack 1): the old window equaled the agent's
  flush cadence, so healthy unit-backed readings blinked out at the tail
  of every flush cycle.
- sweepBox fetches its 48 hourly chunks 8-wide (errgroup) instead of
  sequentially (~2.5s measured on the box).
- LoadMetrics serves an expired-but-recent cache instantly and refreshes
  in a single-flight background goroutine, bounded by a 2-minute
  stale-serve ceiling — past it, callers block for fresh data, which
  also keeps bucket outages visible instead of silently aging the cache.

Opus-reviewed twice: state machine verified race-free across both
rounds; the ceiling exists because round one showed unbounded SWR made
cache age track request-arrival gaps, recreating the flap after lulls.
The third finding (exec-backed instances have no accounting at all) is
specced separately in 2026-08-07-exec-child-accounting-design.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f085e415f98be39d7d8abb5c44c7c15147c00643 parent eb1a287
5 files changed, +1030 −58
  • docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md +6 −3
  • go.mod +1 −1
  • internal/console/metricsview.go +271 −51
  • internal/console/metricsview_test.go +747 −0
  • internal/console/server.go +5 −3
diff --git a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md
index c1ce58c14..f2c37a17b 100644
--- a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md
+++ b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md
@@ -179,10 +179,13 @@ before deriving each instance's `carlos-app@<host>.service` unit name — a
route stamped for a different account is invisible here even if the app
name matches.
-Implementation note: a box whose newest sample is older than 5 minutes
-(`metricsFreshWindow`) is treated as not-reporting in this view — its
+Implementation note: a box whose newest sample is older than
+`metricsFreshWindow` is treated as not-reporting in this view — its
instances render "asleep — no recent readings" rather than presenting
-stale numbers as current. The operator dashboard, by contrast, shows the
+stale numbers as current. (Originally 5 minutes; re-derived 2026-08-07 as
+8 minutes — flush-cadence floor + the stale-while-revalidate serve
+ceiling + slack; the constant's doc comment in
+`internal/console/metricsview.go` carries the full derivation.) The operator dashboard, by contrast, shows the
sample's own timestamp and applies no such cutoff.
House rendering rules for both views: vendored assets only (Lucide-style
diff --git a/go.mod b/go.mod
index 6d1969096..341b46185 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/benbjohnson/litestream v0.5.14
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
+ golang.org/x/sync v0.21.0
modernc.org/sqlite v1.55.0
)
@@ -55,7 +56,6 @@ require (
github.com/superfly/ltx v0.5.1 // indirect
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
- golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
index be27dcb31..0d519279a 100644
--- a/internal/console/metricsview.go
+++ b/internal/console/metricsview.go
@@ -13,6 +13,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log"
"os"
"sort"
"strconv"
@@ -20,6 +21,8 @@ import (
"sync"
"time"
+ "golang.org/x/sync/errgroup"
+
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/metrics"
)
@@ -53,27 +56,88 @@ type AlertRow struct {
FiredAt time.Time
}
-// metricsCacheTTL bounds how long LoadMetrics reuses its last successful
-// sweep before touching the bucket again. All callers within the window
-// share the one sweep — the operator dashboard and every owner's app-metrics
-// page render off the same read.
+// metricsCacheTTL is how soon after a successful sweep LoadMetrics considers
+// the cache FRESH and skips even trying to refresh it. It does NOT bound how
+// long a result may be served overall — see metricsStaleServeCeiling for
+// that outer bound. Past metricsCacheTTL the cache becomes STALE (still
+// served immediately, but a background refresh is triggered) rather than
+// unusable. All callers within the FRESH window share the one sweep — the
+// operator dashboard and every owner's app-metrics page render off the same
+// read.
const metricsCacheTTL = 60 * time.Second
+// metricsStaleServeCeiling bounds how old the cache may be and still be
+// handed out immediately while a background refresh runs (LoadMetrics's
+// STALE-WITHIN-CEILING state). It exists because nothing keeps the cache
+// warm between requests — its age tracks the gap since the last REQUEST,
+// not a timer — so without a ceiling, the first request after a quiet
+// stretch would serve data that is arbitrarily old (a healthy box's actual
+// reporting gap, PLUS however long nobody happened to ask), and a
+// persistent bucket outage would let LoadMetrics return stale data with a
+// nil error forever, since a background sweep's failure is only logged (see
+// startBackgroundSweep) and nothing else ever revisits it. Past this
+// ceiling, LoadMetrics falls through to the same blocking single-flight
+// path the COLD case uses — costing on the order of a second now that
+// sweepBox parallelises its GETs, rather than the several seconds a
+// sequential sweep used to cost — in exchange for two things a stale answer
+// can't give: data no older than one fresh sweep, and a sweep error
+// actually reaching a caller instead of vanishing into a log line. No
+// separate failure backoff is needed on top of this: a sweep that keeps
+// failing never advances c.at, so the cache's age crosses the ceiling on its
+// own within a couple of minutes regardless of how long the outage lasts.
+const metricsStaleServeCeiling = 2 * time.Minute
+
// metricsSweepHours is how far back LoadMetrics reads raw chunks: the last
// 48 UTC hours, days/ rollups untouched (a later task's job).
const metricsSweepHours = 48
+// metricsSweepBoxConcurrency bounds how many of sweepBox's 48 hourly GETs run
+// at once (errgroup.SetLimit). A fully sequential sweep of one box was
+// measured at ~2.5s — 48 round trips to the bucket paid one after another —
+// and that cost used to land inline on whichever request's bad luck it was
+// to arrive right after a cache expiry. 8 is deliberately not tuned to any
+// measured ceiling: comfortably below any per-host connection limit worth
+// worrying about for a fleet this size, comfortably above "might as well be
+// sequential." Collection order doesn't matter — see sweepBox's own comment
+// — so bounding rather than fully parallelising costs nothing in practice.
+const metricsSweepBoxConcurrency = 8
+
// metricsFreshWindow is how old a box's newest sample may be before its
-// readings stop being anyone's CURRENT figure. The agent samples once a
-// minute, so five minutes is five consecutively missed samples — past any
-// normal hiccup, and the point at which a box has plainly stopped
-// publishing. It matters because the 48h sweep keeps serving a dead box's
-// final sample for two days: without this bound an instance that vanished
-// on Monday still reads "11% / 1.0 KB, current" on Wednesday. Owners get the
-// no-recent-readings state instead (they have no timestamp on the page to
-// judge staleness for themselves; the operator dashboard prints "Latest
-// <time>" and so is deliberately left unbounded).
-const metricsFreshWindow = 5 * time.Minute
+// readings stop being anyone's CURRENT figure. The value is derived, not
+// chosen freehand, from three terms:
+//
+// - Flush cadence, 5 minutes — a FLOOR, not an exact figure: metricsLoop
+// sleeps 60s per tick and does per-tick work on top of that (alertTick
+// every tick, metricsFlush's own bucket writes every 5th), so the actual
+// gap between flushes only ever drifts upward from 5:00, never down.
+// - metricsStaleServeCeiling, 2 minutes — the oldest a cache
+// LoadMetrics hands back under stale-while-revalidate may be, i.e. how
+// far behind the true "latest flush" a box.At a caller sees can sit even
+// with a store that is never slow.
+// - One more minute of slack, covering both the flush-cadence drift above
+// and the ≤metricsSweepTimeout (15s) a background refresh can still be
+// mid-flight for when its result gets read.
+//
+// 5 + 2 + 1 = 8 minutes. This window must exceed that sum or a perfectly
+// healthy, perfectly reachable box flaps between reporting and
+// no-recent-readings for the tail of every cycle.
+//
+// It still has to do the job it always did on top of that: the 48h sweep
+// keeps serving a dead box's final sample for two days, so without SOME
+// bound an instance that vanished on Monday still reads "11% / 1.0 KB,
+// current" on Wednesday. Owners get the no-recent-readings state instead
+// (they have no timestamp on the page to judge staleness for themselves; the
+// operator dashboard prints "Latest <time>" and so is deliberately left
+// unbounded).
+//
+// What this window does NOT promise: it does not survive even ONE missed
+// flush — miss a single 5-minute flush and the gap to the NEXT successful
+// one is already ~10 minutes, past this window, and the box drops out
+// exactly as it would have before this fix. That was true of the old 5-minute window too; widening
+// far enough to also cover a missed flush would mean a truly dead box's
+// stale reading survives even longer before it stops being anyone's
+// "current" figure — not a trade worth making for one skipped beat.
+const metricsFreshWindow = 8 * time.Minute
// metricsBoxesPrefix is where a box publishes its raw chunks and day
// rollups (internal/metrics.RawKey / DayKey); box discovery lists this
@@ -90,15 +154,29 @@ const metricsBoxesPrefix = "metrics/boxes/"
const metricsSweepTimeout = 15 * time.Second
// metricsCache holds the result of the last successful bucket sweep. A
-// failed sweep never reaches here — LoadMetrics returns the error to its
-// caller and leaves the previous (possibly stale-but-valid, possibly zero)
-// cache exactly as it was.
+// failed sweep never reaches here — a failed BLOCKING sweep (c.at still
+// zero, OR the cache is stale past metricsStaleServeCeiling) returns the
+// error to its caller and leaves the cache exactly as it was; a failed
+// BACKGROUND sweep (c.at set and still within the ceiling) is only logged,
+// since nobody is waiting on it and the stale cache it leaves behind is
+// still valid data, just not fresher.
+//
+// No separate failure backoff sits on top of the ceiling: a sweep that keeps
+// failing never advances c.at, so the cache's age crosses
+// metricsStaleServeCeiling on its own within a couple of minutes regardless
+// of how long an outage lasts, and every caller after that lands back on the
+// blocking path where the failure is visible again — see
+// metricsStaleServeCeiling's own comment.
//
// mu guards the fields and is deliberately NOT held across the sweep's bucket
// IO; sweeping/done are what serialise the IO instead, so that a cold sweep
// delays only the callers that actually need its result rather than every
-// caller that touches the cache. sweeping is true while one goroutine is out
-// at the bucket, and done is the channel it closes when it comes back.
+// caller that touches the cache. sweeping is true while one goroutine (either
+// a blocking caller's own, or the detached refresh goroutine LoadMetrics
+// launches for stale-while-revalidate) is out at the bucket, and done is the
+// channel it closes when it comes back — the SAME channel and flag serve
+// both shapes of sweep, so a caller arriving mid-refresh, whichever kind
+// started it, coalesces onto it rather than starting a second one.
type metricsCache struct {
mu sync.Mutex
at time.Time
@@ -110,36 +188,95 @@ type metricsCache struct {
// LoadMetrics reads every box under metrics/boxes/, the raw chunks of the
// last 48 hours (missing hours skipped silently), and alerts/boxes/*.json,
-// caching the result in-process for 60s so concurrent callers — the
-// dashboard page and every owner's app-metrics page — share one sweep.
+// caching the result in-process so concurrent callers — the dashboard page
+// and every owner's app-metrics page — usually share one sweep.
+//
+// Three cache states:
//
-// Two properties matter beyond the cache itself, both learned from the shape
-// this replaced (mutex held across the whole sweep, on the request's own
-// context):
+// - FRESH (c.at within metricsCacheTTL): the cached answer, immediately,
+// no bucket touched at all.
+// - STALE-WITHIN-CEILING (c.at is set, older than metricsCacheTTL, but no
+// older than metricsStaleServeCeiling): the cached answer is STILL
+// returned immediately — stale-while-revalidate — and, if no sweep is
+// already in flight, a fresh one is kicked off on a detached goroutine
+// (startBackgroundSweep) for the NEXT caller to benefit from. This is
+// the fix for the sweep's own cost: sweepBox's 48 hourly GETs for a
+// single box were measured at ~2.5s run sequentially, and that cost
+// used to land inline on whichever request's bad luck it was to arrive
+// right after a cache expiry — the dashboard page and every tenant's
+// app page alike.
+// - COLD (c.at is zero — nothing has ever swept successfully) or STALE
+// PAST THE CEILING (c.at is set but older than
+// metricsStaleServeCeiling — nothing has kept the cache warm for a
+// while, e.g. a quiet console between requests, or every sweep since
+// has been failing): the ORIGINAL blocking behaviour. There is either no
+// servable stale answer (COLD) or the one that exists is too old to
+// hand out one more time (STALE-PAST-CEILING) — either way this caller
+// (or whichever caller's sweep is already in flight) waits for a real
+// sweep and gets ITS error if the sweep fails. The ceiling is what keeps
+// a persistent bucket outage visible: without it, stale-while-revalidate
+// alone would let LoadMetrics return stale data with a nil error
+// forever regardless of how long the store stays down, because a
+// background sweep's failure is only logged (see startBackgroundSweep)
+// — crossing the ceiling forces the next caller back onto the path that
+// actually surfaces the error. Blocking here now costs on the order of
+// a second for a fleet this size rather than the several seconds it
+// cost before sweepBox parallelised its GETs, which is what makes
+// paying that cost here — instead of serving indefinitely-stale data —
+// an acceptable trade.
+//
+// Two properties matter beyond the cache states themselves, both learned
+// from the shape this replaced (mutex held across the whole sweep, on the
+// request's own context):
//
// - The cache mutex is never held across bucket IO. Only the goroutine that
// actually sweeps is slow; everyone else either takes the cached answer
-// immediately or waits on the in-flight sweep's channel, and neither
-// blocks a caller whose cache is fresh. handleApp calls this on every
-// tenant's app page, so a cold sweep must not become a queue every page
-// on the console stands in.
-// - The sweep runs on its own context — context.WithoutCancel of the
-// caller's, plus metricsSweepTimeout — never the request's. A request
-// cancelled mid-sweep (a reload, a navigate-away) would otherwise abort
-// the shared sweep AND, since a failed sweep is never cached, leave the
-// next request to start from scratch: reload impatiently enough and the
-// cache never populates at all. The caller's own context still governs
-// WAITING (a cancelled waiter gives up rather than hanging), it just no
-// longer governs the sweep.
+// immediately or waits on the in-flight sweep's channel (only the
+// blocking case waits at all), and neither blocks a caller whose cache
+// is fresh or stale-but-servable. handleApp calls this on every tenant's
+// app page, so a cold sweep must not become a queue every page on the
+// console stands in.
+// - Every sweep — blocking or background — runs on a context detached from
+// any particular caller's: context.WithoutCancel of whatever was handed
+// in (context.Background() for the background case, since there is no
+// caller to derive from), plus metricsSweepTimeout. A request cancelled
+// mid-sweep (a reload, a navigate-away) would otherwise abort a sweep
+// other callers are waiting on AND, since a failed blocking sweep is
+// never cached, leave the next request to start from scratch: reload
+// impatiently enough and the cache never populates at all. The caller's
+// own context still governs WAITING in the blocking case (a cancelled
+// waiter gives up rather than hanging), it just never governs the sweep
+// itself.
func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) {
c := &s.metricsCache
for {
c.mu.Lock()
if !c.at.IsZero() && time.Since(c.at) < metricsCacheTTL {
+ // FRESH.
+ boxes, alerts := c.boxes, c.alerts
+ c.mu.Unlock()
+ return boxes, alerts, nil
+ }
+ if !c.at.IsZero() && time.Since(c.at) <= metricsStaleServeCeiling {
+ // STALE-WITHIN-CEILING: hand back what we have right now — it
+ // is still a real sweep's result, just older than
+ // metricsCacheTTL — and let a refresh happen off to the side
+ // rather than making this caller pay for it. If one is already
+ // running (background OR, in principle, another blocking
+ // caller's), don't start a second; this caller just doesn't
+ // wait for it either way.
boxes, alerts := c.boxes, c.alerts
+ if !c.sweeping {
+ s.startBackgroundSweep(c)
+ }
c.mu.Unlock()
return boxes, alerts, nil
}
+ // COLD, or STALE-PAST-THE-CEILING: no servable stale answer exists
+ // (COLD) or the one that does is too old to hand out again
+ // (past metricsStaleServeCeiling) — this caller must wait for real
+ // data and see its error if the sweep fails, exactly as the
+ // original blocking behaviour did.
if c.sweeping {
// Someone is already at the bucket: wait for their result
// rather than duplicating the sweep, then re-check (their
@@ -193,6 +330,59 @@ func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow,
}
}
+// startBackgroundSweep launches sweepAll on its own goroutine to refresh a
+// cache that has swept successfully at least once and is still within
+// metricsStaleServeCeiling, without making any caller wait on it — see
+// LoadMetrics's STALE-WITHIN-CEILING case. "Swept successfully at least
+// once" is exactly c.at being set: an empty-bucket console (c.at set,
+// c.boxes nil) takes this path just like a populated one, since nil boxes
+// is itself a complete, valid swept result, not an absent one. Must be
+// called with c.mu held: it sets c.sweeping and installs c.done itself, the
+// very same fields the blocking path uses, so a caller arriving while this
+// is in flight (whether via the STALE-WITHIN-CEILING branch or the blocking
+// one) coalesces onto it instead of starting a second sweep. sweepAll is
+// handed context.Background() rather than any request's
+// context because there IS no request behind this goroutine — it already
+// detaches internally via context.WithoutCancel and bounds itself with
+// metricsSweepTimeout, so Background() costs nothing extra.
+//
+// Unlike the blocking path, this goroutine has no net/http per-connection
+// recover sitting above it — an unrecovered panic here would take the whole
+// process down, not just one request — so it recovers explicitly. A failed
+// or panicked background sweep is only logged: no caller is waiting on its
+// error, and the stale cache it leaves untouched is still valid data.
+func (s *Server) startBackgroundSweep(c *metricsCache) {
+ c.sweeping = true
+ c.done = make(chan struct{})
+ done := c.done
+ go func() {
+ var (
+ boxes []BoxMetricsView
+ alerts []AlertRow
+ err = errors.New("console: background metrics sweep did not complete")
+ )
+ defer func() {
+ rec := recover()
+ c.mu.Lock()
+ if err == nil {
+ c.at = time.Now()
+ c.boxes = boxes
+ c.alerts = alerts
+ }
+ c.sweeping = false
+ close(done)
+ c.mu.Unlock()
+ switch {
+ case rec != nil:
+ log.Printf("console: background metrics sweep panic, cache left at its last good sweep: %v", rec)
+ case err != nil:
+ log.Printf("console: background metrics sweep: %v", err)
+ }
+ }()
+ boxes, alerts, err = s.sweepAll(context.Background())
+ }()
+}
+
// sweepAll is the bucket half of LoadMetrics: discovery, every box's 48h raw
// sweep, then the alerts pass, on a context detached from the caller's
// (WithoutCancel) and bounded by metricsSweepTimeout. It holds no lock — see
@@ -253,24 +443,54 @@ func (s *Server) discoverBoxes(ctx context.Context) ([]string, error) {
// is skipped silently — a box that has been up for under 48h, or whose
// agent hiccuped on one hour, still renders on what it does have. Any other
// read error fails the whole sweep (and so is never cached).
+//
+// The 48 GETs run with up to metricsSweepBoxConcurrency in flight
+// (errgroup.WithContext + SetLimit) instead of one after another — see
+// metricsSweepBoxConcurrency's own comment for the sequential cost this
+// replaces. Each hour's samples land in their own slot of a slice indexed by
+// that hour's position (perHour), never appended to a shared slice, so the
+// collection itself needs no mutex; which GET happens to finish first
+// doesn't matter because the flattened result is sorted by sample time right
+// after, exactly as it was before parallelising. errgroup surfaces the
+// FIRST hard error and cancels the rest via its derived context — an
+// os.ErrNotExist or an unparsable chunk is swallowed inside the goroutine
+// (returned as nil, same "skip silently" contract as before) and never
+// reaches that path.
func (s *Server) sweepBox(ctx context.Context, box string) (BoxMetricsView, error) {
now := time.Now().UTC()
- var samples []metrics.Sample
- for i := 0; i < metricsSweepHours; i++ {
- hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
- key := metrics.RawKey(box, hour)
- body, _, err := s.cfg.Releases.Get(ctx, key)
- if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- continue
+ hours := make([]string, metricsSweepHours)
+ for i := range hours {
+ hours[i] = metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
+ }
+
+ perHour := make([][]metrics.Sample, metricsSweepHours)
+ g, gctx := errgroup.WithContext(ctx)
+ g.SetLimit(metricsSweepBoxConcurrency)
+ for i, hour := range hours {
+ g.Go(func() error {
+ key := metrics.RawKey(box, hour)
+ body, _, err := s.cfg.Releases.Get(gctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read %s: %w", key, err)
}
- return BoxMetricsView{}, fmt.Errorf("read %s: %w", key, err)
- }
- var chunk metrics.RawChunk
- if err := json.Unmarshal(body, &chunk); err != nil {
- continue // unparsable chunk: skip, don't fail the sweep
- }
- samples = append(samples, chunk.Samples...)
+ var chunk metrics.RawChunk
+ if err := json.Unmarshal(body, &chunk); err != nil {
+ return nil // unparsable chunk: skip, don't fail the sweep
+ }
+ perHour[i] = chunk.Samples
+ return nil
+ })
+ }
+ if err := g.Wait(); err != nil {
+ return BoxMetricsView{}, err
+ }
+
+ var samples []metrics.Sample
+ for _, hourSamples := range perHour {
+ samples = append(samples, hourSamples...)
}
sort.Slice(samples, func(i, j int) bool { return samples[i].At.Before(samples[j].At) })
return buildBoxView(box, samples), nil
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go
index d54d4213c..27370f67b 100644
--- a/internal/console/metricsview_test.go
+++ b/internal/console/metricsview_test.go
@@ -3,10 +3,12 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"path/filepath"
"strconv"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
@@ -302,6 +304,694 @@ func TestLoadMetricsConcurrentColdCallers(t *testing.T) {
}
}
+// writeBoxSample (re)publishes box's current-hour raw chunk with a single
+// host CPU reading — metrics.WriteRaw's own "replace whatever is there"
+// semantics (see chunk.go) let a later call simulate the agent's next flush
+// landing with a different reading, at the same key.
+func writeBoxSample(t *testing.T, rel release.Store, box string, cpu float64) {
+ t.Helper()
+ at := time.Now().UTC().Add(-time.Minute)
+ if err := metrics.WriteRaw(context.Background(), rel, metrics.RawChunk{
+ V: 1, Box: box, Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{At: at, Host: metrics.HostSample{CPUFrac: p(cpu)}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// slowCountingStore wraps a release.Store, counting List/Get calls and
+// optionally sleeping getDelay before each Get. It is the fixture the
+// stale-while-revalidate tests use to (a) prove a background sweep never
+// makes its trigger's caller wait for it and (b) count how many sweeps
+// actually ran.
+type slowCountingStore struct {
+ release.Store
+ getDelay time.Duration
+ lists atomic.Int64
+ gets atomic.Int64
+ // listGate, when non-nil, is received from (blocking) before every List
+ // call proceeds to the underlying store. discoverBoxes's List is the
+ // very FIRST store operation any sweep makes (before any Get), so
+ // gating it deterministically freezes a background sweep before it
+ // touches the store at all — letting a test assert "the synchronous
+ // caller performed zero store reads" without racing against how fast
+ // the background goroutine happens to get scheduled. Set only from the
+ // test goroutine strictly before the call that triggers the sweep, so
+ // there is a happens-before edge (via the goroutine-creation rule) to
+ // whichever goroutine reads it — never written concurrently with a read.
+ // A CLOSED gate never blocks again (a receive on a closed channel
+ // returns immediately), so once released it stays released for any
+ // later List call in the same test, gated or not.
+ listGate chan struct{}
+ // listErr, when set, is what every List call returns (after clearing
+ // listGate, if any) instead of touching the underlying store — the
+ // fixture for simulating an outage that starts exactly when a gated
+ // sweep is released, so both that sweep AND whatever sweep a coalesced
+ // waiter runs on its own afterward see the same failure.
+ listErr error
+}
+
+func (s *slowCountingStore) List(ctx context.Context, prefix string) ([]string, error) {
+ s.lists.Add(1)
+ if s.listGate != nil {
+ <-s.listGate
+ }
+ if s.listErr != nil {
+ return nil, s.listErr
+ }
+ return s.Store.List(ctx, prefix)
+}
+
+func (s *slowCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.gets.Add(1)
+ if s.getDelay > 0 {
+ time.Sleep(s.getDelay)
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// newSlowMetricsFixture is newMetricsFixture with the server reading through
+// a slowCountingStore, for tests that need to observe how many sweeps ran or
+// need a sweep slow enough to prove a caller didn't wait on it.
+func newSlowMetricsFixture(t *testing.T, getDelay time.Duration) (*Server, *slowCountingStore) {
+ t.Helper()
+ st, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ dir := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
+ slow := &slowCountingStore{Store: dir, getDelay: getDelay}
+ srv, err := NewServer(Config{Releases: slow}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return srv, slow
+}
+
+// waitUntilNotSweeping polls the cache's sweeping flag directly (same
+// package, no exported seam needed) until a background sweep finishes or
+// the deadline passes.
+func waitUntilNotSweeping(t *testing.T, srv *Server, timeout time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for {
+ srv.metricsCache.mu.Lock()
+ sweeping := srv.metricsCache.sweeping
+ srv.metricsCache.mu.Unlock()
+ if !sweeping {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("background sweep did not finish within %v", timeout)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+// metricsStaleTestBackdate is how far the tests below wind c.at back to
+// simulate an expired-but-servable cache: comfortably past metricsCacheTTL
+// (60s) so the cache is definitely STALE, and comfortably short of
+// metricsStaleServeCeiling (2min) so it's just as definitely still within
+// serving range — 30s of margin on each side, generous enough that neither
+// boundary is at risk from scheduling jitter.
+const metricsStaleTestBackdate = metricsCacheTTL + 30*time.Second
+
+// TestLoadMetricsStaleWhileRevalidate is fix B's core behaviour change: an
+// expired-but-still-within-ceiling cache is no longer a reason to make the
+// caller wait. The first call after expiry gets the OLD data back
+// immediately — proven by a zero Get-count delta on the store, not a wall-
+// clock guess — while a sweep runs in the background; once that sweep
+// lands, a LATER call sees the NEW data, still without ever blocking.
+func TestLoadMetricsStaleWhileRevalidate(t *testing.T) {
+ const getDelay = 80 * time.Millisecond
+ srv, store := newSlowMetricsFixture(t, getDelay)
+ ctx := context.Background()
+
+ writeBoxSample(t, store, "box-a", 0.5)
+ boxes, _, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("initial cold sweep = %+v, want box-a at 0.5", boxes)
+ }
+
+ // Expire the cache (within the ceiling) and change what the store
+ // holds. From here on a FRESH sweep would see 0.9 — the immediate call
+ // below must still answer with the cache's last-known 0.5, proving it
+ // didn't wait for a fresh read.
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+ writeBoxSample(t, store, "box-a", 0.9)
+
+ // Freeze the background sweep this call is about to launch at its very
+ // first store operation (discoverBoxes's List, before any Get) — this
+ // makes the zero-Gets assertion below deterministic rather than a race
+ // against the background goroutine's scheduling.
+ store.listGate = make(chan struct{})
+ getsBefore := store.gets.Load()
+ boxes, _, err = srv.LoadMetrics(ctx)
+ getsAfter := store.gets.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("stale call = %+v, want the OLD 0.5 (a background refresh must not be awaited)", boxes)
+ }
+ if getsAfter != getsBefore {
+ t.Fatalf("stale call performed %d store Gets, want 0 — it must return the cache without touching the store at all", getsAfter-getsBefore)
+ }
+ close(store.listGate) // release the frozen background sweep to finish
+
+ waitUntilNotSweeping(t, srv, 5*time.Second)
+
+ boxes, _, err = srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.9 {
+ t.Fatalf("after the background sweep landed = %+v, want the NEW 0.9", boxes)
+ }
+}
+
+// TestLoadMetricsStaleCacheSingleFlight: several callers arriving on an
+// EXPIRED-BUT-WITHIN-CEILING (not cold) cache at once must trigger exactly
+// one background sweep between them, never one each and never zero. The
+// baseline — however many List calls one full sweepAll makes (discoverBoxes
+// plus the alerts pass) — is measured from the initial cold sweep rather
+// than hard-coded, so this test doesn't have to know sweepAll's internals to
+// prove "exactly one more sweep happened."
+func TestLoadMetricsStaleCacheSingleFlight(t *testing.T) {
+ srv, store := newSlowMetricsFixture(t, 30*time.Millisecond)
+ ctx := context.Background()
+ writeBoxSample(t, store, "box-a", 0.5)
+
+ // Measured as a delta across just this call (not the cumulative total),
+ // so any List traffic from fixture/server construction before this
+ // point doesn't get folded into the "one sweep" baseline below.
+ beforeCold := store.lists.Load()
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+ oneSweepLists := store.lists.Load() - beforeCold // however many List calls one sweepAll makes
+
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+ before := store.lists.Load()
+
+ const callers = 8
+ var wg sync.WaitGroup
+ for i := 0; i < callers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wg.Wait()
+
+ waitUntilNotSweeping(t, srv, 5*time.Second)
+
+ if got := store.lists.Load() - before; got != oneSweepLists {
+ t.Fatalf("List calls triggered by %d concurrent callers on a stale cache = %d, want exactly %d (one sweep, shared)", callers, got, oneSweepLists)
+ }
+}
+
+// panicListStore panics on every List call — the fixture for proving a
+// panicking BACKGROUND sweep is recovered (startBackgroundSweep's explicit
+// recover — a detached goroutine has no net/http per-connection recover
+// sitting above it the way a request's own goroutine does) rather than
+// taking the whole process down, and that the cache is left usable
+// afterward rather than wedged.
+type panicListStore struct {
+ release.Store
+}
+
+func (panicListStore) List(context.Context, string) ([]string, error) {
+ panic("simulated bucket panic")
+}
+
+// TestLoadMetricsBackgroundSweepPanicRecovers: a background sweep
+// (triggered by a stale-but-within-ceiling cache) that panics must not crash
+// the test process, must leave the stale cache intact for its trigger's
+// caller (no error), must clear sweeping so nothing is wedged, and must not
+// prevent a later, healthy sweep from succeeding.
+func TestLoadMetricsBackgroundSweepPanicRecovers(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ writeBoxSample(t, rel, "box-a", 0.5)
+
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+ srv.cfg.Releases = panicListStore{Store: rel}
+
+ boxes, _, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatalf("stale call with a panicking background sweep returned an error: %v", err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("stale call = %+v, want the OLD cached data despite the panic", boxes)
+ }
+
+ waitUntilNotSweeping(t, srv, 5*time.Second)
+
+ srv.metricsCache.mu.Lock()
+ sweeping := srv.metricsCache.sweeping
+ srv.metricsCache.mu.Unlock()
+ if sweeping {
+ t.Fatal("cache left wedged (sweeping still true) after a panicking background sweep")
+ }
+
+ // Nothing is permanently wedged: restore a healthy store and force the
+ // COLD path (c.at zeroed) for a clean, unambiguous assertion that a
+ // later sweep still works.
+ srv.cfg.Releases = rel
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Time{}
+ srv.metricsCache.mu.Unlock()
+
+ boxes, _, err = srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatalf("sweep after a prior background panic failed: %v", err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("recovered sweep = %+v, want box-a at 0.5", boxes)
+ }
+}
+
+// errListStore fails every List call with a fixed error — the fixture for
+// proving a background sweep's failure is only logged while its trigger's
+// caller sees no error and the stale cache survives (within the ceiling),
+// and that the SAME failure surfaces to a blocking caller once the cache
+// ages past metricsStaleServeCeiling — restoring outage visibility rather
+// than serving indefinitely-stale data with no error forever.
+type errListStore struct {
+ release.Store
+ err error
+}
+
+func (e errListStore) List(context.Context, string) ([]string, error) {
+ return nil, e.err
+}
+
+// TestLoadMetricsBackgroundSweepFailureThenCeilingSurfacesError covers both
+// halves of that contract against the one failing store.
+func TestLoadMetricsBackgroundSweepFailureThenCeilingSurfacesError(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ writeBoxSample(t, rel, "box-a", 0.5)
+
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ wantErr := errors.New("bucket had a bad day")
+ srv.cfg.Releases = errListStore{Store: rel, err: wantErr}
+
+ // Within the ceiling: the stale cache survives, the failing background
+ // sweep is only logged, and this caller sees no error at all.
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+
+ boxes, _, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatalf("stale call with a failing background sweep returned an error: %v", err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("stale call = %+v, want the OLD cached data", boxes)
+ }
+
+ waitUntilNotSweeping(t, srv, 5*time.Second)
+ srv.metricsCache.mu.Lock()
+ sweeping := srv.metricsCache.sweeping
+ srv.metricsCache.mu.Unlock()
+ if sweeping {
+ t.Fatal("cache left wedged after a failing background sweep")
+ }
+
+ // Past the ceiling: the SAME failing store must now surface its error
+ // to a blocking caller instead of handing back stale data forever.
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second))
+ srv.metricsCache.mu.Unlock()
+
+ if _, _, err := srv.LoadMetrics(ctx); err == nil {
+ t.Fatal("call past the ceiling with a failing store returned no error")
+ } else if !errors.Is(err, wantErr) {
+ t.Fatalf("error = %v, want it to wrap %v", err, wantErr)
+ }
+}
+
+// TestLoadMetricsPastCeilingBlocksForFreshData: a cache older than
+// metricsStaleServeCeiling is no longer eligible for stale-while-revalidate
+// even with a perfectly healthy store — the caller blocks on a synchronous
+// sweep and gets genuinely fresh data, proven by a non-zero Get-count delta
+// (the sweep ran INSIDE this call, not off to the side) rather than a
+// timing guess.
+func TestLoadMetricsPastCeilingBlocksForFreshData(t *testing.T) {
+ srv, store := newSlowMetricsFixture(t, 0)
+ ctx := context.Background()
+ writeBoxSample(t, store, "box-a", 0.5)
+
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ writeBoxSample(t, store, "box-a", 0.9)
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second))
+ srv.metricsCache.mu.Unlock()
+
+ getsBefore := store.gets.Load()
+ boxes, _, err := srv.LoadMetrics(ctx)
+ getsAfter := store.gets.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if getsAfter == getsBefore {
+ t.Fatal("call past the ceiling performed no store Gets — it must sweep synchronously, not serve stale data")
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.9 {
+ t.Fatalf("boxes = %+v, want the FRESH 0.9 — this call must have swept, not served the stale 0.5", boxes)
+ }
+}
+
+// TestLoadMetricsPastCeilingWaiterCoalescesOntoBackgroundSweep pins the one
+// state-machine transition the other tests only exercise as a side effect:
+// a caller landing in the blocking COLD-or-STALE-PAST-CEILING branch while a
+// BACKGROUND sweep (started earlier by a within-ceiling stale caller) is
+// already in flight. LoadMetrics's blocking branch waits on c.sweeping/
+// c.done regardless of which kind of sweep set them — this proves that
+// holds for a background sweep specifically, not just another blocking
+// caller's, by gating the background sweep at its very first store call so
+// the two callers' timing is deterministic rather than a race.
+func TestLoadMetricsPastCeilingWaiterCoalescesOntoBackgroundSweep(t *testing.T) {
+ srv, store := newSlowMetricsFixture(t, 0)
+ ctx := context.Background()
+ writeBoxSample(t, store, "box-a", 0.5)
+
+ // Baseline: however many List calls one full sweepAll makes, measured
+ // the same way the single-flight test does.
+ beforeCold := store.lists.Load()
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+ oneSweepLists := store.lists.Load() - beforeCold
+
+ listsBeforeTrigger := store.lists.Load()
+
+ // Gate the NEXT sweep at its first store call, then trigger it via a
+ // within-ceiling stale call — this is startBackgroundSweep's path, not
+ // the blocking one.
+ store.listGate = make(chan struct{})
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+ writeBoxSample(t, store, "box-a", 0.9) // what the gated sweep will see once released
+
+ if boxes, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ } else if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("triggering call = %+v, want the OLD 0.5 (background sweep just launched, now gated)", boxes)
+ }
+
+ // Age the SAME cache past the ceiling while the background sweep is
+ // still stuck at the gate — exactly the state the waiter below lands in.
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second))
+ srv.metricsCache.mu.Unlock()
+
+ waiterDone := make(chan struct{})
+ var waiterBoxes []BoxMetricsView
+ var waiterErr error
+ go func() {
+ defer close(waiterDone)
+ waiterBoxes, _, waiterErr = srv.LoadMetrics(ctx)
+ }()
+
+ select {
+ case <-waiterDone:
+ t.Fatal("past-ceiling waiter returned before the gated background sweep was released")
+ case <-time.After(200 * time.Millisecond):
+ // still blocked, as it must be — coalesced onto the background
+ // sweep's c.done rather than racing off to start its own.
+ }
+
+ close(store.listGate)
+
+ select {
+ case <-waiterDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("past-ceiling waiter never returned after the gate was released")
+ }
+ if waiterErr != nil {
+ t.Fatalf("waiter error = %v, want nil", waiterErr)
+ }
+ if len(waiterBoxes) != 1 || waiterBoxes[0].CPU == nil || *waiterBoxes[0].CPU != 0.9 {
+ t.Fatalf("waiter boxes = %+v, want the FRESH 0.9 the background sweep it coalesced onto produced", waiterBoxes)
+ }
+ if got := store.lists.Load() - listsBeforeTrigger; got != oneSweepLists {
+ t.Fatalf("List calls across the whole gated-sweep-plus-waiter span = %d, want exactly %d (one sweep, not duplicated by the waiter)", got, oneSweepLists)
+ }
+ srv.metricsCache.mu.Lock()
+ sweeping := srv.metricsCache.sweeping
+ srv.metricsCache.mu.Unlock()
+ if sweeping {
+ t.Fatal("cache left wedged (sweeping still true) after the waiter returned")
+ }
+}
+
+// TestLoadMetricsPastCeilingWaiterTakesOverFailedBackgroundSweep is the
+// failure half of the same coalescing transition: the background sweep the
+// waiter coalesced onto fails (rather than lands), so the cache stays past
+// the ceiling — the waiter must then take the sweep over ITSELF (the
+// c.sweeping check it started blocked on now reads false) rather than
+// staying blocked or silently giving up, and surface that second attempt's
+// error like any ordinary blocking caller would. Nothing may wedge: the
+// flag clears either way, and a later call against a healthy store must
+// still succeed.
+func TestLoadMetricsPastCeilingWaiterTakesOverFailedBackgroundSweep(t *testing.T) {
+ srv, store := newSlowMetricsFixture(t, 0)
+ ctx := context.Background()
+ writeBoxSample(t, store, "box-a", 0.5)
+
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ store.listGate = make(chan struct{})
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate)
+ srv.metricsCache.mu.Unlock()
+
+ if _, _, err := srv.LoadMetrics(ctx); err != nil {
+ t.Fatal(err)
+ } // stale 0.5 returned, background sweep launched and now gated
+
+ srv.metricsCache.mu.Lock()
+ srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second))
+ srv.metricsCache.mu.Unlock()
+
+ waiterDone := make(chan struct{})
+ var waiterErr error
+ go func() {
+ defer close(waiterDone)
+ _, _, waiterErr = srv.LoadMetrics(ctx)
+ }()
+
+ select {
+ case <-waiterDone:
+ t.Fatal("past-ceiling waiter returned before the gated background sweep was released")
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ // The outage starts exactly when the gate opens: the background
+ // sweep's blocked List call, and any later List call (including the
+ // waiter's own follow-up sweep), all see the same error from here on.
+ wantErr := errors.New("bucket had a bad day")
+ store.listErr = wantErr
+ close(store.listGate)
+
+ select {
+ case <-waiterDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("past-ceiling waiter never returned after the gate was released")
+ }
+ if waiterErr == nil {
+ t.Fatal("waiter that took over a failed background sweep returned no error")
+ }
+ if !errors.Is(waiterErr, wantErr) {
+ t.Fatalf("waiter error = %v, want it to wrap %v", waiterErr, wantErr)
+ }
+
+ waitUntilNotSweeping(t, srv, 5*time.Second)
+
+ // Nothing is permanently wedged: clear the outage and confirm a later
+ // call against the same (now healthy) store still succeeds.
+ store.listErr = nil
+ boxes, _, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatalf("sweep after the prior failures still failed: %v", err)
+ }
+ if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 {
+ t.Fatalf("recovered sweep = %+v, want box-a at 0.5", boxes)
+ }
+}
+
+// concurrencyProbeStore counts how many Get calls are in flight at once —
+// the fixture proving sweepBox's parallel GETs are actually bounded at
+// metricsSweepBoxConcurrency: neither serial (the cost fix B removes) nor
+// unbounded (the thing errgroup.SetLimit exists to prevent).
+type concurrencyProbeStore struct {
+ release.Store
+ mu sync.Mutex
+ cur, max int
+}
+
+func (c *concurrencyProbeStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.mu.Lock()
+ c.cur++
+ if c.cur > c.max {
+ c.max = c.cur
+ }
+ c.mu.Unlock()
+ time.Sleep(5 * time.Millisecond) // hold the "in flight" window open long enough to overlap
+ body, etag, err := c.Store.Get(ctx, key)
+ c.mu.Lock()
+ c.cur--
+ c.mu.Unlock()
+ return body, etag, err
+}
+
+// TestSweepBoxBoundsConcurrency: a box with all 48 hours present sweeps with
+// more than one GET in flight at once (the point of fix B) but never more
+// than metricsSweepBoxConcurrency (the point of SetLimit).
+func TestSweepBoxBoundsConcurrency(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+ for i := 0; i < metricsSweepHours; i++ {
+ at := now.Add(-time.Duration(i) * time.Hour)
+ if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{
+ V: 1, Box: "box-conc", Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{At: at, Host: metrics.HostSample{CPUFrac: p(0.1)}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ probe := &concurrencyProbeStore{Store: rel}
+ srv.cfg.Releases = probe
+
+ if _, err := srv.sweepBox(ctx, "box-conc"); err != nil {
+ t.Fatal(err)
+ }
+ probe.mu.Lock()
+ max := probe.max
+ probe.mu.Unlock()
+ if max <= 1 {
+ t.Fatalf("max concurrent Gets = %d, want > 1 (the parallelization fix B adds)", max)
+ }
+ if max > metricsSweepBoxConcurrency {
+ t.Fatalf("max concurrent Gets = %d, want <= metricsSweepBoxConcurrency (%d)", max, metricsSweepBoxConcurrency)
+ }
+}
+
+// TestSweepBoxParallelGetsPreserveSemantics: mixing present, missing
+// (os.ErrNotExist), and corrupt-JSON hours through the now-parallel GETs
+// must yield exactly the same sample set the old sequential version did —
+// fix B changes the sweep's speed, not its behaviour.
+func TestSweepBoxParallelGetsPreserveSemantics(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ presentAt := now.Add(-2 * time.Hour)
+ if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{
+ V: 1, Box: "box-mix", Hour: metrics.HourOf(presentAt),
+ Samples: []metrics.Sample{{At: presentAt, Host: metrics.HostSample{CPUFrac: p(0.31)}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // A corrupt hour: an object exists at the key, but it isn't a RawChunk.
+ corruptAt := now.Add(-10 * time.Hour)
+ if _, err := rel.Put(ctx, metrics.RawKey("box-mix", metrics.HourOf(corruptAt)), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ // Every other one of the 48 hours is left missing (os.ErrNotExist).
+
+ view, err := srv.sweepBox(ctx, "box-mix")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view.CPU == nil || *view.CPU != 0.31 {
+ t.Fatalf("CPU = %v, want 0.31 (the only readable sample; missing/corrupt hours skipped)", view.CPU)
+ }
+ if !view.At.Equal(presentAt) {
+ t.Fatalf("At = %v, want %v", view.At, presentAt)
+ }
+}
+
+// errKeyStore fails Get for exactly one key with a non-NotExist error and
+// passes everything else through — proves sweepBox's parallel GETs still
+// fail the WHOLE sweep on a hard error, exactly like the sequential version
+// did, rather than silently skipping it like a missing or corrupt hour.
+type errKeyStore struct {
+ release.Store
+ failKey string
+ err error
+}
+
+func (e errKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == e.failKey {
+ return nil, "", e.err
+ }
+ return e.Store.Get(ctx, key)
+}
+
+// TestSweepBoxParallelGetsFailOnHardError pins the "any other error fails
+// the whole sweep" contract under parallel GETs: one hour returning a real
+// error (not os.ErrNotExist) must fail sweepBox and surface that error,
+// even though 47 other GETs are in flight or already succeeded.
+func TestSweepBoxParallelGetsFailOnHardError(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ goodAt := now.Add(-time.Hour)
+ if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{
+ V: 1, Box: "box-fail", Hour: metrics.HourOf(goodAt),
+ Samples: []metrics.Sample{{At: goodAt, Host: metrics.HostSample{CPUFrac: p(0.5)}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ failAt := now.Add(-20 * time.Hour)
+ wantErr := errors.New("bucket had a bad day")
+ srv.cfg.Releases = errKeyStore{
+ Store: rel,
+ failKey: metrics.RawKey("box-fail", metrics.HourOf(failAt)),
+ err: wantErr,
+ }
+
+ if _, err := srv.sweepBox(ctx, "box-fail"); err == nil {
+ t.Fatal("sweepBox with a hard Get error on one hour returned no error")
+ } else if !errors.Is(err, wantErr) {
+ t.Fatalf("sweepBox error = %v, want it to wrap %v", err, wantErr)
+ }
+}
+
// TestOwnerAppMetricsEmptyHosts: an app with no routes asks for nothing and
// gets nothing — empty, no error, and (proven by the poisoned-free fixture
// having no boxes at all) never an error path the app page has to handle.
@@ -394,6 +1084,63 @@ func TestOwnerAppMetricsFiltersToOwnUnits(t *testing.T) {
}
}
+// TestOwnerAppMetricsSurvivesHealthyFlushCycle pins metricsFreshWindow's
+// derivation (flush cadence floor 5min + stale-serve ceiling 2min + 1min
+// slack = 8 minutes): a healthy agent's newest VISIBLE sample can be as old
+// as flush-cadence-plus-ceiling and still be current, but a sample old
+// enough that NOTHING short of a dead agent explains it must still flap to
+// no-recent-readings. Three ages, three different hosts/boxes (so the
+// "freshest box wins" rule in OwnerAppMetrics can't let one case's fresh
+// reading paper over another's staleness):
+//
+// - 5m30s: the original healthy-worst-case under the OLD (5min-flush-only)
+// math — comfortably inside both the old and new window.
+// - 7m0s: the NEW healthy worst case this fix widens the window to cover
+// — flush cadence's floor (5min) plus the full stale-serve ceiling
+// (2min) a caller's cache may be behind by.
+// - 8m30s: comfortably past the 8-minute window regardless of derivation
+// — must NOT read as current.
+func TestOwnerAppMetricsSurvivesHealthyFlushCycle(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+
+ write := func(box, host string, age time.Duration) {
+ at := time.Now().UTC().Add(-age)
+ if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{
+ V: 1, Box: box, Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{
+ At: at,
+ Units: map[string]metrics.UnitSample{
+ "carlos-app@" + host + ".service": {CPUFrac: p(0.42), MemBytes: pi64(2048)},
+ },
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("box-healthy-1", "flush1.example.com", 5*time.Minute+30*time.Second)
+ write("box-healthy-2", "flush2.example.com", 7*time.Minute)
+ write("box-dead", "dead.example.com", 8*time.Minute+30*time.Second)
+
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ byHost := map[string]InstanceMetrics{}
+ for _, r := range rows {
+ byHost[r.Host] = r
+ }
+ if r := byHost["flush1.example.com"]; !r.Reporting() {
+ t.Fatalf("a 5m30s-old sample flapped to no-recent-readings: %+v", r)
+ }
+ if r := byHost["flush2.example.com"]; !r.Reporting() {
+ t.Fatalf("a 7m0s-old sample (flush floor + stale-serve ceiling) flapped to no-recent-readings: %+v", r)
+ }
+ if r := byHost["dead.example.com"]; r.Reporting() {
+ t.Fatalf("an 8m30s-old sample — past the fresh window — still read as current: %+v", r)
+ }
+}
+
// TestSparkPointsInvertsYAndSkipsNil pins the coordinate math the brief
// calls out explicitly: a nil sample contributes no pair at all (not a
// zero), and a higher fraction plots at a SMALLER y (SVG's y axis points
diff --git a/internal/console/server.go b/internal/console/server.go
index c31e827de..58aaed3ea 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -184,9 +184,11 @@ type Server struct {
// direct call so an alertPass assertion needs no keymail server.
sendApp func(ctx context.Context, token, to, subject, body string) (string, error)
// metricsCache holds the result of LoadMetrics's last successful bucket
- // sweep (metricsview.go), shared across callers for metricsCacheTTL so
- // the operator dashboard and every owner's app-metrics page don't each
- // re-sweep 48h of chunks per request.
+ // sweep (metricsview.go). Concurrent callers within metricsCacheTTL share
+ // it outright; callers arriving later still get it immediately (stale-
+ // while-revalidate, up to metricsStaleServeCeiling) so the operator
+ // dashboard and every owner's app-metrics page don't each re-sweep 48h of
+ // chunks per request.
metricsCache metricsCache
// shipSem bounds how many `carlos ship` uploads (apicli.go,
// handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which