fix(store): remove evicted transmissions from resolved byPathHop entries - #144
Conversation
evict_resolved_pathhop_115_test.go indexes transmissions through the real helpers (addTxToPathHopIndex, indexResolvedPathHops with two observations, so resolved keys hold duplicates) and evicts the older half by retention, by memory and through RunEviction, in both useResolvedPathIndex modes. On master 5 of 7 fail: each evicted transmission is still in byPathHop 6 times (its two resolved keys twice each, plus a key only evicted transmissions used), because eviction removes raw hop keys only; and a raw bucket keeps the evicted pointer in its backing array when the evicted entry is not the last one. The rebuild-resurrection and cache invalidation controls pass. BenchmarkEvictPathHops_115 measures a 1% eviction batch at 20k and 100k transmissions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
…115) Eviction removed a transmission only from the byPathHop keys of its raw wire hops, one occurrence each. indexResolvedPathHops also puts it under resolved full-pubkey keys, once per observation that resolved it, so evicted transmissions stayed in relay counts, transported scopes and memory until the next full rebuild. evictFromPathHopIndex now removes the evicted batch from every bucket in one pass: raw and resolved keys, all duplicates. Empty buckets are deleted and slices.DeleteFunc zeroes each compacted tail, so no backing array keeps an evicted transmission alive. The resolved pubkeys are kept nowhere per transmission (Kpa-clawbot#800) and may come from path_json reconstruction as well as resolved_path, so a sweep is the only complete way; it runs once per batch under the write lock eviction already holds, like compactDistIndex. removeTxFromSlice (raw path change) now removes every occurrence and zeroes the tail as well. The rebuild defence (retainResolvedPathHops) is unchanged and its comment updated; relay readers keep materializing under the read lock. The memory-eviction test evicts only a quarter of the store, so it now skips the "bucket used only by evicted transmissions is deleted" check, which applies when all of them are evicted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
…115) Eviction runs every minute and usually evicts a handful of transmissions, but the first fix swept every byPathHop bucket per batch. A resolved pubkey always starts with the hop it resolves (the ingestor's prefixIndex maps a hop prefix to the pubkeys that start with it, and byPathHop's resolved keys come only from persisted resolved_path), so evictFromPathHopIndex now collects the hops of every evicted transmission, from tx.PathJSON and every observation's path, and sweeps only buckets whose key starts with one of them (case-insensitively). Still complete: all raw and resolved buckets of the batch, all duplicates. Tests: the fixture's resolved pubkeys now start with their hops as in real data; new case for a key resolved from a non-display observation path; the benchmark uses 4000 prefix-consistent relays and adds a one-minute batch (0.01%). BenchmarkEvictPathHops_115 (min ms, same loaded machine, master is the incomplete raw-only removal): 100k/batch 10: 0.43 -> 2.1; 20k/batch 2: 0.10 -> 0.86; 100k/batch 1000: 5.05 -> 15.4. Relates to #115 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Independent review of
|
| Criterion | Result |
|---|---|
Remove every evicted tx from all raw and resolved byPathHop buckets, including duplicates |
Met [F]. A single pass per batch (store.go:5111) covers the hops of tx.PathJSON plus every observation's PathJSON. Each resolved key comes from resolved_path (the ingestor's unique-prefix resolvePath) or from the server's resolvePathForObs, so it starts with a hop of the path of the observation that produced it. The ingestor upsert keys on (transmission_id, observer_idx, path_json), so resolved_path stays aligned with its path_json. Mutants M1, M2, M3 and M10 are caught. It also works for uppercase hops (probe), though that is untested (finding 1). |
| Delete empty buckets; no retention via backing arrays | Met [F]. Mutant M5 (keep empty bucket) is caught, and M7 (compaction without zeroing the tail) is caught by TestEvictDoesNotRetainPointerInRawBucketTail_115. |
| Preserve surviving entries and index invariants | Met [F]. Removal is by pointer identity against the evicted set. The tests check that each survivor keeps exactly 7 refs (9 in the memory-eviction case). byNode, nodeHashes and the Kpa-clawbot#800 index are untouched. Readers iterate under RLock (routes.go:2282, store.go:10005-10025, forEachRelayCandidate is nil-safe). |
| Invalidate relay/stat caches | Met [F]. invalidateRelayStatsCache() runs after the sweep. Mutant M8 is caught. The test was already green on master, which already invalidated the cache there, so it is a guard rather than a red test. |
| Retention and memory eviction; rebuild cannot resurrect | Met [F]. TestRunEvictionRemovesResolvedPathHops_115, TestMemoryEvictionRemovesResolvedPathHops_115 and TestRebuildAfterEvictionDoesNotResurrect_115 (the rebuild defence retainResolvedPathHops is unchanged). |
| Relay aggregation concurrent with eviction is race-free | Met [F]. TestRelayStatsConcurrentWithEviction_115 passes under -race on the head and on the stacked tree. Only one RunEviction overlaps the readers, so it is a light race probe. |
Test-first and mutants
Red and green:
- Head test file on master: 6 FAIL, 2 PASS. The two passes are the rebuild and cache guards, as the PR states. [F]
- Commit A's tests on the A tree: 5 FAIL, 2 PASS. [F]
- B: 7/7 PASS. Head: 8/8 PASS. [F]
Changes to the tests between A and the head, both justified [F]:
- B: A asserted that the
evict115Onlybucket is deleted after a partial memory eviction, which was a test bug. B split this intoassertEvictedGone115Partial. - Head: the fixture pubkeys became prefix-consistent (
aa…/bb…/cc…) to match the prefix-scoped design and real resolver output. A new case for other-observation paths was added, and the benchmark was extended.
Mutants were run against the head's store.go. Each was checked against 109 tests (-run 'Evict|PathHop|Relay|_115|Removal|RemoveTx'). The original was restored afterwards (shasum 496c1684… matches git show 1477f579:cmd/server/store.go). [F]
| # | Mutant | Result |
|---|---|---|
| M1 | Revert to per-tx removeTxFromPathHopIndex (raw only) |
caught (5 _115 tests) |
| M2 | Drop the observation-path hops | caught (…OtherObservationPaths_115) |
| M3 | Exact key match only (no prefix match for resolved keys) | caught (5) |
| M4 | hasEvictedHopPrefix always true (global sweep) |
survived. Equivalent for correctness (perf only), and covered by the benchmark |
| M5 | Keep empty buckets | caught (4) |
| M6 | No ToLower on hops |
survived. A real gap on uppercase data, proven by the probe (finding 1) |
| M7 | Compact without zeroing the tail | caught (…RawBucketTail_115) |
| M8 | Drop invalidateRelayStatsCache() |
caught |
| M9 | Old single-occurrence removeTxFromSlice |
survived (finding 2) |
| M10 | Drop the tx.PathJSON hops |
caught (6) |
| M11 | No ToLower on the key prefix |
survived. Equivalent in practice: byPathHop keys are lowercased on insert (addTxToPathHopIndex, and resolvers lowercase the pubkeys) |
Suites run locally
- Head,
cd cmd/server && go test -race -count=1 -timeout 60m ./...:ok github.com/corescope/server 869.739s, exit 0. The knownTestIssue1008flake did not occur. This includesreadonly_invariant_test.go. [F] - Stacked tree (master + fix(store): preserve first_seen ordering when merging background chunks #137 + fix(store): remove evicted transmissions from resolved byPathHop entries #144),
-raceon the new tests of both PRs (_114|_115): 6_114tests and 8_115tests PASS,ok. A wider stacked-racesubset (_114|_115|Evict|PathHop|Chunk|MergeByFirstSeen|Relay) also passes:ok 29.1s. [F] - Stacked tree, full
go test -race -count=1 -timeout 60m ./...:ok github.com/corescope/server 876.103s, exit 0. [F] - The ingestor is not touched, so it was not run. [F]
Performance and security
-
Complexity per batch [F]:
- Collecting the hops: O(evicted × (hops + observations)).
- Then one pass over the
byPathHopkeys with ≤len(lens)(usually 1–3) map lookups each.strings.ToLowerdoes not allocate for keys that are already lowercase. - Then a full
DeleteFuncover the candidate buckets only. - Everything runs under the write lock eviction already holds, and nothing new is taken under it.
- The worst case (a batch touching every 1-byte prefix) is O(total entries), the same shape as
compactDistIndex.
-
PR benchmark
BenchmarkEvictPathHops_115, reproduced interleaved (3 rounds, host shared with other agents, so noisy). Master vs head, ms/op [F]:- 20k/2: 0.017–0.03 vs 0.25–0.61
- 100k/10: 0.07–0.47 vs 1.2–3.0
- 20k/200: 0.29–0.98 vs 2.9–4.6
- 100k/1000: 1.8–5.0 vs 7.7–11.5
The order of magnitude is consistent with the PR's table [T]. The skewed-mix numbers are in finding 3.
-
Bounded structures [F]:
- The fix removes an unbounded-until-rebuild leak.
- The temporary maps (
prefixes,evictedTxSet) are per batch. evictedTxSetis now built earlier and reused bycompactDistIndex, so no extra allocation.
-
No new
map[string]interface{}, no DB writes incmd/server, and no goroutines or timers added. [F] -
Interaction with fix(store): preserve first_seen ordering when merging background chunks #137 (
loadChunkmerges byfirst_seen) [F]:- The two PRs change different functions.
evictFromPathHopIndexmakes no ordering assumption abouts.packetsor the buckets. It works on the evicted set, so fix(store): preserve first_seen ordering when merging background chunks #137's correct head-of-slice eviction feeds it directly.
Not verified
- Lock-hold time on a production-size store and relay mix (staging). [K], also listed in the PR.
- That no persisted
resolved_pathholds a pubkey that does not start with its hop, for example rows written by older ingestor versions or a backfill. Checked only for the current ingestorresolvePathand the serverresolvePathForObs. Such a key would stay until the next rebuild. [A] - No browser check: there is no UI change. [F]
Relates to #115
Plan and design
The user asked for autonomous work, so the plan is written here instead of waiting for sign-off (AGENTS.md rule 5).
Commits:
1e7ce403: tests (red on master).1a2f7733: complete removal.1477f579: cost proportional to the batch. The test fixture now uses realistic, prefix-consistent pubkeys, and there is a new case plus a one-minute-batch benchmark.Claims in the issue, verified against master
indexResolvedPathHops→addResolvedPubkeysToPathHopIndexputs a transmission under resolved full-pubkey keys inbyPathHop, once per observation that resolved it.PathJSONhops, and only one occurrence each.removeFromResolvedPubkeyIndexcleans a different (hash-only, perf: remove per-StoreTx ResolvedPath, replace with membership index + on-demand decode (final spec) Kpa-clawbot/CoreScope#800) index.Change (
cmd/server/store.go)evictFromPathHopIndex(idx, evicted)runs once per eviction batch, under the write lock eviction already holds.Which buckets. Only buckets whose key starts (case-insensitively) with a hop of an evicted transmission. Hops are taken from
tx.PathJSONand every observation'sPathJSON. This is complete because:byPathHop's resolved keys come only from persistedresolved_path(thepath_jsonreconstruction on cold load goes intobyNodeonly).prefixIndex, which maps a hop prefix to the pubkeys that start with it.What happens in those buckets.
slices.DeleteFunc).Other changes.
removeTxFromSlice(raw path change) now removes every occurrence and zeroes the tail as well.Unchanged. The rebuild defence (
retainResolvedPathHops),byNode/nodeHashes, the hash-only reverse index, and relay readers materializing owned values underRLock(the fork's model, as the issue asks).How this differs from upstream
Kpa-clawbot/CoreScope#1966Upstream is read as a reference only; nothing was cherry-picked.
RLock).Acceptance criteria
TestEvictRemovesRawAndResolvedPathHops_115(both resolved-index modes),TestEvictRemovesResolvedKeysOfOtherObservationPaths_115TestEvictDoesNotRetainPointerInRawBucketTail_115TestEvictionInvalidatesRelayStatsCache_115TestRunEvictionRemovesResolvedPathHops_115,TestMemoryEvictionRemovesResolvedPathHops_115,TestRebuildAfterEvictionDoesNotResurrect_115TestRelayStatsConcurrentWithEviction_115under-raceTests
-race -run _115on the branch:ok.TestEvictRemovesResolvedKeysOfOtherObservationPaths_115fail.Perf (AGENTS.md rule 0; eviction runs every minute under the write lock)
BenchmarkEvictPathHops_115: 3 raw one-byte hops per transmission, 2 resolved to one of 4000 prefix-consistent relays, 2 observations each. Min / median in ms, both run interleaved on the same loaded machine. Master's code is the incomplete raw-only removal.byPathHopkey (≈ relays + hop prefixes), plus sweeping the candidate buckets that master left dirty.Full server
-raceFAILonly inTestIssue1008_HandlerReturns503WhileSubpathIndexLoading(timing-dependent; passes 30/30 in isolation on master and branch). Reproduced on origin/masterd264716c:go test -race -count=200 -cpu 1,2,8 -run '^TestIssue1008_HandlerReturns503WhileSubpathIndexLoading$'failed 2 of 600 runs, twice, with the identical message (status = 200, want 503). It is a scheduling race in the test: the background subpath build on a tiny DB can finish before the handler call. This PR does not touch that build or the handler. The master full-suite run that passed (ok, 1307 s) simply did not hit it.1477f579):ok github.com/corescope/server 1613.295s.Not verified
resolved_pathwhose pubkey does not start with its hop. The ingestor'sprefixIndexguarantees it. A violating key would stay until the next rebuild, as every resolved key does on master today.Overlap with other open PRs
cmd/server/store.gois also changed by PR fix(store): preserve first_seen ordering when merging background chunks #137 (fix(store): preserve first_seen ordering when merging background chunks #114,loadChunkpublication) and the upcoming fix(analytics): recompute cached snapshots after the complete startup load #116 PR (TriggerDistanceIndexBuild, a struct field). Different functions.85bfee49and merges cleanly withorigin/masterd264716c. A merge simulation of all 13 batch branches in issue order merges this one without conflicts.🤖 Generated with Claude Code
https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Generated by Claude Code