Files
meshcore-analyzer/cmd/server/analytics_warmup_1659_test.go
Kpa-clawbot a8c99c61fd fix(#1659): block analytics endpoint until first pass complete (503 Retry-After) (#1688)
## Summary

Fixes #1659 — analytics cards no longer show the post-restart slice when
"All data" is selected.

## Root cause

After server restart, `s.recompRF` / `s.recompTopology` /
`s.recompChannels` cache the FIRST computation, which is the small
in-RAM observations slice (background chunk-loader has not yet
backfilled history). The recomputer serves that slice through
`GetAnalyticsRFWithWindow`'s default shortcut for an entire recompute
interval, while the client pins it via `CLIENT_TTL.analyticsRF`. UX:
cards show a tiny window even when the user selects "All data".

## Fix shape (option B from the issue body)

Server-side per-recomputer warm-up gate:

- `cmd/server/analytics_warmup_1659.go` adds a per-recomputer
`firstPassDoneNs` atomic timestamp, set ONLY by the first successful
`runOnce()` (CAS-guarded for idempotency). `IsWarmingUp_1659()` /
`FirstPassDoneAt_1659()` are lock-free reads.
- `cmd/server/analytics_recomputer.go` `runOnce()` calls
`markFirstPassDone_1659()` after every successful compute.
- `cmd/server/routes.go` handlers for RF / Topology / Channels: when the
request is the default shape (`region=="" && area=="" &&
window.IsZero()`) AND the matching recomputer is still warming up,
return `503` + `Retry-After: 5` + `{"error":"analytics warming
up","retry_after_s":5}`. Windowed / region-filtered requests bypass the
gate (they already bypass the recomputer cache, so they are unaffected
by the warm-up bug).

Client-side:

- `public/app.js` `api()` helper retries any 503 response, honoring
`Retry-After`, with exponential backoff capped at 30s, max 6 attempts
(~63s total).
- Small "Computing analytics…" banner appears while any warm-up retry is
in flight, dismissed once the request resolves. Pages can override via
`window.onWarmup_1659`.

## Tests

RED commit `8b2b2d7` ships failing-on-assertion tests + a stub. GREEN
commit `2716c23` lands the fix and flips them green.

- `cmd/server/analytics_warmup_1659_test.go` — 3 cases: 503 during
warmup, 200 after first pass, windowed request bypasses gate.
- `test-1659-analytics-warmup.js` — 3 cases: Retry-After honored, retry
cap bounded, non-503 errors not retried. Wired into
`.github/workflows/deploy.yml`.

## Preflight overrides

- cross-stack: justified — server-side 503 contract MUST be paired with
client-side retry-and-banner handling; splitting across two PRs would
land a half-working fix.

Fixes #1659.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw <openclaw@local>
2026-06-12 21:02:59 +00:00

331 lines
11 KiB
Go

// Package main: issue #1659 — analytics warmup gating.
//
// After a server restart, the analytics recomputer caches the FIRST
// computation (a small in-RAM slice) and serves it via the default
// region="", zero-window shortcut in GetAnalyticsRFWithWindow until the
// next periodic recompute fires. The client-side CLIENT_TTL.analyticsRF
// then pins that small slice on the page even after the server flips
// to steady-state.
//
// Fix: each recomputer carries a firstPassDoneAt timestamp set ONLY
// after a full-range compute completes. While firstPassDoneAt is zero
// AND the request is the default-shape (region="" && area="" &&
// window.IsZero()), the handler returns 503 + Retry-After: 5 with a
// JSON body the client recognizes and retries with backoff.
//
// These tests are the RED contract: they must FAIL on the assertion
// (not a build error) when the warmup gate is absent, and PASS once
// the fix lands.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestAnalyticsRF_WarmupReturns503 asserts that immediately after the
// server starts — before any analytics recomputer has finished its
// first full-range pass — GET /api/analytics/rf returns 503 with
// Retry-After: 5 and a JSON body shaped as
// {"error":"analytics warming up","retry_after_s":5}.
//
// This is the core acceptance criterion (c) from #1659.
func TestAnalyticsRF_WarmupReturns503(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// Register recomputers but DO NOT let them complete a first pass.
// We install a compute func that blocks until we release it, so the
// recomputer's firstPassDoneAt stays zero.
block := make(chan struct{})
defer close(block)
store.installWarmupBlocker_1659(block) // helper added in GREEN
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/rf", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 during warmup, got %d (body=%s)", w.Code, w.Body.String())
}
if got := w.Header().Get("Retry-After"); got != "5" {
t.Fatalf("expected Retry-After: 5, got %q", got)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON body: %v (raw=%s)", err, w.Body.String())
}
if resp["error"] != "analytics warming up" {
t.Fatalf("expected error='analytics warming up', got %v", resp["error"])
}
if v, ok := resp["retry_after_s"].(float64); !ok || v != 5 {
t.Fatalf("expected retry_after_s=5, got %v", resp["retry_after_s"])
}
}
// TestAnalyticsRF_AfterFirstPassReturns200 asserts the post-warmup
// happy path: once the recomputer's first full-range compute completes,
// the handler serves the cached snapshot as 200.
func TestAnalyticsRF_AfterFirstPassReturns200(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// #1688 r1: the warmup gate now ALSO requires LoadComplete() to be
// true before first-pass-done flips (munger #5). Tests that don't
// exercise the chunked loader must flip it manually to model a
// production server that has finished cold-loading.
store.loadComplete.Store(true)
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
defer stop()
// Wait for the synchronous first-pass to complete. Start() runs
// the initial compute synchronously, so by the time it returns
// firstPassDoneAt should be set. We poll a brief moment to keep
// the test robust to scheduling.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if store.recompRF != nil && !store.recompRF.FirstPassDoneAt_1659().IsZero() {
break
}
time.Sleep(10 * time.Millisecond)
}
if store.recompRF == nil || store.recompRF.FirstPassDoneAt_1659().IsZero() {
t.Fatal("recompRF.firstPassDoneAt never flipped after Start()")
}
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/rf", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 after first pass, got %d (body=%s)", w.Code, w.Body.String())
}
if got := w.Header().Get("Retry-After"); got != "" {
t.Fatalf("expected no Retry-After header on 200, got %q", got)
}
// Body should be a valid JSON object (the RF analytics map).
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON body: %v", err)
}
if len(resp) == 0 {
t.Fatal("expected non-empty RF analytics response after first pass")
}
}
// TestAnalyticsRF_WindowedRequestNotGated asserts that even during
// warmup, a request with an explicit time window (?since=/?until=) or
// region/area filter is NOT gated by the warmup flag — those queries
// bypass the recomputer entirely and hit the legacy compute-then-cache
// path, which is unaffected by the first-pass bug.
func TestAnalyticsRF_WindowedRequestNotGated(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
block := make(chan struct{})
defer close(block)
store.installWarmupBlocker_1659(block)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
// Explicit window — should bypass warmup gate.
req := httptest.NewRequest("GET", "/api/analytics/rf?window=1h", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusServiceUnavailable {
t.Fatalf("windowed request must NOT be gated by warmup (got 503)")
}
}
// === PR #1688 r1 — new test cases ===
// TestAnalyticsTopology_WarmupReturns503 — kent-beck #1: topology
// gate is symmetric with RF; assert the same 503 contract.
func TestAnalyticsTopology_WarmupReturns503(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
block := make(chan struct{})
defer close(block)
store.installWarmupBlocker_1659(block)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/topology", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("topology: expected 503 during warmup, got %d", w.Code)
}
if got := w.Header().Get("Retry-After"); got != "5" {
t.Fatalf("topology: expected Retry-After: 5, got %q", got)
}
}
// TestAnalyticsChannels_WarmupReturns503 — kent-beck #1: channels
// gate is symmetric with RF; assert the same 503 contract.
func TestAnalyticsChannels_WarmupReturns503(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
block := make(chan struct{})
defer close(block)
store.installWarmupBlocker_1659(block)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("channels: expected 503 during warmup, got %d", w.Code)
}
if got := w.Header().Get("Retry-After"); got != "5" {
t.Fatalf("channels: expected Retry-After: 5, got %q", got)
}
}
// TestWarmup_GateBlockedUntilLoadComplete — munger #5 correctness:
// the chunked loader readiness MUST gate first-pass-done. A recomputer
// pass that completes while LoadComplete() is false must NOT lift the
// gate; a SUBSEQUENT pass after LoadComplete() flips true must lift it.
func TestWarmup_GateBlockedUntilLoadComplete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// LoadComplete starts false — chunked loader still running.
called := make(chan struct{}, 16)
rc := newAnalyticsRecomputer("test-rf", time.Hour, func() interface{} {
called <- struct{}{}
return map[string]int{"x": 1}
})
rc.setWarmupReadyGate_1659(store.LoadComplete)
rc.Start()
defer rc.Stop()
// First pass already ran synchronously in Start(). Gate must still
// be warming up because LoadComplete() is false.
<-called
if !rc.IsWarmingUp_1659() {
t.Fatalf("expected IsWarmingUp_1659=true while LoadComplete()=false (munger #5 bug)")
}
if !rc.FirstPassDoneAt_1659().IsZero() {
t.Fatalf("expected FirstPassDoneAt zero while LoadComplete()=false")
}
// Now flip the loader and trigger another pass.
store.loadComplete.Store(true)
rc.runOnce()
if rc.IsWarmingUp_1659() {
t.Fatalf("expected gate to lift after LoadComplete()=true + another pass")
}
}
// TestWarmup_NilResultStillLiftsGate — munger #2 / kent-beck #2:
// a compute that returns nil but doesn't panic must still flip the
// gate (the cache stays empty but the banner does NOT get stuck).
func TestWarmup_NilResultStillLiftsGate(t *testing.T) {
rc := newAnalyticsRecomputer("test-nil", time.Hour, func() interface{} {
return nil
})
rc.Start()
defer rc.Stop()
if rc.IsWarmingUp_1659() {
t.Fatalf("nil-result compute must still lift warmup gate after first pass")
}
}
// TestWarmup_PanicEventuallyLiftsGate — munger #2 / kent-beck #2:
// a compute that ALWAYS panics must not leave the gate stuck forever.
// The fallback timeout (warmupForceTimeout) is the safety net.
func TestWarmup_PanicEventuallyLiftsGate(t *testing.T) {
prev := warmupForceTimeout
warmupForceTimeout = 50 * time.Millisecond
defer func() { warmupForceTimeout = prev }()
rc := newAnalyticsRecomputer("test-panic", time.Hour, func() interface{} {
panic("compute boom")
})
rc.Start()
defer rc.Stop()
// Panic was recovered inside runOnce; firstPassDoneNs is still 0.
if rc.FirstPassDoneAt_1659().IsZero() == false {
t.Fatalf("panicking compute should not have set firstPassDoneNs")
}
// But after warmupForceTimeout elapses, the gate must lift.
time.Sleep(80 * time.Millisecond)
if rc.IsWarmingUp_1659() {
t.Fatalf("expected fallback timeout to lift gate after warmupForceTimeout (got still-warming)")
}
}
// TestWarmup_TimeoutLiftsHangingCompute — munger #2 / kent-beck #2:
// hung compute (blocks indefinitely on a channel) must not result in
// permanent 503. Fallback timeout lifts it.
func TestWarmup_TimeoutLiftsHangingCompute(t *testing.T) {
prev := warmupForceTimeout
warmupForceTimeout = 50 * time.Millisecond
defer func() { warmupForceTimeout = prev }()
block := make(chan struct{})
defer close(block)
rc := newAnalyticsRecomputer("test-hang", time.Hour, func() interface{} {
<-block
return nil
})
// Don't call Start (would block forever on synchronous initial
// compute). Just simulate "we noted warmup start, compute is
// hanging in another goroutine".
rc.noteWarmupStart_1659()
go rc.runOnce()
if !rc.IsWarmingUp_1659() {
t.Fatalf("expected initial state to be warming-up")
}
time.Sleep(80 * time.Millisecond)
if rc.IsWarmingUp_1659() {
t.Fatalf("expected fallback timeout to lift hung-compute warmup")
}
}