Repository navigation
fix(store): index live observations from the persisted resolved_path, as Load does (#158) - #182
Conversation
…d load (#158) After #162 the sum of traffic_share_score on staging still rose with uptime (14.3 at 2.6 h, 20.7 at 18 h). Load indexes the resolved_path the ingestor persisted, which leaves an ambiguous 1-byte hop null; live ingest indexes the server's own resolution, which always picks a candidate and can pick a different one per observer. The fixture has two repeaters sharing prefix E5 and an observer near each. On baa68cd live ingest of the same rows gives each E5 node a 0.5 share (0 after load), doubles the full-key sum and indexes a 2-hop transmission under 3 relays. A NULL resolved_path is also indexed differently. Adds BenchmarkIngestNewFromDB_158. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBJURW9dzgaWhWyyp4f9qw
… as Load does (#158) IngestNewFromDB deliberately did not select resolved_path, on the premise that Load reads back what live ingest computed (review item #7). That held while the server wrote resolved_path itself. Since Kpa-clawbot#1287/Kpa-clawbot#1547 the ingestor writes it, with its own conservative resolver (Kpa-clawbot#1560), so a transmission was indexed under more relays when it arrived live than after a restart. traffic_share_score mixes both halves of the store, so its sum rose with uptime until retention turned the store over. indexObservationRelayHops is now the one place that feeds the relay-hop indexes from an observation, used by Load, scanAndMergeChunk, IngestNewFromDB and IngestNewObservations: the persisted resolved_path goes to indexResolvedPathHops; a NULL one is re-resolved on unique prefixes into byNode only, as Load already did. The live broadcast still carries the server's own resolution. The #158 fixture inserted live observations with a NULL resolved_path ("resolved fresh by the server"); it now writes resolved_path in the same row, as the ingestor does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBJURW9dzgaWhWyyp4f9qw
…158) Review of the live-ingest fix found that a mutant of IngestNewObservations which ignores the persisted resolved_path (indexing "" instead) survived: the late observation in the existing late-observation case reuses the first observation's path, so there is nothing new to index. - TestTrafficShareLiveMatchesLoad_LateObserverNewRelay_158: a late observation from another observer whose resolved_path names a relay the first observation did not have (B2 for even transmissions, A1 for odd ones). Live byPathHop and traffic share must equal a Load of the same rows. Red under the mutant (share 0.5 vs 1.0, sum 1.0 vs 2.0, 10 vs 20 transmissions per bucket). - TestLateObservationIndexesLikeLoad_NullResolvedPath_158: the NULL resolved_path case of TestLiveIngestIndexesLikeLoad_NullResolvedPath_158 for IngestNewObservations: byNode only, never byPathHop. Relates to #158 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nop1P1pX4SrfRMHAernzQ
…an (#158) IngestNewFromDB and IngestNewObservations read hasObsRawHex, hasResolvedPath, hasScopeName and hasRouteMask once to build the SELECT and again to build the Scan destinations. The flags are atomics the schema healer (or main.go's forceTrue) can latch at any time, so a flip in between gave Scan the wrong destination count; the error is swallowed and the row was silently skipped. The #158 follow-up added resolved_path to both queries and so widened that window. DB.ingestCols takes one snapshot per call, used for both. The test flips each flag between query and scan through a test-only hook (PacketStore.ingestAfterQueryHook), in both directions, in both functions: 14 subcases, all red before this change (rows skipped), all green after. Load and LoadChunked read their flags twice in the same way; they are not changed here (startup schema detection is #184). Relates to #158 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nop1P1pX4SrfRMHAernzQ
…158) IngestNewFromDB and IngestNewObservations unmarshalled resolved_path once per observation inside indexObservationRelayHops, i.e. while holding s.mu. The rows are already read into tempRows/obsRows before the lock, so the column is now decoded there (decodePersistedRelayPath) and the index step receives the decoded pubkeys. Load and scanAndMergeChunk call the same helper at the same place as before. The decode itself is cheaper: it unmarshals straight into []string and compacts in place instead of []*string plus extractResolvedPubkeys. A test pins it against that old pair, including NULL, all-null, corrupt and non-string input. Measured (n=10, interleaved, same test files): total time per call is unchanged (the work moved, it did not go away: IngestNewFromDB 4.58 -> 4.55 ms, p=0.74), allocations -1.8 % (IngestNewFromDB, p<0.001). Time spent holding s.mu, measured in an instrumented copy: IngestNewFromDB 2.28 -> 1.90 ms (-16.6 %, p<0.001); IngestNewObservations 1.83 -> 1.72 ms (not significant, +-24 %). Relates to #158 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nop1P1pX4SrfRMHAernzQ
…ed (#158) Both said resolved_path is NULL on every live deployment since Kpa-clawbot#1287. That contradicts indexObservationRelayHops and Kpa-clawbot#1547: the ingestor persists it with its conservative resolver (Kpa-clawbot#1560), and it is NULL only where that resolver could not resolve the path (a large share, #184). Comment-only change. Relates to #158 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nop1P1pX4SrfRMHAernzQ
|
Review feedback addressed (commit
Local checks: Generated by Claude Code |
Rapport — CS-Cloud PR#182 runde 2 — head 504b514Status: Round 2 is done and CI is green on Tags: [T] test or CI result, [A] analysis, [K] taken from the PR description or the earlier comment, not re-run for this report. Head and commits
Tests
MutantsI re-ran four of the PR's mutants on a scratch copy of the head (
The schema-flag mutant (Scan re-reads the flags) is described in the PR text with 14 red subcases before the fix. I did not re-run it. [K] Benchmarks [K]Not re-run for this report. The PR description gives
CI on 504b514 (run 37110718094) [T]
Not verified
Generated by Claude Code |
Review — CS-Macmini re-review PR#182 runde 2 — head 504b514Dom: APPROVE med nits Independent re-review of round 2 ( Tags: [T] test or measurement I ran myself, [A] analysis of code or diff, [K] not checked. Head
Blocking findingsNone. Round 1 findings
Tests I ran
Mutants (my own, in the delta)Each mutant was run as the full
BenchmarksSetup: Apple M4. One test binary per revision, all with the same Auto benchtime, n=8:
The PR's claim "lock time −17 %, total about +12 % against baa68cd" holds in direction but not in size on this machine. Round 2 does shrink Pre-existing flake:
|
Brings the branch up to master 8e4b13d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mVL1VB4jtY7caZQnMWJpW
…the pks contract (#158) Review round 2 nits 3 and 4. - ingestAfterColsHook (test-only) runs right after the ingestCols snapshot in IngestNewFromDB and IngestNewObservations. The flag-flip test now flips the flag there as well as after the query, so a SELECT built from a fresh flag read while Scan uses the snapshot fails it (mutants M3c/M3d killed). - indexResolvedPathHops' caller contract: pks may also be the pubkeys of decodePersistedRelayPath, which drops nils and empties as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mVL1VB4jtY7caZQnMWJpW
Rapport — CS-cloud PR#182 runde 3 — head 287e853Status: Round-2 nits handled (1, 3, 4 fixed; 2 left with reasons), branch merged up to master, all local tests and CI green. Evidence tags: [T] test or CI run, [A] analysis, [K] taken from earlier and not rerun. Merge with master
Review nits
Commit identity of TestsOn head
CI on
|
| Job | Result |
|---|---|
| Go Build & Test | success |
| Playwright E2E Tests | success |
| Build & Publish Docker Image | success |
| Release Artifacts | skipped (not run for a PR) |
| Deploy Staging | skipped (not run for a PR) |
| Publish Badges & Summary | skipped (not run for a PR) |
[T] (read per job from the Actions API)
Not verified
- [K] Staging behaviour after deploy, as in rounds 1 and 2.
- [A] Benchmarks were not rerun this round: there is no production code change apart from the test hook, which is a nil check on each call.
Generated by Claude Code
Brings in #182 (9d29dae), #191, #194 and #196, so that the observer anchor and the backfill are tested against the current base. Master's server now indexes live observations from the persisted resolved_path (#182). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbNdgonR8kq7SPEU7LcmXD
Depends on #162 — stacked on baa68cd
Relates to #158
Relates to #184
Problem
After #162 (no more duplicate
byPathHopentries), staging still showed the sum oftraffic_share_scoreover/api/nodesrising with uptime: 14.34 at 2 h 36 m, 14.02 at 5 h 10 m, 20.67 at 18 h 6 m (no node at 1.0, so not the #158 duplicates).Cause
traffic_share_scorecounts, per relay, the transmissions indexed under its full pubkey inbyPathHop. Those entries come from two different resolvers, depending on how the transmission entered the store:indexResolvedPathHopsLoad,scanAndMergeChunkobservations.resolved_path, written by the ingestor (Kpa-clawbot#1547) withresolvePathWithContext(Kpa-clawbot#1560): an ambiguous prefix staysnullunless the neighbor graph gives exactly one adjacent candidateIngestNewFromDB,IngestNewObservationsresolvePathForObs→prefixMap.resolveWithContext, which always picks a candidate (affinity → geo proximity to the observer → GPS → observation count), and can pick a different one per observerIngestNewFromDBexplicitly did not selectresolved_path("On restart, Load() handles reading persisted resolved_path values", review item #7). That premise held while the server persisted its own resolution. Since Kpa-clawbot#1287/Kpa-clawbot#1547 the ingestor writes it with a different algorithm, so the same transmission counts for more relays when it arrives live than after a restart. The store mixes both halves, so the sum rises until retention (48 h) has replaced the loaded half, and drops back after every restart.Live evidence (staging, public API, read-only)
WebSocket broadcasts carry the server's per-observation resolution;
/api/packets?expand=observationscarries the ingestor's persisted one for the same rows. 8 minutes, 2,860 non-advert observations with a path, 21,651 hops:A model where the loaded part of the store (first_seen before the restart) contributes the ingestor count and the live part the server count (approximated by distinct hop prefixes) predicts 19.9 at 18 h;
/api/nodesshowed 20.79. With only ingestor resolution it would be 15.4.Part of the rise is real traffic: the live windows also have more observers per transmission (more floods), and with the ingestor's resolution alone the per-tx count is 13.4 (loaded windows) vs 17.9 (live windows).
Fix
indexObservationRelayHops(new,store.go) is the one place that feeds the relay-hop indexes from an observation. Load,scanAndMergeChunk,IngestNewFromDBandIngestNewObservationsall call it:resolved_path→indexResolvedPathHops(byNode, byPathHop, resolved index);NULL→path_jsonre-resolved on unique prefixes, into byNode only (as Load already did, bug(paths-through-node): 1-byte prefix collision resolves arbitrarily — attributes packets to wrong node Kpa-clawbot/CoreScope#1352 / PR fix(nodes): rebuild relay-hop history on startup from path_json Kpa-clawbot/CoreScope#1643).The two live queries now select
o.resolved_path(when the column exists). The ingestor writes it in the same INSERT, so it is there when the server polls. The WebSocket broadcast still carries the server's own resolution; only indexing changes.Round 2 (review follow-ups, no change to the behaviour above):
DB.ingestCols) for the SELECT list and the Scan destinations. Reading the atomics twice could give Scan the wrong destination count if the schema healer flipped a flag in between, and the swallowed error silently skipped the row.resolved_pathis decoded befores.muis taken on the live paths (decodePersistedRelayPath), and decoded straight into[]string.Load/LoadChunkedare corrected.Behaviour change
Coverage is partial, and this PR does not change that. The ingestor's resolver is conservative, so on staging only about 47 % of the observations that carry a path have a non-null
resolved_path(sample of 500 packets: 283 with a path, 132 non-null; inside the non-null rows only 4.3 % of hops are null, so it is mostly all-or-nothing per observation). About 53 % of relay traffic therefore gives live ingest byNode credit only (unique-prefix fallback) and no byPathHop or traffic-share credit, exactly as after a restart today.What the change does: it makes live and loaded numbers consistent, at the ingestor's conservative level. Live relay attribution (relay counts, traffic share, byNode activity of relays) for hops behind an ambiguous prefix now matches what a restart already showed. Nodes that were credited only through the server's tie-break lose that credit between restarts too. Traffic share will therefore read lower than the old live numbers; that is the consistency being restored, not a regression.
Relates to #184 for the coverage question (why ~53 % of observations stay NULL, whether the resolver or a backfill should close the gap) and for the possible startup race on
hasResolvedPath.The #158 fixture inserted live observations with
resolved_path = NULL("resolved fresh by the server on ingest"). It now writesresolved_pathin the same row, as the ingestor does; expectations are unchanged.Tests
TestTrafficShareLiveMatchesLoad_AmbiguousPrefix_158(live batch, late observation)resolved_path; live indexes the server's resolutionTestTrafficShareLiveMatchesLoad_LateObserverNewRelay_158resolved_pathnames a relay the first one lacked: live byPathHop and share == LoadIngestNewObservationsignoresresolved_path(it survived the tests above);IngestNewObservationsdoes not select the columnTestLiveIngestIndexesLikeLoad_NullResolvedPath_158resolved_path,IngestNewFromDB: byNode only, identical to loadTestLateObservationIndexesLikeLoad_NullResolvedPath_158IngestNewObservationsTestIngestSchemaFlagFlipBetweenQueryAndScan_158(14 subcases)TestDecodePersistedRelayPath_158extractResolvedPubkeys(unmarshalResolvedPath(s))Moving the decode out of the lock changes no behaviour, so no test fails if it is moved back; the benchmark below is the only guard.
cd cmd/server && go test ./...: ok.cd cmd/ingestor && go test ./...: ok.go test -race -count=10 -run '_158|_115|IngestNew|LoadChunked|ScanAndMerge|1352|1643': ok, no races.Performance
benchstat, n=10, interleaved runs, same test files on all sides. "base" is baa68cd plus the benchmark file of the first commit.IngestNewObservations_158(20 obs), timeIngestNewFromDB_158(20 tx × 11 obs), timeIngestNewFromDB_158, allocs/ops.mu,IngestNewFromDB_158s.mu,IngestNewObservations_158These percentages depend on the machine. The table above was measured on a linux/amd64 cloud container. An independent re-review on an Apple M4 (n=8, Mann-Whitney U) found the same direction but smaller sizes: the lock-held time of
IngestNewFromDB_158drops by 6.4 % against 9e3c519 (here −17 %), and its total time is +6.6 % against base (here +12 %).IngestNewObservations_158also depends on the benchmark's iteration count: it rotates over 511 observers, so with a large autob.Nmost iterations only hit the dedupe path (on the M4: +5.7 % with auto benchtime, −0.3 % with-benchtime=400x).Honest reading: the extra cost is one column, one
resolved_pathdecode and one index step per observation, because the server resolution still runs for the broadcast. Total time per call is unchanged by round 2 (the work moved, it did not go away); what shrank is the part spent under the lock forIngestNewFromDB. Lock time was measured in an instrumented copy (timestamps around the critical section), not in committed code. At staging rates (~4–5 observations/s on the WebSocket) the absolute cost is negligible. No new maps, and nomap[string]interface{}.Not verified
resolved_path = COALESCE(excluded.resolved_path, resolved_path)upsert can fill a row in later; live ingest reads the value present at first poll, Load reads the later one.IngestNewObservationstheresolved_pathdecode runs for every scanned row, outside the lock, including rows that are then skipped under the lock as already ingested (byObsID) or as an observer+path duplicate. Decoding only the surviving rows would need those checks before the decode, i.e. a second pass under the lock, so the small cost (one short JSON decode per row) is accepted.LoadandLoadChunkedstill read the optional-column flags twice (query and Scan), and the possible startup race onhasResolvedPathis not addressed here: both belong to ingestor resolves only ~half of relay paths (resolved_path NULL in ~53 % of observations); possible hasResolvedPath startup race #184.TestHandleNodePaths_FallbackPreconfirmed_1352fails with a 503 "index loading" under heavy CPU load, on 9e3c519 as well (itsreloadStoredoes not wait for the index build). Not touched here.🤖 Generated with Claude Code
https://claude.ai/code/session_017nop1P1pX4SrfRMHAernzQ
Generated by Claude Code