mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-15 05:19:01 +00:00
a8c99c61fd
## 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>
331 lines
11 KiB
Go
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")
|
|
}
|
|
}
|