Files
meshcore-analyzer/cmd/server/index_ready_1008.go
T
40f664c587 chore(#1859): gofmt sweep + gofmt/go vet CI gate (rebase of #1881) (#1941)
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three
commits are preserved, two of them cherry-picked with authorship intact;
the sweep itself had to be regenerated. Opened as a new PR rather than
force-pushing their branch.

Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2
landed as #1937.

## Why regenerated rather than merged

The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs
landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on
current master is cheaper and less error-prone than resolving 72
conflicts that are all whitespace. The drift it fixes also grew in the
meantime: 66 files now, against 72 then, but spread differently.

## The three commits

1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files.
2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet`
copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range
variable copied a `Config` embedding `sync.Once`. Cherry-picked
unchanged.
3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift
or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one
change, noted in the commit message: the ignore file pointed at
`04bc80ee`, the sweep commit on their branch, which does not exist on
this base and would make `git blame --ignore-revs-file` error. Repointed
at `d3a02599`, the sweep here.

## Verification

The claim "formatting only" is checked twice rather than asserted:

- Every changed file is byte-identical to `gofmt(previous content)`. 0
of 66 deviate.
- With line comments and all whitespace stripped, 0 of 66 files differ,
so no code outside comments changed.

14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt`
re-indents indented comment blocks to tabs and inserts a blank comment
line before them; the behavior matrix above `resolveHopWithContext` in
`cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own
output, not an edit, but it is worth naming because it makes the diff
look larger than "whitespace" suggests.

The gate was run locally exactly as the workflow runs it: `gofmt` clean,
and `go vet` clean in all 14 modules, including `cmd/ingestor` which is
what commit 2 fixes.

Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s),
`cmd/ingestor` passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically
on bare master with "A required privilege is not held by the client"
(Windows symlink privilege on my host, not code).

## Sequencing

This should go last in the queue. The sweep touches 66 files, so merging
it before the remaining open Go PRs gives each of them a conflict about
nothing but formatting. After it lands the gate is active, and any PR
with drift fails CI until it runs `gofmt -w`.

Excluded from the sweep: the misnamed `Dockerfile.go`, which is a
Dockerfile that gofmt cannot parse (the workflow excludes it too), and
`docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a
spurious modification against `docs/deployment.md` and is unrelated.

---------

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:52:03 +02:00

218 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Issue #1008: background-deferred subpath + pathHop index builds.
//
// Pattern mirrors the distance index (#1011) — but where distance is
// fully lazy (built on first request), these two indexes are kicked off
// eagerly by Load() in a background goroutine so HTTP becomes ready
// immediately while the indexes finish populating.
//
// Concurrency model:
//
// - subpathReady / pathHopReady are atomic.Bool flags written exactly
// once by the background builder (false → true) and never reset
// thereafter. Handlers read them via SubpathIndexReady() /
// PathHopIndexReady() before touching s.spIndex / s.spTxIndex /
// s.byPathHop. While a flag is false, the handler responds 503 +
// Retry-After: 5.
//
// - The builder itself acquires s.mu.Lock() and calls the existing
// buildSubpathIndex() / buildPathHopIndex() methods. Those methods
// replace s.spIndex / s.spTxIndex / s.byPathHop with freshly-
// allocated maps under the write lock. Visibility of the populated
// maps to handlers that see Ready()==true is guaranteed by Go's
// sync/atomic acquire-release semantics (formalized in Go 1.19):
// the atomic.Store(true) happens-after the s.mu.Unlock() that
// completes the build, and the handler's atomic.Load()==true
// synchronizes-with that store. The handler's subsequent s.mu.RLock
// is not what establishes visibility — it only serializes against
// concurrent ingest writers — so dropping the RLock would still be
// safe for the build's "populated map" snapshot (we keep it for
// ingest serialization).
//
// - Ingest-side incremental updates in StoreNewTransmissions /
// pruning / hash-collision paths continue to write s.spIndex /
// s.spTxIndex / s.byPathHop directly under s.mu.Lock(). Because
// the builder also runs under s.mu.Lock() and the builder
// overwrites whatever is there, the brief window between Load()
// returning and the goroutine acquiring s.mu means any
// concurrent ingest writes will be overwritten by the build —
// this matches the prior behavior where ingest could not start
// until Load() released s.mu, so in practice ingest does not
// run during the build window. Documenting this rather than
// adding a separate gate: the existing main.go boot sequence
// does not start ingest goroutines until after store.Load()
// and graph init complete.
//
// Handler scope of the ready gate (issue #1008 review M2):
//
// - HARD-GATED with 503 + Retry-After: 5 — analytics endpoints whose
// entire response is the index aggregate. Empty data would be
// visibly broken (charts, top-N tables). See routes.go:
// /api/analytics/subpaths, /api/analytics/subpaths-bulk,
// /api/analytics/subpath-detail, /api/nodes/{pubkey}/paths.
//
// - BEST-EFFORT (not gated) — endpoints where the index drives
// enrichment fields that callers already treat as optional. During
// the not-ready window these report zero counts / nil scores
// rather than 503-ing the whole list. Acceptable because:
//
// - /api/nodes and /api/nodes/{pubkey} have many other fields
// (last-seen, position, advert metadata) that callers depend
// on at startup. 503-ing the SPA bootstrap to wait for an
// index that exclusively affects "relay activity" badges
// would be a worse UX than a 30–60s window of "—" badges.
//
// - GetRepeaterRelayInfoMap / GetRepeaterUsefulnessScoreMap /
// GetBridgeScore / repeater_liveness / repeater_usefulness
// all walk s.byPathHop. During the build window they return
// empty maps or zero scores; the steady-state recomputer
// (#1262) refreshes them every 5min once indexes flip ready
// (prewarm guarded by WaitIndexesReady — see review M1).
//
// This is documented rather than gated so operators do not see
// /api/nodes 503 during routine restarts on Cascadia-scale data.
package main
import (
"log"
"net/http"
"time"
)
// writeIndexLoading503 emits the standard 503 response used by handlers
// that depend on a not-yet-built index (#1008). Body shape matches the
// triage spec: {"error":"index loading","retryAfter":5}. The Retry-After
// header is also set so well-behaved clients back off automatically.
func writeIndexLoading503(w http.ResponseWriter) {
w.Header().Set("Retry-After", "5")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"index loading","retryAfter":5}`))
}
// SubpathIndexReady reports whether the subpath index build kicked off
// by Load() has completed (#1008). Until this returns true, callers
// must NOT read s.spIndex / s.spTxIndex.
func (s *PacketStore) SubpathIndexReady() bool {
return s.subpathReady.Load()
}
// PathHopIndexReady reports whether the path-hop index build kicked
// off by Load() has completed (#1008). Until this returns true,
// callers must NOT read s.byPathHop.
func (s *PacketStore) PathHopIndexReady() bool {
return s.pathHopReady.Load()
}
// indexReadyCh returns the channel that is closed when BOTH indexes
// have flipped ready. Lazily created on first access. Safe to call
// concurrently. Used by WaitIndexesReady and any future waiters that
// want event-driven semantics instead of polling.
func (s *PacketStore) indexReadyCh() <-chan struct{} {
s.indexReadyChMu.Lock()
defer s.indexReadyChMu.Unlock()
if s.indexReadyChan == nil {
s.indexReadyChan = make(chan struct{})
// If both are already ready (e.g. background chunk loader
// flipped them synchronously before any waiter showed up),
// close immediately so the channel is usable as a one-shot.
if s.subpathReady.Load() && s.pathHopReady.Load() {
close(s.indexReadyChan)
}
}
return s.indexReadyChan
}
// maybeCloseIndexReadyCh closes the ready channel iff both flags are
// set. Idempotent (a sync.Once on the channel) and safe to call from
// either builder goroutine on the green-path transitions, as well as
// from markIndexesReadySync.
func (s *PacketStore) maybeCloseIndexReadyCh() {
if !(s.subpathReady.Load() && s.pathHopReady.Load()) {
return
}
s.indexReadyChMu.Lock()
defer s.indexReadyChMu.Unlock()
if s.indexReadyChan == nil {
// Lazily allocate AND close it in one step so any future
// indexReadyCh() caller gets a pre-closed channel.
s.indexReadyChan = make(chan struct{})
close(s.indexReadyChan)
return
}
select {
case <-s.indexReadyChan:
// Already closed.
default:
close(s.indexReadyChan)
}
}
// startBackgroundIndexBuilds is called from Load() after s.loaded=true
// to populate the subpath + path-hop indexes off the critical path
// (#1008). It returns immediately; the work runs in two background
// goroutines (one per index — see review m7) that each acquire
// s.mu.Lock() independently, install their map, then set the
// corresponding atomic ready flag.
//
// At Cascadia scale (~5M observations) this previously blocked HTTP
// readiness ~60s inside Load() under s.mu. Running the two builds in
// parallel halves the pathHop-not-ready window since the two builders
// are independent of each other.
func (s *PacketStore) startBackgroundIndexBuilds() {
go func() {
t0 := time.Now()
s.mu.Lock()
s.buildSubpathIndex()
s.mu.Unlock()
// Atomic.Store happens-after s.mu.Unlock; handlers that
// observe Ready()==true synchronize-with this store.
s.subpathReady.Store(true)
s.maybeCloseIndexReadyCh()
log.Printf("[startup] index build complete: subpath (%s)",
time.Since(t0).Round(time.Millisecond))
}()
go func() {
t1 := time.Now()
s.mu.Lock()
s.buildPathHopIndex()
s.mu.Unlock()
s.pathHopReady.Store(true)
s.maybeCloseIndexReadyCh()
log.Printf("[startup] index build complete: pathHop (%s)",
time.Since(t1).Round(time.Millisecond))
}()
}
// markIndexesReadySync is the synchronous-build entry point used by
// the background chunk loader in store.go (and by tests). The chunk
// loader rebuilds both indexes under s.mu.Lock(); after the Unlock it
// calls this to flip the ready flags and close the broadcast channel
// in one shot, preserving symmetry with the goroutine path above.
func (s *PacketStore) markIndexesReadySync() {
s.subpathReady.Store(true)
s.pathHopReady.Store(true)
s.maybeCloseIndexReadyCh()
}
// WaitIndexesReady blocks until both background indexes built by
// startBackgroundIndexBuilds() report ready, or the deadline expires.
// Returns true if both flipped in time. Intended for tests that read
// s.spIndex / s.spTxIndex / s.byPathHop directly after Load(); production
// code paths gate via SubpathIndexReady() / PathHopIndexReady() and
// respond 503 + Retry-After to clients instead of blocking.
//
// Uses the indexReadyCh broadcast channel rather than polling
// (see review m6) so wake-up is immediate with no poll-interval jitter.
func (s *PacketStore) WaitIndexesReady(timeout time.Duration) bool {
if s.SubpathIndexReady() && s.PathHopIndexReady() {
return true
}
ch := s.indexReadyCh()
select {
case <-ch:
return true
case <-time.After(timeout):
return s.SubpathIndexReady() && s.PathHopIndexReady()
}
}