Skip to content

port(upstream#1907): keep resolved full-pubkey hops across a path-hop index rebuild - #35

Merged
dborup merged 4 commits into
masterfrom
codex/port-upstream-1907-pathhop-rebuild-resolved-hops
Sep 20, 2026
Merged

dborup merged 4 commits into
masterfrom
codex/port-upstream-1907-pathhop-rebuild-resolved-hops

Conversation

@adminopenclaw8-sketch

@adminopenclaw8-sketch adminopenclaw8-sketch commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Split out of #25 (commit b251f299 there). This branch holds exactly one upstream change so it can be reviewed, tested and reverted on its own.

Upstream

Problem

buildPathHopIndex rebuilt byPathHop only from each packet's raw path_json hops. The resolved full-pubkey keys that indexResolvedPathHops adds are stored nowhere else, so every cold load or background-fill completion silently dropped all resolved relay attribution. Relay counts and transported scopes were then empty until live ingest refilled them.

Change

retainResolvedPathHops re-merges entries from the pre-rebuild index for transmissions still present in s.packets, de-duplicated and with evicted transmissions filtered out so the index stays bounded. Cost is O(entries in the old index), only where the rebuild already runs (cold load / background fill), never on ingest or request paths.

Adaptation to this fork

None. The cherry-pick applied without conflicts and the changed lines are identical to upstream.

Notes for review

Relevant to this fork: our relay/transported-scope views read byPathHop.

Dependencies and merge order

Verification

Local run of the same commands as CI's “Go Build & Test” job (server tests with -race), on this branch and on master fda24ca5 under the same conditions (same machine, run one after another):

Check master fda24ca5 this branch verdict
go-server-build-vet PASS PASS
go-server-test-race PASS PASS
channel-lib-test PASS PASS
decrypt-cli-build-test PASS PASS
dockerfile-copy-invariants FAIL FAIL not runnable locally: script needs bash ≥4 (declare -A), macOS has 3.2; identical on master
staging-disk-monitor PASS PASS
css-vars-lint PASS PASS

Baseline failures (fail identically on master; not introduced or changed here): see rows marked baseline failure, unchanged.

Browser validation (local, fixture DB, no staging/production): Not applicable (no frontend change).

Not run:

  • Playwright E2E suites (no local Playwright install); CI's E2E job will also be skipped, see below.
  • eslint (not installed locally; CI installs it on the fly).
  • Frontend JS suites (no frontend change).
  • Browser tests against staging/production (deliberately none).

Expected GitHub CI: “Go Build & Test” is expected to fail on TestPruneOldNeighborMetrics, which already fails on master (see #25's run). Downstream jobs (Playwright, image build) are therefore skipped. “Deploy Staging” and all GHCR publish steps only run on push to master and cannot run for this PR.
Two further ingestor tests have failed intermittently in this split's CI on branches whose cmd/ingestor tree is byte-identical to master (#27, #28), so they can also appear here without being caused by this change:

GitHub CI result: run 34750398749 on 9f67c90c. Go Build & Test: failure; all downstream jobs incl. Deploy Staging skipped. Failed tests:

  • TestPruneOldNeighborMetrics: fails on master, documented baseline

🤖 Generated with Claude Code

efiten and others added 4 commits September 13, 2026 10:42
…op index rebuild (Kpa-clawbot#1907)

Fixes Kpa-clawbot#1904.

## The bug

`buildPathHopIndex` reassigned `s.byPathHop` to a fresh map and refilled
it from every packet's raw `path_json` hops:

```go
func (s *PacketStore) buildPathHopIndex() {
	s.byPathHop = make(map[string][]*StoreTx, 4096)
	for _, tx := range s.packets {
		addTxToPathHopIndex(s.byPathHop, tx)   // raw hops only
	}
	...
}
```

`byPathHop` carries two kinds of key, though: those raw wire hops, and
the resolved full pubkeys fed per observation by
`indexResolvedPathHops`. The pubkey strings behind the second kind are
retained nowhere — Kpa-clawbot#800 replaced the per-`StoreTx` `ResolvedPath` field
with a hash-only membership index (`resolvedPubkeyIndex` stores FNV
hashes, not strings) — so the rebuild could not reproduce them and
dropped them.

All three call sites run post-load: `LoadChunked`
(`chunked_load.go:459`), the background fill loader (`store.go:1573`),
and the deferred startup build (`index_ready_1008.go:177`). The
`resolved_path` branch of the chunk scan populates the index and is then
silently undone a few hundred lines later, while the `resolved_path IS
NULL` fallback right beside it is explicitly documented as "byNode ONLY
— the resolved_path/path-hop indexes must NOT be populated here". The
two branches disagreed about who owns the index.

Consequence: after a cold start every lookup keyed by a node's full
pubkey missed, so `relay_count_1h/24h`, `last_relayed`,
`unscoped_relay_count_24h`, `transported_scopes` (Kpa-clawbot#1751) and the
usefulness Traffic axis all read zero until live ingestion slowly
refilled the index.

## Evidence

Fixture built from live data: 2512 nodes, 17,056 transmissions, 528,891
observations, 123,057 of them carrying a non-NULL `resolved_path`.

```
before   [store] Built path-hop index: 2924 unique keys
         /api/nodes → 0 of 2000 nodes with transported_scopes
                      0 with relay_count_24h > 0

after    [store] Built path-hop index: 3881 unique keys
                      (172181 resolved-hop entries retained)
         /api/nodes → 726 with transported_scopes
                      741 with relay_count_24h > 0
```

The 957 extra keys are the full pubkeys.

## The change

`retainResolvedPathHops` re-merges the pre-rebuild map's entries that
the raw-hop pass cannot reproduce.

Entries are carried over **only for transmissions still in
`s.packets`**. That filter is load-bearing rather than defensive.
`removeTxFromPathHopIndex` strips raw hops only — it derives them from
`txGetParsedPath` — and its companion `removeFromResolvedPubkeyIndex`
cleans the hash index, not `byPathHop`. So evicted transmissions linger
under their resolved keys, and the wipe this PR removes was the only
thing that ever cleared them. Filtering on liveness keeps the index
bounded by the eviction policy instead of converting that gap into a
permanent leak.
`TestBuildPathHopIndex_DropsResolvedHopsOfEvictedTx_1904` pins it.

(The eviction gap itself is pre-existing and outside this change:
between rebuilds, an evicted transmission still stays referenced under
its resolved keys. Filed separately.)

## Perf

`O(entries in prev)` with one scratch map reused across keys (`clear()`
per key, the same idiom as `hopsSeen`), plus one `map[*StoreTx]struct{}`
over `s.packets` for the liveness check. It runs only where
`buildPathHopIndex` already ran — cold load and background-fill
completion — never on an ingest or request path. Measured on the fixture
above: index build stayed within the same `LoadChunked` step, 15.2s
total for 17k transmissions / 527k observations.

Memory: the retained entries point at transmissions already held by
`s.packets`, so no `StoreTx` is kept alive beyond eviction; the cost is
map/slice overhead for keys that the feature is supposed to have.

## Tests

`cmd/server/pathhop_rebuild_1904_test.go`, red before / green after:

1. `TestBuildPathHopIndex_RetainsResolvedHops_1904` — a resolved
full-pubkey key survives the rebuild alongside the raw hop.
2. `TestBuildPathHopIndex_DropsResolvedHopsOfEvictedTx_1904` — a
resolved key whose transmission is no longer in `s.packets` is dropped,
and the now-empty key is not left behind.
3. `TestBuildPathHopIndex_NoDuplicateOnRepeatedBuild_1904` — building
twice does not double-append (`indexResolvedPathHops` dedups within a
call, not across the several observations of one transmission, so `prev`
can legitimately contain duplicates).

```
cd cmd/server && go test ./...    ok  github.com/corescope/server  85.5s
go vet ./...                      clean
```

Frontend and ingestor suites are untouched by this change (Go server
only, no `public/` files).

## Interaction with Kpa-clawbot#1903

Both touch `byPathHop` semantics, so I verified them composed on the
same fixture. With Kpa-clawbot#1904 alone the resolved keys come back and Kpa-clawbot#1902's
prefix collision is plainly visible again (51% of 1-byte prefix groups
reporting an identical scope set). With both:

```
f79616  BE repeater      ['#be','#de','#eu','#nl']   relay24h=542
f752c2  DE/NRW repeater  ['#de','#de-nw']            relay24h=343
f788ad  BE repeater      none                        relay24h=383
```

Identical-set prefix groups fall to 8%, relay counts stay intact, and
each node's scopes match what its own `resolved_path` rows say. The two
changes are independent and compose cleanly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f081f91)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the branch up to master af2f73e (post #32, which also touched
cmd/server/store.go in a different region) so CI runs on current master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nvalidates

The PR as submitted broke an existing test on master. My full -race run
caught it (CI would have too): TestNodeActivity_AlternateObservationRelay-
SurvivesRestart passes on master and fails here, subtest
persisted_resolved_path=true, at "fixture must reproduce the restart index
shape".

That test's precondition asserted the Kpa-clawbot#1904 gap EXISTS — that after a
restart the tx is absent from byPathHop under the resolved relay key — so
the rest of it could check activity survives despite the gap. Retaining
resolved hops makes that precondition false, which is the fix working, not
a regression.

It also answers the open question about whether this helps a cold start. It
does, for a persisted resolved_path: buildPathHopIndex runs more than once
during Load, and the later build retains what the earlier population
established ("3 resolved-hop entries retained" in the test's own log).
Without a persisted resolved_path there is still nothing to retain, which is
why only the persisted=true subtest changed.

The precondition now asserts the CORRECTED shape rather than dropping the
check: the relay bucket must contain the tx exactly when resolved_path was
persisted (got != persisted), so it fails both on a revert to the old
behaviour and on over-retention. The separate assertion that a non-display
raw hop ("a1") is never indexed is kept.

Verified: disabling retention fails it, and dropping the live-set filter
fails the eviction test. Full go test -race ./... is clean (262s, 0 races).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tants

Independent review disproved my cold-start worry by execution: prev is NOT
empty on a cold start, because both load paths populate byPathHop with
resolved keys during the row scan (chunked_load.go scanAndMergeChunk, and
the legacy Load) BEFORE buildPathHopIndex replaces the map. Measured on a DB
with one persisted resolved_path through LoadChunked: 1 entry with the fix,
0 with retention stubbed out. The reported "relay counts empty after
restart" symptom is genuinely fixed.

Three findings addressed:

1. buildPathHopIndex violated a contract the codebase states about itself.
   addResolvedPubkeysToPathHopIndex says mutating byPathHop "MUST be paired
   with invalidateRelayStatsCache". A rebuild replaces the entire map and
   invalidated nothing. That was survivable while a rebuild only ever
   DISCARDED relay attribution; now that it restores it, the 300s batch
   cache could pin the pre-rebuild empty relay stats for minutes after the
   index was fixed — hiding exactly the data this retention restores.
   GetRepeaterNodeStatsBatchCached is not gated on PathHopIndexReady and
   HTTP binds before the load completes, so one request landing just before
   the rebuild is enough. Now invalidated.

2. Two mutants survived the original tests. Deleting the in-loop seen[tx]
   marking passed everything, as did skipping a whole key when ANY entry is
   non-live — because every test bucket held a single transmission. Added a
   mixed live+evicted bucket test and one for repeated appends of the same
   tx (which indexResolvedPathHops genuinely produces: it dedups within a
   call, not across the several observations of one transmission). Both
   mutants now fail.

3. Documented, not changed: that dedup alters repeater usefulness scores.
   Three readers count ENTRIES rather than distinct transmissions, so a
   multi-observer relay measured 3 entries before a rebuild and 1 after.
   The collapsed value is the correct one and the dedup is load-bearing for
   boundedness, but it is a user-visible score change worth naming.

Also renamed the log line to "entries carried over", matching what the merge
actually does rather than implying only resolved keys qualify.

go test -race ./... clean (255s, 0 failures, 0 races).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dborup
dborup merged commit f63148a into master Sep 20, 2026
11 of 12 checks passed
dborup pushed a commit that referenced this pull request Sep 20, 2026
Brings the branch up to master 834c8da (post #35, which also touched
cmd/server/store.go in a different region) so CI runs on current master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants