Files
meshcore-analyzer/cmd/server/analytics_warmup_1659.go
T
efitenandClaude Opus 5 2d4019f719 fix(analytics): recompute once the store has fully loaded (#2025)
Refs #2023, #1659, #1724

### Problem
`main.go:258` waits only for the first load chunk, then `main.go:402`
starts the analytics recomputers. `Start()` computes immediately on that
chunk (`analytics_recomputer.go:86` on master) and the next compute
waits a full interval (`:93`, 5 min default). The chunk loader walks by
ascending id, so that chunk holds the oldest transmissions.

- RF, topology, channels: the #1659 gate checked `LoadComplete()` after
the compute (`analytics_warmup_1659.go:122`). `LoadComplete` flips at
the end of the hot window (`chunked_load.go:489`), before the background
fill (`store.go:1455`), so the gate could open on a snapshot without the
background fill, and the 60 s force timeout (`:73`, `:162`) opened it on
the first-chunk snapshot. In an end-to-end test on master,
`/api/analytics/rf` returned 200 with 8 of 100 packets before the
background fill ran.
- Distance, hash-collisions, hash-sizes, roles, observers-clock-skew,
nodes-clock-skew: no gate, partial snapshot served from the start.
- Distance additionally served a snapshot from the previous index for up
to one interval after each lazy index build.

On a staging instance, a default analytics request returned 5,911
packets with hours-old last buckets until the next recompute (about
74k).

### Change
- `StartupLoadDone()` (`chunked_load.go:108`): closed when
`RunStartupLoad` returns, on every path (`chunked_load.go:202`). Closing
it drops the hash-size info cache (15 s TTL) and the clock-skew engine
throttle (30 s, `clock_skew.go:225`), both read by the post-load
computes.
- `recomputeWhenLoaded` (`analytics_recomputer.go:172`): on that signal,
recompute each recomputer once, sequentially, via `RecomputeNow`
(`:154`), which runs on the recomputer's own loop and restarts its
ticker (`:106`). Order (`:255`): rf, topology, channels, distance,
hash-collisions, hash-sizes, observers-clock-skew, nodes-clock-skew,
roles (roles reads the nodes-clock-skew snapshot). Logs one line with
per-recomputer durations.
- Warm-up gate: now the same signal (`:343-354`), sampled before the
compute starts (`:129`), so a pass that began on partial data never
opens it. 503 + `Retry-After: 5` and the force timeout are unchanged; a
forced-open snapshot is replaced by the post-load recompute.
- Ungated endpoints: no new 503s (their API has none); snapshot replaced
right after the load.
- Distance: the lazy index build refreshes the distance recomputer
before reporting built (`store.go:4476`).
- Recompute intervals and config unchanged.

### Performance
One extra compute per recomputer per process start, run sequentially so
they do not all hold the store read lock at once. Ticker phases
afterwards are offset by the cumulative post-load compute durations
instead of all starting within the first-chunk compute window (relevant
to #1724; the effect on lock waves is not measured).

### Tests
`analytics_recompute_after_load_test.go`: signal open during background
fill, closed after success and failure; cache drops; immediate and
ordered post-load recompute; gate not opened by a pass started before
the load; forced-open snapshot replaced on load; ticker restart;
distance refresh before 202 ends; end to end with recomputers started
before the background fill (RF 503 until load, then `totalTransmissions`
equals the full store; six ungated endpoints 200 during load; all nine
recomputed after load). 9 of these failed on master with stubs; 8
single-line mutations each caught. `go test ./...` in `cmd/server`
passes.

### Staging validation
Deployed together with the review follow-ups of #2015-#2023 (build
`c646310f`), container restart:

```
16:35:20 [store] first chunk ready (chunkSize=10000)
16:35:25 [store] LoadChunked complete ... starting background fill loader
16:36:58 [store] background load complete: 121120/121282 packets in memory (coverage=99.9%)
16:37:03 [analytics-recompute] startup load done: recomputed 10 snapshots in 5.155s (rf=955ms topology=1.684s channels=43ms distance=49ms hash-collisions=30ms hash-sizes=338ms observers-clock-skew=369ms nodes-clock-skew=692ms roles=2ms retransmissions=994ms)
```

Right after that line, `/api/analytics/rf` reported `totalTransmissions`
121,121 against 120,700 packets in memory, and the retransmissions
default shape from #2023 covered the full 7 days. Before this change
both waited for the next 5 minute tick.

### Merge order with #2023
#2023 adds a tenth recomputer. Whichever of the two merges second has to
add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it to
`loadedGate` instead of `LoadComplete`, and change 9 to 10 in
`TestAnalyticsRecomputers_PostLoadOrder`; the retransmissions gate test
then calls `signalStartupLoadDone()` instead of setting `loadComplete`.
That resolution is what ran on staging above.

### Not verified
- Repeater-enrich recomputer and the region/window TTL caches
(hash-collisions region results have a 1 h TTL) may also keep partial
results after the load; not changed here.
- Recompute order is tested structurally, not with roles/clock-skew
data.
- Whether this reduces the #1724 stalls; not measured.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:37 +02:00

215 lines
8.3 KiB
Go

// Package main: issue #1659 — analytics warmup gating.
//
// Problem: after server restart, recompRF (and recompTopology /
// recompChannels) cache the FIRST computation, which immediately after
// boot is just the small in-RAM-observations slice (background
// chunk-loader has not yet backfilled history). The recomputer then
// serves that small slice from GetAnalyticsRFWithWindow's default
// shortcut for an entire recompute interval, while the client pins it
// via CLIENT_TTL.analyticsRF. UX: cards show a tiny "post-restart"
// window even when the user selects "All data".
//
// Fix (r1 — addresses #1688 review munger #5):
//
// The first-pass-done signal is NOT enough on its own — the FIRST
// recomputer pass at boot can complete against the post-restart slice
// BEFORE the chunked loader (#1008 / chunked_load.go) has populated
// the full observation set. Marking the gate ready in that window
// reproduces the original #1659 bug.
//
// Two correctness invariants:
//
// 1. (#1688 munger #5) Only mark first-pass-done when BOTH:
// a. a recomputer pass has completed, AND
// b. that pass started after the startup load finished, hot
// window and background fill (store.StartupLoadDone()).
// The gate's `readyGate` callback is wired by
// StartAnalyticsRecomputers to that signal. Passes that start
// before it leave the gate in the warming-up state; the loop
// recomputes as soon as the signal fires, and that pass opens
// the gate. (The gate used to be store.LoadComplete, which flips
// at the end of the hot window, before the background fill.)
//
// 2. (#1688 munger #2 + kent-beck #2) The gate MUST lift in bounded
// time. If compute() panics on every pass, hangs indefinitely,
// or returns nil forever, an unguarded gate would leave the
// 503 banner permanent. Two safeguards:
// a. compute() panics are already caught by runOnce()'s
// defer recover(); we additionally call markFirstPassDone
// on EVERY pass (even nil-result), so a recomputer that
// returns nil but doesn't panic still flips the gate.
// b. A hard fallback timeout (warmupForceTimeout, 60s by
// default) elapsed since the recomputer was constructed
// forces IsWarmingUp_1659() to false — degraded mode
// (serve whatever cache exists, possibly empty) is
// strictly better than a permanent 503. The partial snapshot
// served that way is replaced by the post-load recompute.
//
// Concurrency (#1688 munger #3):
//
// The previous r0 design used a package-level map keyed by recomputer
// pointer, guarded by a global chanLock. Every default-shape analytics
// request acquired that lock — a serialization point on a hot path.
//
// r1 inlines the warmup fields directly on `analyticsRecomputer`:
// - firstPassDoneNs atomic.Int64
// - warmupStartedNs atomic.Int64
// - readyGate atomic.Value (holds func() bool, may be nil)
//
// Reads on the hot path are lock-free atomic loads. No package-level
// state, no map lookups, no mutex.
//
// Tests: analytics_warmup_1659_test.go.
package main
import (
"net/http"
"time"
)
// warmupForceTimeout is the deadline after which IsWarmingUp_1659()
// flips false regardless of whether a successful first pass has run.
// Operators get degraded analytics (possibly empty until the next
// successful compute) instead of a permanent 503 banner.
//
// Var (not const) so tests can shorten it.
var warmupForceTimeout = 60 * time.Second
// setWarmupReadyGate wires a callback that the recomputer consults
// before honoring a markFirstPassDone_1659() request. When the gate
// returns false, the warmup state is preserved across the pass —
// equivalent to "this pass doesn't count; we need at least one pass
// AFTER the gate flips true".
//
// nil callback means "no extra gating" (legacy behavior).
//
// Called from StartAnalyticsRecomputers; safe to call before Start().
func (r *analyticsRecomputer) setWarmupReadyGate_1659(gate func() bool) {
if r == nil {
return
}
if gate == nil {
r.warmupReadyGate.Store((*func() bool)(nil))
return
}
r.warmupReadyGate.Store(&gate)
}
func (r *analyticsRecomputer) loadWarmupReadyGate_1659() func() bool {
v := r.warmupReadyGate.Load()
if v == nil {
return nil
}
p, ok := v.(*func() bool)
if !ok || p == nil {
return nil
}
return *p
}
// warmupReadyGateOpen_1659 reports whether the readyGate (when set)
// is open. runOnce samples it BEFORE computing and only calls
// markFirstPassDone_1659 when it was open: first-pass-done requires a
// pass that started after the store finished loading (munger #5). A
// check after the compute would accept a pass that began on partial
// data and merely finished after the load.
func (r *analyticsRecomputer) warmupReadyGateOpen_1659() bool {
gate := r.loadWarmupReadyGate_1659()
return gate == nil || gate()
}
// markFirstPassDone_1659 is called from analyticsRecomputer.runOnce()
// after a compute attempt (success OR nil result; panics are caught
// upstream and never reach here) that started with the readyGate open.
//
// Idempotent: only the FIRST successful flip wins.
func (r *analyticsRecomputer) markFirstPassDone_1659() {
r.firstPassDoneNs.CompareAndSwap(0, time.Now().UnixNano())
}
// FirstPassDoneAt_1659 reports the time the first full compute pass
// completed (subject to the readyGate). Returns zero time if no
// qualifying pass has completed yet.
func (r *analyticsRecomputer) FirstPassDoneAt_1659() time.Time {
if r == nil {
return time.Time{}
}
ns := r.firstPassDoneNs.Load()
if ns == 0 {
return time.Time{}
}
return time.Unix(0, ns)
}
// IsWarmingUp_1659 reports true when the recomputer has not yet
// completed a qualifying first pass AND the fallback timeout has not
// yet elapsed. Handlers for the default-shape request must return
// 503 + Retry-After: 5 while this is true.
//
// Fallback timeout (warmupForceTimeout) prevents a permanent 503 in
// pathological compute paths (perpetual panic, perpetual nil, hang).
//
// Lock-free: pure atomic loads.
func (r *analyticsRecomputer) IsWarmingUp_1659() bool {
if r == nil {
// No recomputer registered → treat as ready; the handler
// falls through to the legacy compute path.
return false
}
if r.firstPassDoneNs.Load() != 0 {
return false
}
startedNs := r.warmupStartedNs.Load()
if startedNs != 0 {
if time.Since(time.Unix(0, startedNs)) >= warmupForceTimeout {
// Forced-ready: gate has been stuck too long. Stop
// serving 503; let the handler serve whatever is in
// the cache (possibly empty).
return false
}
}
return true
}
// noteWarmupStart_1659 records the moment the recomputer was launched
// (called once from Start). Used by IsWarmingUp_1659 to compute the
// fallback-timeout elapsed window.
func (r *analyticsRecomputer) noteWarmupStart_1659() {
if r == nil {
return
}
r.warmupStartedNs.CompareAndSwap(0, time.Now().UnixNano())
}
// writeAnalyticsWarmup503 emits the standard warmup response. The body
// shape is documented for clients: error string + retry_after_s int.
func writeAnalyticsWarmup503(w http.ResponseWriter) {
w.Header().Set("Retry-After", "5")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"analytics warming up","retry_after_s":5}`))
}
// installWarmupBlocker_1659 is a test-only helper that registers the
// RF / topology / channels recomputers with a compute function that
// blocks on the supplied channel. firstPassDoneNs therefore stays
// zero, simulating the post-restart warmup window for the warmup test.
//
// We bypass StartAnalyticsRecomputers entirely and wire the
// recomputers manually so the background goroutines never fire. The
// test only needs the *analyticsRecomputer pointers to be non-nil and
// in the warmup state.
func (s *PacketStore) installWarmupBlocker_1659(block <-chan struct{}) {
blockCompute := func() interface{} {
<-block
return nil
}
s.analyticsRecomputerMu.Lock()
defer s.analyticsRecomputerMu.Unlock()
s.recompRF = newAnalyticsRecomputer("rf-test-block", time.Hour, blockCompute)
s.recompTopology = newAnalyticsRecomputer("topo-test-block", time.Hour, blockCompute)
s.recompChannels = newAnalyticsRecomputer("chan-test-block", time.Hour, blockCompute)
// Do NOT call Start() — leaving firstPassDoneNs at zero is exactly
// the warmup state the test wants to exercise.
}