Repository navigation
fix(store): accurate trackedBytes for paths, caches and resolved relay hops (#113, #164) - #198
Conversation
…resolved relays (#113, #164) The invariant, checked after Load, after eviction and after every step of a mixed insert / extra-observation / resolved-relay / eviction sequence: trackedBytes == sum over live tx of chargedBytes + resolvedRelayBytes(len(pathHopResolved[tx])) + obs charges with chargedBytes == estimateStoreTxBytes(tx) (no stale charge). Further tests: eviction subtracts the charge and not a fresh estimate, a recharge covers a path assigned later, resolved relays are charged per new key and credited when a rebuild clears their records, a memory pass reaches the low watermark, the cold-load estimate stays in line with the real charge, and trackedBytes is within 0.8-1.3 of the heap growth of a 20k-tx Load (master: 0.65). Inert scaffolding so the tests compile and fail on assertions: StoreTx gets chargedBytes (uint32) in the 4 bytes UniqueObserverCount gives up by becoming int32, so unsafe.Sizeof(StoreTx{}) stays 320, and rechargeTx / resolvedRelayBytes are stubs that return 0. The next commit implements them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ct what was charged (#113, #164) trackedBytes undercounted the store, so packetStore.maxMemoryMB eviction fired late: a tx was charged when created, before pickBestObservation gave it a path (so byPathHop and the O(path^2) spTxIndex entries were never charged), eviction then re-estimated with the path known and subtracted more than had been added, and several allocations were not estimated at all. - StoreTx.chargedBytes records what a tx was charged (uint32, in the 4 bytes UniqueObserverCount gave up by becoming int32; unsafe.Sizeof stays 320). rechargeTx returns the delta and runs at creation and after every pickBestObservation (Load, loadChunk, LoadChunked, the background pass in main, IngestNewFromDB, IngestNewObservations). - Resolved relay hops (#164) arrive after insertion. Their charge is resolvedRelayBytes(len(pathHopResolved[tx])): addResolvedPubkeysToPathHopIndex charges the growth, retainResolvedPathHops credits records it drops, and eviction derives the same figure from the record length. - Eviction (candidate selection and the removal pass) subtracts txChargedBytes, never a fresh estimate. - New estimate terms: the decode cache (4x DecodedJSON, charged up front), the obsKeys dedup key, the observation strings, and a per-record and per-key allowance for resolved relays calibrated by TestTrackedBytesVsHeap_Measure_113. estimateStoreTxBytesTypical (the cold-load budget) follows. Idea and the decode-cache / dedup-key terms from Kpa-clawbot/CoreScope e6323ec (upstream PR 2035); the per-tx record, the resolved-relay charge and the layout trick are specific to this fork. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…hes (#113) docs/go-migration.md still said the Go server ignores packetStore.maxMemoryMB, which has not been true for a long time: it caps the cold load, drives oldest-first eviction down to 85%, and derives GOMEMLIMIT when that is unset. It now says what the budget counts after #113 and #164 and that the estimate grew, so a budget that held the whole store before may now evict and the cold load admits fewer packets. docs/deployment.md gets the same sentence the upstream fix added, extended for the path indexes and resolved relays. Wording of the go-migration section adapted from Kpa-clawbot/CoreScope e6323ec (upstream PR 2035), checked against this fork's main.go and store.go. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s benchmarks (#113, #164) A rebuild must credit the record of a tx that is not in the store (loadChunk indexes a batch's resolved relays before publishing it), and the eviction candidates must be the tx the pass evicts. Two mutants of the fix survived without these. Benchmarks (production entry points only, so they also run on master): cold Load of 20k tx, IngestNewFromDB of 200 new tx, IngestNewObservations of 200 longer paths, and a retention pass over a 20k store. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ransmissions (#164) The marginal heap of a resolved relay is 110-170 B per pathHopResolved record plus 85-105 B per key, depending on the store size (map growth steps), so the allowance is the midpoint: 140 B per record and 96 B per key. On the synthetic store trackedBytes is now 1.02-1.04 of the heap at 100k transmissions and 1.00-1.02 at 500k, with 1-4 resolved relays per tx. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ting tests touch a tx (#113) Load starts the path-hop and subpath index builds in the background; they read every tx. TestTrackedBytes_EvictionIgnoresAFreshEstimate_113 mutated one while they ran, which the race detector flagged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Rapport — CS-MacBook PR#198 #164+#113 trackedBytes — head 97e0304Status: Both issues are implemented and verified locally on the tree merged with master (which now includes #182); the draft PR is open and CI had not finished when this was posted. Draft PR for #113 and #164: 7 commits plus a merge of master on Evidence tags: [T] test, benchmark, measurement or CI I ran, [A] analysis, [K] known, not run again. #113 acceptance criteria
#164 acceptance criteria
Memory: trackedBytes against the real heap [T]Synthetic SQLite store loaded through
(tracked / heap; intermediate rows are in the PR description.) At 100k with 4 relays per tx that is 166 MB tracked against 328 MB of heap on master and 330 MB against 319 MB on this branch. With the decode cache cold the branch is at 1.25: it charges the cache up front, so eviction starts a little early rather than late. Perf [T]Interleaved master/branch, 3 rounds. Cold Tests
Mutants [T]13 mutants, each caught; the table with test names is in the PR description. Two survived the first set of tests (the rebuild credit for a record whose tx is outside the store, and candidate selection using the estimate); I added CIPending when this was posted (Go Build & Test had started). I did not poll it, so per-job results are not in this report; they need a follow-up check on the PR. Differences from upstream 2035(Upstream = Known leftovers
|
Review — CS-Macmini PR#198 trackedBytes — head 97e0304Verdict: APPROVE with nits. The accounting is correct on every charge/credit path I could find, and the tracked/heap ratio reproduces (0.61 → 1.03 at 100k). The main nit: the production load path ( The review was read-only. It used Evidence tags: [T] test, CI or measurement run here, [A] analysis, [K] taken from the author's report and not rerun. Findings
1. Accounting correctness
2. Interplay with #182 (
|
| Mutant | Result |
|---|---|
M1 no recharge after IngestNewFromDB's pick |
red: TestTrackedBytes_MixedSequenceDoesNotDrift_113 |
M2 no recharge after loadChunk's local pick |
survives; red with probe 2 (F1) |
M3 no recharge after LoadChunked's pick |
survives; red with probes 1 and 2 (F1) |
| M4 late observation charges the full estimate instead of the delta | red: …MixedSequenceDoesNotDrift_113 |
M5 the new observation strings dropped from estimateStoreObsBytes |
survives (F3) |
M6 perResolvedRecordBytes = 0 |
survives (F3) |
The author's list already covers "forget the credit in retainResolvedPathHops" and "recharge only at creation" in their variants, so I did not repeat them.
Probe (scratch only), using the PR's own helpers:
store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 168, HotStartupHours: 2})
store.LoadChunked(100); store.WaitIndexesReady(30 * time.Second)
store.loadChunk(now.Add(-100*time.Hour), now.Add(-2*time.Hour)) // acct113CreateDB(t, p, 400, 200)
acct113Check(t, store, "after loadChunk")5. Perf and memory
[T] TestTrackedBytesVsHeap_Measure_113 with CORESCOPE_TRACKED_BYTES_MEASURE=100000, the same harness on master (copied in) and on the merged tree:
| 100k tx, 250k obs | master tracked/heap | merged tracked/heap |
|---|---|---|
| no relays, decode cache cold | 166 / 213 MB = 0.78 | 280 / 224 MB = 1.25 |
| no relays, decode cache warm | 166 / 275 MB = 0.61 | 280 / 271 MB = 1.03 |
| 1.0 relays per record | 0.56 | 1.05 |
| 1.9 relays per record | 0.55 | 1.05 |
| 4.0 relays per record | 0.52 | 1.05 |
This matches the author's 0.51–0.60 → 1.02–1.04 within 0.01–0.02.
[T] Benchmarks, test binaries built once, 3 interleaved rounds, -benchtime 5x. Apple M4, load average 3.4–4.6. Median [min–max]:
| Benchmark | master | merged | change |
|---|---|---|---|
TrackedBytes_Load_113 (20k tx) |
366.3 ms [365.6–369.1] | 384.1 ms [371.8–384.7] | +5 % |
TrackedBytes_IngestNewTx_113 |
51.2 ms [49.1–54.9] | 51.3 ms [49.8–53.5] | 0 % |
TrackedBytes_IngestNewObs_113 |
3.4 ms [3.4–3.4] | 3.8 ms [3.5–3.9] | +11 % |
TrackedBytes_Evict_113 (10k of 20k) |
280.4 ms [279.6–281.4] | 283.7 ms [278.2–289.3] | +1 % |
[A] Hot paths:
addResolvedPubkeysToPathHopIndex: one subtraction per call, O(1).rechargeTx: one estimate per picked tx, O(path).- Eviction: one map lookup per tx.
retainResolvedPathHops: the credit loop is O(records) and runs only at a rebuild.
No O(n²), and the eviction order is unchanged. The IngestNewObs +0.4 ms per 200 observations is the recharge's path re-parse (F2).
6. Operational impact
[A] The high watermark is maxMemoryMB, the low one 85 %. The cold-load budget is maxMemoryMB / estimateStoreTxBytesTypical(10), which is now 8506 B.
Prod (1800 MB, 48 h, about 38K packets, trackedMB about 344 today).
- At master's ratio of about 0.5–0.6, that is roughly 570–690 MB of real store heap.
- With this PR,
trackedMBshould read about 600–860 MB (1.03 warm to 1.25 cold). - Far below 1800, so no memory eviction; retention binds first.
- The cold-load budget is 1800 MiB / 8506 B ≈ 222K packets, against about 38K, so it does not bind.
The larger 168 h instance (5000 MB).
- Budget ≈ 616K packets.
- At prod's rate (about 38K per 48 h), 168 h is about 133K packets and about 2.1–3.0 GB tracked: below 5000, but the closest of the two (about 40–60 %).
Caveat: the factor and allowances are synthetic. Real decoded_json is larger, which raises the charge, so compare /api/perf packetStore.trackedMB after deploy.
[A] The docs are correct against the code:
- the cold load stops at the budget (
maxPackets); - eviction runs oldest-first down to 85 %;
GOMEMLIMITis derived with 1.5× headroom when neitherGOMEMLIMITnorruntime.maxMemoryMBis set (main.go:139);/api/perfexposespacketStore.trackedMBandmaxMB.
7. Rules
- [T] Added
map[string]interface{}: 0 (the one match is a comment). - [T]
cmd/serverstays read-only: all 13 added SQL writes are in test fixtures. - [T]
deploy.ymlis unchanged, with 9 fork guards before and after. - [T]
go vetandgofmtas above;test-all.sh201/201. - [T] The PR text has no closing keywords. Upstream is credited in code format (
Kpa-clawbot/CoreScopePR2035,e6323ec5), not as a link.
Not verified
- Real data, staging or production: the ratio and constants are synthetic.
- The larger instance's real packet count.
- Measurements through the production
LoadChunkedpath, and at 500k. - A second full
-racerun (one run, F4). - The upstream 2035 diff in detail: I relied on the PR's comparison table for upstream's side [K].
- Playwright CI on head (still running).
…te terms (#113, #164) Review of this PR found that every invariant test built the store with Load(), while production starts through RunStartupLoad: LoadChunked, and with hotStartupHours > 0 the loadChunk background fill. Two mutants of the fix survived there: no recharge after the pick in LoadChunked, and none in loadChunk's local batch. - TestTrackedBytes_LoadChunkedChargesPathAndResolvedRelays_113 runs the startup load without a hot window; the background-fill test runs it with hotStartupHours = 2 over a store half of whose rows are older, then evicts the filled half. Both check the accounting invariant. - TestEstimateStoreObsBytes_CountsEveryTerm_113 pins each new observation string (one byte moves the estimate by one, or two for the observer ID and path, which are also in the dedup key); the resolved-relay allowance test pins its record and key parts and their physical floors. Dropping the five observation strings, or zeroing either allowance, survived before. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Rapport — CS-MacBook PR#198 runde 2 — head 4d0a84fStatus: F1, F2 and F3 are fixed, F4 to F6 are documented in the PR text; all local checks pass on the pushed head and CI was still pending when this was posted. One new commit on top of Evidence tags: [T] test, mutant or measurement I ran, [A] analysis, [K] taken from the review and not rerun. Findings
Mutants [T]Each on a copy of the branch, run against the accounting, estimate, layout, eviction and tracked-bytes tests.
The thirteen mutants of round 1 are unchanged and stay red. Local checks on the pushed head [T]
CIPending when this was posted (Go Build & Test had started). I did not poll it; per-job results are not in this report. Not doneNothing beyond the list: F5 and F6 are not fixed, and the memory measurement still goes through |
Relates to #113, #164
trackedBytesdrivespacketStore.maxMemoryMBeviction. It undercounted the store (to about 0.5-0.6 of the real heap on a synthetic store), so eviction fired late, and it drifted downward as the store ran. This PR makes it exact for what it charges, charges what it missed, and removes exactly what it charged. Server only; no ingestor, schema or frontend change.Plan and design
StoreTx.chargedBytesrecords what a tx was charged.rechargeTx(tx)returns the delta against a fresh estimate and runs at creation and after everypickBestObservation(Load, loadChunk, LoadChunked, the background pass inmain.go,IngestNewFromDB,IngestNewObservations: all six pick sites).chargedBytes. Their charge isresolvedRelayBytes(len(pathHopResolved[tx])), a function of the side-map record length only:addResolvedPubkeysToPathHopIndexcharges the growth (O(1) per appended key);retainResolvedPathHopscredits the records it drops (non-live txs, orclearwhen there is no previous index);txChargedBytes(tx) = chargedBytes + resolvedRelayBytes(len(record)). It never re-estimates. The eviction order is unchanged.DecodedJSON, charged up front), theobsKeysdedup key, the observation strings, and a resolved-relay allowance.estimateStoreTxBytesTypical(the cold-load budget) and both docs follow.The invariant, asserted after Load, after eviction and after every step of a mixed sequence:
with
chargedBytes == estimateStoreTxBytes(tx)for every live tx (no stale charge).StoreTxlayout contract: unchanged, 320 bytesStoreTxhas no spare padding (320 = 40 x 8, fields are packed). A per-tx charge field therefore needs bytes from somewhere:UniqueObserverCountgoes frominttoint32(8 uses, one read, which feeds a JSON number);chargedBytes uint32(a tx costs a few KB, andrechargeTxclamps atMaxUint32).TestStoreTxLayoutFitsRouteMaskInPaddingandTestStoreTxLayoutFitsObservedPathHashSizeMaskInPaddingpass unchanged.Differences from upstream
Idea and the decode-cache and dedup-key terms are from
Kpa-clawbot/CoreScopePR2035(commite6323ec5), not cherry-picked. What differs, and why:accountedBytes int64(growsStoreTxby 8 bytes)chargedBytes uint32in the bytes freed by narrowingUniqueObserverCount; the fork pins 320 bytesretainResolvedPathHops)main.gobackground passTests, red before and green after
Fifteen new tests in
tracked_bytes_accounting_113_test.go, built on a SQLite store, plus a gated measurement test. Eleven load it throughLoad(); two go throughRunStartupLoad(LoadChunked, and with a hot window theloadChunkbackground fill), which is how production starts (round 2); two pin the estimate terms. Commit 1 holds the tests and inert scaffolding (the field, and two stubs that return 0), so they fail on assertions: for exampleafter Load: 400 of 400 tx carry a stale charge,trackedBytes = 697012, charges sum to 308000, andtrackedBytes is 0.65 of the real heap growth. The fix is commit 2.TestTrackedBytes_LoadChargesPathAndResolvedRelays_113TestTrackedBytes_EvictionSubtractsExactlyWhatWasCharged_113TestTrackedBytes_EvictionIgnoresAFreshEstimate_113TestTrackedBytes_RechargeCoversALaterPath_113TestTrackedBytes_ResolvedRelaysAreChargedIncrementally_164TestTrackedBytes_RebuildCreditsRecordsOfTxNotInTheStore_164TestTrackedBytes_MixedSequenceDoesNotDrift_113TestMemoryEviction_CrossesHighAndReachesLow_113TestEvictionCandidatesMatchWhatIsEvicted_113TestEstimateStoreTxBytesTypical_TracksTheRealCharge_113TestTrackedBytesTracksTheHeap_113TestTrackedBytes_LoadChunkedChargesPathAndResolvedRelays_113LoadChunked)TestTrackedBytes_BackgroundFillChargesPathAndResolvedRelays_113LoadChunked, the rest throughloadChunk; invariant, then eviction of the filled halfTestEstimateStoreObsBytes_CountsEveryTerm_113TestResolvedRelayBytes_AllowanceHasARecordAndAKeyPart_164Results on the merged tree (this branch plus master
6f7a5e7c, which includes #182 and #194):cmd/server:go test -race -count=1 ./...passed once in full on the merged tree (374 s, round 1); after round 2,go test ./...passes and the targeted accounting, estimate, layout and eviction tests pass with-race -count=3.cmd/ingestor:go test ./...passes (unchanged by this PR).go vet ./...is clean;gofmt -lis clean for every file this PR adds or changes (eviction_test.gowas already unformatted on master and is only touched on three lines).sh test-all.sh: 201 passed, 0 failed (201 files).map[string]interface{},cmd/serverstays read-only,deploy.ymlis untouched.Mutants (each in a copy of the branch, targeted tests)
IngestNewObservationspicks a pathTestTrackedBytes_MixedSequenceDoesNotDrift_113…LoadChargesPathAndResolvedRelays_113,TestTrackedBytesTracksTheHeap_113TestTrackedBytes_EvictionIgnoresAFreshEstimate_113…ResolvedRelaysAreChargedIncrementally_164…ResolvedRelaysAreChargedIncrementally_164…RebuildCreditsRecordsOfTxNotInTheStore_164…EvictionSubtractsExactlyWhatWasCharged_113rechargeTxforgets the chargeTestMemoryEviction_CrossesHighAndReachesLow_113TestMemoryEviction_CrossesHighAndReachesLow_113…MixedSequenceDoesNotDrift_113TestEstimateStoreTxBytes,TestEstimateStoreTxBytesTypical_…_113TestEstimateStoreObsBytes,TestEstimateStoreTxBytesTypical_…_113Round 1: two mutants survived the first set of tests (the rebuild credit for a tx outside the store, and candidate selection); the two tests above them were added for those.
Round 2 (review by CS-Macmini): four more survived because every invariant test loaded through
Load(), or pinned only the heap-ratio tolerance. Each is now red:LoadChunked…LoadChunkedChargesPathAndResolvedRelays_113,…BackgroundFillChargesPathAndResolvedRelays_113loadChunk's local batch…BackgroundFillChargesPathAndResolvedRelays_113estimateStoreObsBytes…EstimateStoreObsBytes_CountsEveryTerm_113perResolvedRecordBytes = 0…AllowanceHasARecordAndAKeyPart_164perResolvedRelayBytes = 0(extra)…AllowanceHasARecordAndAKeyPart_164,…ResolvedRelaysAreChargedIncrementally_164One more mutant, dropping the charge at creation in
scanAndMergeChunk, survives and is equivalent: the recharge after the pick charges the whole difference from zero.Memory: trackedBytes against the real heap
TestTrackedBytesVsHeap_Measure_113(gated byCORESCOPE_TRACKED_BYTES_MEASURE=100000,500000) loads a synthetic SQLite store throughLoad()(2.5 observations per tx, 1-5 hop paths, 60 relay pubkeys, 0-4 resolved relays per tx), forces a GC on both sides, and comparestrackedByteswith theHeapAllocgrowth. "Warm" fills the decode cache the way analytics do. The same harness runs on master.With the decode cache still cold, this PR is at 1.25 (it charges the cache up front, as upstream does, so eviction starts a little early rather than late). The gap went from 0.49-0.60 to 1.00-1.04. The resolved-relay allowance is the midpoint of two scales: the marginal heap per record plus keys was 110-170 B and 85-105 B per key at 100k and 500k.
Perf
Interleaved master/branch benchmarks, three rounds each. The numbers depend on the machine and its load, so two independent measurements are shown. Neither is a clear win or loss across the board, and no gain is claimed.
-benchtime 5x) master → this PR-benchtime 5x) master → this PRLoad, 20k txIngestNewFromDB, 200 txIngestNewObservations, 200 obsRunEviction, 10k of 20kIngestNewFromDBdifference in the author's runs does not reproduce on the second machine. That benchmark is dominated by SQLite (about 85% of a CPU profile), so it is not evidence for or against the change.rechargeTxper updated tx: a path re-parse (about 0.5 µs, 14 allocations), O(path), once perpickBestObservation. At 200 observations that is at most about 0.4 ms (the reviewer's +11%); onLoadit is the +2-5%.addResolvedPubkeysToPathHopIndex176-184 ns before and 178-191 ns after in the steady state (0 allocations), 254-257 vs 255-259 ns when it appends three keys;rechargeTxandestimateStoreTxBytesalone are within noise of each other. The accounting in the resolved-relay path is O(1) per appended key.Operator impact
maxMemoryMBis set, eviction now caps the real store size. A store that fitted before may now evict.estimateStoreTxBytesTypical(10), goes from 5478 to 8506 bytes, so the samemaxMemoryMBadmits about 36% fewer packets at startup.docs/go-migration.mdstill said the Go server ignores the setting; corrected, anddocs/deployment.mdstates what the budget counts.Interplay with #182 (now merged)
#182 landed on master while this was in progress, so this branch contains a merge of it (clean, no conflicts). It changes how live observations are indexed from
resolved_pathbut still feedsindexResolvedPathHops/addResolvedPubkeysToPathHopIndex, where the charge is taken, and adds no pick site. All of this PR's invariant tests and the full-racesuite run on the merged tree.Known leftovers
decoded_jsonis larger and the real resolved relays per tx are unknown here (no staging or prod access). ComparepacketStore.trackedMBwith the Go heap on staging before relying on the exact ratio.hash_migrate.go) merges a duplicate transmission it appends the duplicate's observations to the survivor without removing them from the duplicate, which stays ins.packets. Both are evicted later, so those observations are subtracted twice. The total drifts downward, hidden by the clamp at 0. It is the one path where the invariant above does not hold, and it behaves the same on master. Worth its own issue.resolved_path, relays found by thepath_jsonfallback (resolvePathForObsColdLoad) go intobyNodeonly and are not charged. Small.Load()only; it has not been repeated throughLoadChunkedor at 500k with the production startup path. The invariant tests do cover that path now.TestIssue1008_HandlerReturns503WhileSubpathIndexLoadingfailed once in a full-racerun on a machine with a load average of about 38 (the subpath index was already built when the request arrived), then passed 20 of 20 in isolation on this branch and on master. A timing race between a background index build and the request.GOMEMLIMITbehaviour under the larger estimate.Verification not done
No deploy, no live-environment measurement, no CI result at the time of writing.
🤖 Generated with Claude Code