Skip to content

History coverage: prove retained history instead of exact load ratio - #511

Merged
dborup merged 7 commits into
masterfrom
fix/509-history-coverage-ratio
Oct 20, 2026
Merged

dborup merged 7 commits into
masterfrom
fix/509-history-coverage-ratio

Conversation

@dborup-agent

@dborup-agent dborup-agent commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #509

Problem

retainedHistoryCoverageStartLocked (node activity24h / My Mesh, Regions coverage) returned an unknown start unless loadCoverageRatio was exactly 1. The #494 background-fill analytics coverage used the same comparison (len(s.packets) < totalInDB → load_failed).

The ratio divides the rows in memory after loading by a COUNT(*) taken before loading. The ratio drops below 1 in three ways while startup is running, and stays there until the next restart:

  1. Retention eviction during the load. The loaders select rows by last_seen, but retention evicts by FirstSeen. A long-lived, re-heard transmission (old FirstSeen, recent last_seen) is counted and loaded. The next eviction tick then removes it. The eviction ticker starts at the first chunk, so this happens on both startup paths.
  2. Memory-cap eviction during the load.
  3. Database retention. The ingestor prunes by first_seen and can delete counted rows before the background loader reaches their window.

It is intermittent because it depends on whether a tick lands inside the load. That fits staging's 0.9989 on one restart and exactly 1 on others.

The analytics side has a second defect. The eviction edge folded in h.from, which is still the provisional hot-window floor while the background fill runs. One retention eviction of an old re-heard row therefore shrank the final analytics coverage to the hot window.

Plan and change

The exact-ratio gate is replaced by a proof that runs once after a successful startup load (cmd/server/history_coverage.go):

  • Edge = load floor (oldestLoaded), moved past the newest evicted FirstSeen (+1 ns), raised to the retention floor when retentionHours > 0, and rounded up to a whole second. Rows that retention or eviction removed lie below the edge, so they are not part of the claim. The retention floor matters with hotStartupHours = 0, where oldestLoaded is packets[0].FirstSeen and a loaded re-heard row can put it below the floor; the DB rows between the two were never meant to be loaded. now is taken once per verification, so only an eviction moves the edge between the two reads.
  • Proof: SELECT COUNT(*) FROM transmissions WHERE first_seen >= edge AND id <= maxTxID must equal the in-memory count under the same predicate. Pruned rows are no longer counted. Rows above maxTxID have not been ingested yet. first_seen is compared as a string on both sides, against a zone-less whole-second prefix (the My Mesh: accurate 24h node activity and compact repeater observer cards #351 F6 reasoning), so the string test equals the instant test.
  • Conservative: a capped or failed load is never verified. Any other mismatch (for example a row the loader never read) keeps coverage unknown. This is stricter than before: the old gate certified a missing last_seen = 0 row (see test below).
  • Re-heard transmissions (background path): the windows select by last_seen, and the ingestor moves last_seen to the time of each new observation. A transmission heard again before its background window is read, or after the hot cursor passed it, falls out of every window still to be read, and the poller skips observations of transmissions that are not in memory. It stayed missing until the next restart, and the proof correctly refused the history. This is the mechanism the staging-shape test reproduces locally; staging itself still has to confirm it. LoadChunked now records the highest observation id when the load begins. After the last window, loadReheardTransmissions loads the transmissions observed since then (id <= maxTxID) that are not in memory, through the same merge as a window (loadChunkWhere). Ids above maxTxID stay with the poller, so it still ingests and broadcasts them as new. Errors only leave rows missing, which the proof then reports as unknown.
  • The retention floor also bounds the edge on the background path. There oldestLoaded ends at the floor taken when the fill started, so rows that the ingestor prunes in the sliver before verification are not counted as surplus.
  • Locking (perf(store): move runtime SQL outside packet-store locks #495): the SQL count runs without s.mu; only the in-memory reads take RLock. If an eviction moves the edge between the two counts, the check is repeated against the new edge (max 3 attempts). If all attempts fail, coverage stays unknown.
  • The result is stored in retainedHistoryFrom (guarded by mu). retainedHistoryCoverageStartLocked uses it instead of the ratio, so callers no longer pass loadCoverage. The memory-cap clamp (packets[0]) and the retention floor are unchanged.
  • fix(nodes): calculate analytics over available retained history #494 analytics: the background path marks the load failed when it is unverified, instead of when len < count. evictedFrom now records only the eviction boundary.
  • nodeActivityCoverageStartLocked keeps the 24h contract. Unlimited retention with a hot window shorter than 24h stays unknown; the rewritten condition is equivalent to the old one.
  • loadCoverageRatio, the 90% done gate and /api/perf are unchanged.

Callers checked

  • GetNodeHealth → nodeActivityCoverageStartLocked (activity24h, My Mesh).
  • computeRepeaterRelaySnapshot → retainedHistoryCoverageStartLocked. Its snapshot feeds /api/regions/observed and /api/regions/history (coverageStart).
  • nodeAnalyticsCoverageLocked (fix(nodes): calculate analytics over available retained history #494) via analyticsHistory.failed / evictedFrom.

The verification runs on all three startup terminal paths: background fill, hotStartupHours = 0, and unlimited retention with a hot window.

Performance

The check runs once per startup, not per request. It does one indexed COUNT(*) over the retention window, which is the same order as the existing startup count, and runs outside the lock. In memory it does one binary search plus a suffix scan of s.packets under RLock: O(log n + k) integer and string comparisons for k retained rows. The re-heard step also runs once per startup, after the last background window: one MAX(id) primary-key lookup when the load begins, and one SELECT DISTINCT transmission_id FROM observations WHERE id > ? range scan over the observations written during the load (rowid range). Under RLock it does one map lookup per distinct id, and the missing ones are loaded in batches of 500 ids with the window loader's merge, outside the lock except for the merge itself. Its size is bounded by the observations written during the startup load. Nothing is added to the ingest, eviction or request hot paths beyond one time comparison per evicted transmission.

Tests

cmd/server/history_coverage_509_test.go drives real SQLite startup loads:

Test Scenario
RetentionEvictionDuringBackgroundLoad Retention tick between background chunks evicts re-heard rows (ratio 0.9756). Retained, activity24h and analytics coverage are checked.
RetentionEvictionAtBackgroundLoadStart Tick right before the background count.
RetentionEvictionDuringSynchronousLoad hotStartupHours = 0, tick while LoadChunked runs.
DatabaseRetentionDuringBackgroundLoad Counted rows deleted from the DB before their window is loaded.
MemoryEvictionDuringBackgroundLoad Memory-cap eviction mid-load. Coverage must start after the newest evicted row, although older rows were merged back at packets[0].
UnloadedRowKeepsCoverageUnknown A row the loader never read keeps coverage unknown (both paths) and marks background analytics load_failed.
EvictionBetweenCountsRetries Eviction between the SQL and in-memory counts repeats the check.
ConcurrentEvictionAndReaders Eviction and coverage readers run concurrently with the load (-race).
EdgeRefusesCappedOrFailedLoad Edge rounding; capped and failed loads are not verifiable.
DBRowsBelowRetentionFloorNoTick DB holds rows older than retention plus re-heard rows, no tick during the load (both paths). Every retained row is in memory, so coverage is known.
ConcurrentIngestAtRetentionEdge Ingest during the load and a poller tick between the two counts, with DB rows two minutes either side of the retention floor (both paths). Pins id <= maxID on both counts.
EdgeFlooredAtRetention The edge is raised to the retention floor; an eviction edge above it still wins; unlimited retention has no floor.
StagingShapeBackgroundLoad Staging configuration: retention 48h, hot 6h, memory cap, background fill, with retention and memory-cap ticks, the poller and new ingest during the load. The control (no re-hearing) proves the history; with transmissions re-heard behind the hot cursor and before the oldest window, they must be in memory with both observations and coverage must be known. The newest row stays with the poller.
BackgroundEdgeFlooredAtRetention Background path: rows that the ingestor prunes in the sliver between the background floor and the verification floor are not surplus (pins the floor, round-2 mutant R2).

The first commit adds the scenario tests against unchanged code, where 6 of them fail. Review round 2 (F1, F2) follows the same pattern: 5b061973 adds the two integration tests (red on the round-1 code), 9f2d9ff0 adds the fix and the edge unit test. The second commit contains the fix and the tests for the new helpers. Round 3 does the same: 3294f958 adds the staging-shape test (red on 9f2d9ff0, "1871 of 1875") and the background floor pin, and the next commit adds the re-heard step. Existing unit tests that set loadCoverageRatio = 1 now set the verified start.

The verification evidence (red-before, mutants, suites, E2E) is posted as a comment on this PR.

🤖 Generated with Claude Code

dborup and others added 2 commits October 20, 2026 04:55
…viction (#509)

Retention or memory eviction while the startup load runs, and database
retention deleting counted rows before the loader reaches them, leave
loadCoverageRatio below 1, so node activity24h, Regions coverage and the
#494 analytics coverage stay unknown or load_failed until a restart. Also
pin that a row the loader never read keeps coverage unknown, which the
current ratio gate certifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exact load ratio (#509)

Coverage for node activity24h, Regions and the #494 analytics no longer
requires loadCoverageRatio == 1. That ratio divides the rows in memory after
loading by a count taken before it, so rows that retention or eviction
removed meanwhile, or that database retention pruned, read as missing until
the next restart.

After a successful startup load, verifyRetainedHistory compares the DB count
of transmissions with first_seen at or after the edge (and id <= maxTxID)
with the in-memory count. The edge is the load floor moved past the newest
evicted row, so evicted rows are not part of the claim; pruned rows are no
longer counted. A capped or failed load is never verified, and any other
mismatch keeps coverage unknown. The SQL count runs without s.mu (#495); an
eviction between the two counts repeats the check.

The analytics eviction edge now records only the eviction boundary, so one
retention eviction of an old re-heard row during the background fill no
longer shrinks analytics coverage to the hot window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#511 #509 — head 3650261

Status: Draft. Every requirement has a test that was red on unchanged code and is green now. All 14 mutants are killed and CI is green on every job. Not verified on staging; that is for the coordinator.

Evidence labels: [T] reproduced by a test in this run · [A] analysis of source/firmware/code paths · [K] known/pre-existing, not caused by this PR.

Root cause (investigation)

  • [T] The startup loaders select rows by last_seen, but retention evicts by FirstSeen. A re-heard transmission (60h-old FirstSeen, last_seen inside the 48h window) is counted, loaded, and then evicted by the next tick. The ratio is computed once at the end, as len(s.packets)/totalInDB, so it ends below 1 (0.9756 in the fixture) and stays there until a restart.
  • [A] The eviction ticker starts at the first chunk (main.go), so this applies to both hotStartupHours = 0 and the background fill. Whether a tick lands inside the load is timing-dependent, which matches the intermittent 0.9989 / 1.0 seen across restarts.
  • [T] Database retention (the ingestor prunes by first_seen) can delete counted rows before their window is read. Memory-cap eviction mid-load has the same effect on the ratio.
  • [T] fix(nodes): calculate analytics over available retained history #494 analytics had the same defect: the background path set load_failed (0 s coverage) when len < count. Separately, evictedFrom folded in the provisional hot-window floor, so one eviction of an old row shrank analytics coverage to the hot window.
  • [T] The old gate also certified coverage while a window row was missing. A last_seen = 0 row is not counted by the last_seen filter, so the ratio was 1.

Requirements

Requirement Test(s) Red before [T] Mutant(s) killed [T]
Retention eviction during the background load must not count as missing TestCoverage509RetentionEvictionDuringBackgroundLoad, …AtBackgroundLoadStart coverage null, ratio 0.9756 M1 bg path never verified
Same for the synchronous path (hotStartupHours = 0) TestCoverage509RetentionEvictionDuringSynchronousLoad coverage null M2 sync path not verified
Rows pruned by DB retention during the load are not missing TestCoverage509DatabaseRetentionDuringBackgroundLoad coverage null M3 count ignores the first_seen edge
Memory eviction: no claim before the newest evicted row TestCoverage509MemoryEvictionDuringBackgroundLoad coverage null M5 eviction edge ignored
Coverage that does not exist is never claimed (row never loaded) TestCoverage509UnloadedRowKeepsCoverageUnknown hot=0 certified a missing row M4 undercount accepted, M8 count check off, M12 unverified state accepted
Capped load stays conservative TestRegionsObservedCappedHotStartupCoverageIntegration, TestCoverage509EdgeRefusesCappedOrFailedLoad n/a (new gate) M13 cap gate off
Failed load stays conservative TestRetainedHistoryCoverageForRegions, TestNodeActivity24hCoverageRequiresLoadedHistory, …EdgeRefusesCappedOrFailedLoad n/a (existing + new gate) M7 failed flag ignored, M14 hot-path failed gate off
Eviction between the two counts repeats the check TestCoverage509EvictionBetweenCountsRetries n/a (new code) M6 no retry
#494 analytics: not load_failed, not shrunk to the hot window TestCoverage509RetentionEvictionDuringBackgroundLoad (analytics assertion) load_failed, 0 s M9 old len < count clause, M10 edge folds provisional floor
24h activity contract unchanged TestNodeActivity24hCoverageRequiresLoadedHistory n/a (unchanged behaviour) M11 short hot window passes
#495 lock rule: no SQL under PacketStore.mu TestNoSQLUnderStoreLock (lexical scan includes the new functions), TestCoverage509ConcurrentEvictionAndReaders n/a covered by -race run below

The red-before evidence comes from the first commit 7540c378: the scenario tests run against unchanged origin/master code, and 6 of them fail. The analytics assertion was also run on its own on base, and failed with Reason: load_failed, Seconds: 0. The retry and edge tests exercise helpers that do not exist on base, so their red state is shown by mutants M6, M13 and M14.

Callers checked [A]

  • GetNodeHealth → nodeActivityCoverageStartLocked (activity24h, My Mesh).
  • computeRepeaterRelaySnapshot → retainedHistoryCoverageStartLocked (/api/regions/observed, /api/regions/history).
  • nodeAnalyticsCoverageLocked (fix(nodes): calculate analytics over available retained history #494) through analyticsHistory.failed and evictedFrom.
  • Verification runs on all three startup terminal paths. loadCoverageRatio, the 90% done gate and /api/perf are unchanged.

Local runs [T]

  • cd cmd/server && go test ./...: pass (≈48 s, repeated runs). go vet is clean.
  • cd cmd/ingestor && go test ./...: pass.
  • go test -race on the affected tests: pass. The run covered the Coverage509, NodeActivity, RegionsObserved, RetainedHistory, 351F1, NodeAnalytics, 1690, 1809, RunStartup, Evict and NoSQLUnderStoreLock suites.
  • sh test-all.sh: 258/258 files pass. node test-frontend-helpers.js: 719 passed, 0 failed.
  • E2E ran against a local Go server on e2e-fixture.db, prepared with the CI steps (freshen, seeds, migrate). All passed:
    • test-node-analytics-coverage-e2e.js: 6/6.
    • test-issue-304-my-mesh-e2e.js: pass.
    • test-issue-2027-my-mesh-node-page-e2e.js: 25/25.
    • test-regions-map-e2e.js: pass.
    • test-e2e-playwright.js: 154/157, 3 skipped (fixture-dependent).
    • The server was stopped by pid.
  • The fixture behaves the same as on master. Its hot load reads 500 of 545 rows, so analytics is already failed and coverage stays unknown before and after this PR.
  • Gates:
    • scripts/check-xss-sinks.sh --diff origin/master: clean (no frontend changes).
    • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
    • No new map[string]interface{} outside tests.
    • cmd/server stays read-only: only a SELECT COUNT(*), and readonly_invariant_test passes.

CI per job [T]

Job Result
Go Build & Test pass
Go Server Tests 1/3, 2/3, 3/3 pass
Go Ingestor Tests pass
Go Libraries & CLI pass
Verify Go Test Assignments pass
JavaScript Unit Tests & Lint pass
Script & Container Unit Checks pass
Playwright E2E 1/3, 2/3, 3/3 pass
Verify E2E Shards & Coverage pass
Validate Docker Image pass
Build & Publish Docker Image pass
Release Artifacts, Publish Badges & Summary, Deploy Staging skipping

No reruns were needed. The known flaky test #271 did not fail.

Remaining issues

  • [K] TestRepeaterRelayInfoMap_ConcurrentIngestAndEviction is intermittently flaky on unchanged origin/master. Locally it failed in 1 of 6 full runs, both on master and on this branch, and it passed 40/40 when run in isolation. It is not caused by this change. It passed in CI. No issue exists for it yet.
  • [A] The hot-path analytics clause totalLoaded < totalInDB in LoadChunked is unchanged. It counts rows read, not rows in memory, so in-memory eviction does not affect it. In theory it can still mark analytics failed if DB retention deletes counted rows during the hot window itself. That is out of scope here.
  • [A] The ingestor can move transmissions.first_seen earlier (rxTime < existing first_seen). If that happens to a row right at the verification edge during startup, the counts differ and coverage stays unknown (conservative). Expected to be rare.
  • [A] Verification gives up after 3 attempts if eviction keeps moving the edge between the two counts. Coverage then stays unknown until the next restart, which is conservative. Each attempt is sub-second, and ticks are one minute apart.
  • Staging verification across several restarts is still open (coordinator).

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent3 PR#511 — head 3650261

Dom: REQUEST CHANGES

Evidence labels: [T] tested in this review · [A] analysis of source/code paths · [K] known/pre-existing, not caused by this PR.

The approach is sound, and the background-fill path now does what #509 asks: retention or eviction during the load is no longer counted as missing, and capped, failed and unloaded-row loads stay unknown. One finding blocks: on the synchronous path (hotStartupHours = 0, the value in config.example.json), the new proof can fail in a case that was known before, and the result stays unknown until the next restart. That is the #509 symptom in another form.

Findings

# Severity Finding Evidence
F1 Major Sync path: the verification edge can sit below the retention floor, so DB rows that were never meant to be loaded count as missing. Coverage then stays unknown until a restart, although every retained row is in memory. Base reports it as known. [T] repro below, [A]
F2 Nit The id <= maxID bounds are not pinned by any test. Two mutants survive. Both only make coverage more conservative, but they would make it unknown under live ingest. [T] mutants D, J
F3 Info The issue's "verify on staging across several restarts" is still open, as the author's report says. —
F4 Info If the ingestor's packetDays × 24 is shorter than the server's retentionHours, rows the DB pruned inside the claimed window are treated as never existing. This is the same as before (pruned before startup); out of scope. [A][K]
F5 Info test-e2e-playwright.js "Version info lives on Perf dashboard" times out locally. A server built from origin/master on the same fixture fails the same way, CI is green, so this is my environment. [T][K]

F1 in detail

With hotStartupHours = 0, LoadChunked sets oldestLoaded = s.packets[0].FirstSeen (chunked_load.go:530), not the query cutoff. The query selects by last_seen >= cutoff, so a re-heard transmission (old first_seen, recent last_seen) is loaded and becomes packets[0]. Its FirstSeen is below the retention floor. retainedHistoryEdgeLocked uses that as the edge, and SELECT COUNT(*) … WHERE first_seen >= edge then counts every plain DB row between that FirstSeen and the floor. The loader never read those rows (their last_seen is below the cutoff), so inMemory != inDB and the result is unknown.

That DB state is normal. config.example.json ships packetDays: 30 with retentionHours: 168 and hotStartupHours: 0, and #351 F1 already names "a DB holding more than 168h of history" as the default case. Re-heard rows older than retention are the root cause the PR itself describes. Prune lag gives the same result when the two windows are equal.

The PR's own RetentionEvictionDuringSynchronousLoad passes because a retention tick runs during the load, and its fixture has no plain rows between 60h and 47h. Without a tick, which is the common case for a fast load, it fails. Verification runs only once, so a tick a minute later does not help.

Repro (cmd/server/review511_test.go, uses the PR's helpers). 5 re-heard rows (first_seen 60h ago, last_seen 30h ago), 3 plain rows at 55h, 200 rows inside 47h, retentionHours = 48, no tick:

func review511DB(t *testing.T, now time.Time) string {
	dbPath := filepath.Join(t.TempDir(), "r511.db")
	seedTestDBRows(t, dbPath, 208, 1, func(i int) (string, int64) {
		switch {
		case i <= 5: // re-heard: old first_seen, recent last_seen
			return now.Add(-60 * time.Hour).Format(time.RFC3339), now.Add(-30 * time.Hour).Unix()
		case i <= 8: // plain rows older than server retention, still in DB
			ts := now.Add(-55 * time.Hour)
			return ts.Format(time.RFC3339), ts.Unix()
		}
		ts := now.Add(-47*time.Hour + time.Duration(i-9)*14*time.Minute)
		return ts.Format(time.RFC3339), ts.Unix()
	})
	return dbPath
}

func TestReview511OlderDBRowsSyncNoTick(t *testing.T) {
	for _, hot := range []float64{0, 12} {
		now := time.Now().UTC()
		store := openCoverage509Store(t, review511DB(t, now), &PacketStoreConfig{RetentionHours: 48, HotStartupHours: hot})
		if err := store.RunStartupLoad(50); err != nil {
			t.Fatal(err)
		}
		retained, activity, _ := coverage509Starts(store, now)
		if retained.IsZero() || activity.IsZero() {
			t.Errorf("hot=%g: every retained row is in memory but coverage is unknown", hot)
		}
	}
}

Results [T]:

Tree hot=0, no tick hot=12 (background) hot=0, tick during load
7540c378 (tests only, old code) ratio 1.0000, retained and activity24h known known ratio 0.9756, unknown (#509)
head 3650261 ratio 1.0000, retained and activity24h unknown known known

So on this path the PR moves the failure from "tick during load" to "no tick during load". Analytics (#494) is unaffected here, because the sync path does not use verified.

Suggested direction: floor the edge at the retention cutoff when retentionHours > 0. Any row with first_seen >= cutoff has last_seen >= cutoff and was selected, and retainedHistoryCoverageStartLocked clamps to the floor anyway. I tried this in scratch:

// retainedHistoryEdgeLocked, after the evictedFrom step
if s.retentionHours > 0 {
	if floor := time.Now().UTC().Add(-time.Duration(s.retentionHours * float64(time.Hour))); floor.After(from) {
		from = floor
	}
}

With it, the repro and the PR's tests pass (Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics) [T]. I did not run the full suites with it. Using analyticsHistory.loadCutoff instead would be wrong for the background path, because there it stays at the hot cutoff. The PR should add the repro (or an equivalent) as a red-before test.

Answers to the focus points

  • Honest reproduction? Yes [T]. On 7540c378 (the PR's tests against unchanged code), 6 tests fail with the expected symptom: retained coverage start=0001-01-01… at ratio 0.9756 for the four retention and DB-retention scenarios, unknown after memory eviction, and "hot=0: unloaded row was certified". On head they are green. The scenarios match the issue: the loader selects by last_seen, eviction and ingestor retention go by first_seen [A].
  • Removed rows no longer count as missing; capped, failed and loading stay conservative? On the background path, yes [T]. Capped and failed are refused in retainedHistoryEdgeLocked (mutants B and C killed). Loading stays unknown via backgroundLoadDone, and publishRetainedHistory runs before backgroundLoadDone.Store(true) on all three terminal paths, so no reader sees done with an unset proof [A]. I found no overclaim path: every mismatch, parse error or count error returns zero [A]. The sync-path gap is F1. It is conservative, not an overclaim.
  • Callers [A]:
    • GetNodeHealth → nodeActivityCoverageStartLocked (activity24h / My Mesh). The rewritten 24h condition is equivalent to the old one for retention <= 0.
    • computeRepeaterRelaySnapshot → retainedHistoryCoverageStartLocked (/api/regions/observed and /api/regions/history, coverageStart). loading is unchanged.
    • fix(nodes): calculate analytics over available retained history #494 analytics via failed = … || (totalInDB > 0 && !verified) on the background path. The evictedFrom change keeps the old unparseable-FirstSeen behaviour (edge = now) and fixes the fold of the provisional floor (mutant F killed). I found no other callers.
  • perf(store): move runtime SQL outside packet-store locks #495 lock rule [T][A]: the SQL count runs between two RLock sections, and publishRetainedHistory takes Lock only for the store. Both call sites run outside s.mu. bgErrMu is no longer read from GetNodeHealth or the relay snapshot, so no new nesting. TestNoSQLUnderStoreLock globs every *.go, so it covers history_coverage.go. go test -race -count=3 on the affected suites passes.
  • No behaviour change beyond the purpose? Mostly. These changes are intended and documented: the last_seen = 0 strictness, and that unlimited retention with a hot window now needs the proof instead of being trusted. The only side effect is F1.

Mutants [T]

Each mutant ran on a copy of head against Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|1690|1809|RunStartup|Evict|Regions.

Mutant Result Killed by
A: back to requiring loadCoverageRatio == 1 (in retainedHistoryCoverageStartLocked) killed 5 × Coverage509…, TestNodeActivity24hCoverageRequiresLoadedHistory
B: drop the h.cap check in the edge killed …EdgeRefusesCappedOrFailedLoad, TestRegionsObservedCappedHotStartupCoverageIntegration
C: drop the h.failed check in the edge killed …EdgeRefusesCappedOrFailedLoad
D: drop tx.ID <= maxID in the in-memory count survived — (F2)
E: no retry when the edge moves killed …EvictionBetweenCountsRetries
F: evictedFrom folds h.from again killed …RetentionEvictionDuringBackgroundLoad, …AtBackgroundLoadStart, …EvictionBetweenCountsRetries
G: edge ignores evictedFrom killed …MemoryEvictionDuringBackgroundLoad, …EvictionBetweenCountsRetries, …EdgeRefuses…
H: analytics back to loadedCount < totalInDB killed …RetentionEvictionDuringBackgroundLoad, …UnloadedRowKeepsCoverageUnknown
I: accept any count mismatch killed …UnloadedRowKeepsCoverageUnknown
J: drop AND id <= ? from the SQL count survived — (F2)

F2 suggestion: in retainedHistoryCountedHook, insert a DB row with a new id and a recent first_seen (stand-in for an ingest the poller has not picked up yet), and assert that coverage stays known. Mutant J would then fail. Mutant D needs the matching in-memory case.

Tests [T]

Merged tree = git merge-tree --write-tree origin/master 3650261… on origin/master ee6f8d09 (tree b3dfef9a), extracted with git archive into scratch.

Gates [T]

  • cmd/server stays read-only: the only new SQL is one SELECT COUNT(*). The DELETEs in the diff are in the test file, against a temp DB.
  • No new map[string]interface{} (0 added interface{} lines).
  • No frontend changes and no hardcoded colours. scripts/check-xss-sinks.sh --diff origin/master (run in a scratch clone at head): "no public/**/*.{js,html} changes to scan", exit 0.
  • Fork guards are unchanged: 9 in deploy.yml, 1 in release-fast-path.yml. The PR does not touch .github.
  • No closing keywords in the PR body or commits. Both commits are authored and committed by dborup <kontakt@meshview.dk>.
  • Remote head was 3650261… before and after the review.

Not verified

  • Staging behaviour across restarts. I had no staging access, by design.
  • The full Go and E2E suites with the suggested F1 fix. Only the targeted subset ran.
  • How often re-heard rows older than retentionHours occur in production DBs [A]. Staging's 0.9989 suggests they are not rare.
  • Concurrent live ingest during verification (F2). Covered by analysis only.
  • The cause of the local F5 timeout.

dborup and others added 3 commits October 20, 2026 06:54
…retention floor (#509)

With hotStartupHours=0 the load floor is s.packets[0].FirstSeen. A loaded
re-heard row (old first_seen, recent last_seen) pulls it below the retention
floor, so the verification counts DB rows that were never meant to be
loaded and coverage stays unknown although every retained row is in
memory. No retention tick is needed; this is the default-config case
(#351 F1).

The second test adds concurrent ingest during the load and between the two
counts, and rows on both sides of the retention floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…509)

Review F1 on #511. With hotStartupHours=0 the load floor is
s.packets[0].FirstSeen; a loaded re-heard row puts it below the retention
floor, and the verification then counted DB rows that were never meant to
be loaded. Coverage stayed unknown until a restart, although every
retained row was in memory.

retainedHistoryEdgeLocked now takes now and raises the edge to the
retention floor when retentionHours > 0. A row with first_seen at or after
the floor has last_seen after it too, so the load (whose cutoff was taken
earlier) selected it. verifyRetainedHistory takes now once, so only an
eviction moves the edge between the two reads.

Also pins the id <= maxID bounds on both counts (review F2) through the
concurrent-ingest test, and adds an edge unit test for the floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#511 runde-2 — head 9f2d9ff

Review feedback addressed (commit 9f2d9ff0)

  1. F1 (major), fixed. retainedHistoryEdgeLocked(now) now raises the verification edge to the retention floor (now − retentionHours) when retentionHours > 0. DB rows older than retention, which the load never selected, are no longer counted as missing. verifyRetainedHistory takes now once per verification, so the edge only moves between the two reads when an eviction moves it. A floor taken from time.Now() inside the edge function would have moved at every second boundary and could have used up the retries. Safety argument [A]: a row with first_seen >= floor has last_seen >= floor, and the load cutoff was taken earlier, so the load selected that row. A last_seen = 0 row inside the window is still detected as missing (UnloadedRowKeepsCoverageUnknown stays green). Red test in 5b061973, fix in 9f2d9ff0.
  2. F2, fixed. The id <= maxID bounds on both counts are now pinned by TestCoverage509ConcurrentIngestAtRetentionEdge. Mutants D and J are both killed (table below).
  3. F3: not handled here. Staging verification belongs to the coordinator. This session had no stg or prod access.
  4. F4, F5: info only, no change, as instructed.

Evidence labels: [T] tested in this run · [A] analysis of source/code paths · [K] known/pre-existing, not caused by this PR.

Commits (normal pushes, no force-push)

Commit Content
4add54a9 Merge of origin/master (f56ef97c). Clean, no conflicts.
5b061973 Tests only (red on the round-1 code).
9f2d9ff0 Fix, edge unit test, existing edge test updated to the new signature.

The PR body's Edge bullet and test table were updated to match.

Tests

Test Scenario Red before [T]
TestCoverage509DBRowsBelowRetentionFloorNoTick The reviewer's repro: 5 re-heard rows (first_seen 60h, last_seen 30h), 3 plain rows at 55h, 200 rows within 47h, retentionHours = 48, no tick. Runs on both hot=0 and hot=12. hot=0: ratio 1.0000, coverage unknown. Log: 205 of 208 transmissions since <60h edge>
TestCoverage509ConcurrentIngestAtRetentionEdge Same DB shape, plus 4 rows at floor − 2 min and 4 at floor + 2 min. Concurrent ingest: the ingestor writes 3 rows during the load, then a further row and a poller tick (IngestNewFromDB) between the SQL count and the in-memory count. Runs on both hot=0 and hot=12. hot=0: unknown. Log: 212 of 219
TestCoverage509EdgeFlooredAtRetention Unit test: the edge equals the retention floor, rounded up. An eviction edge above the floor still wins. Unlimited retention has no floor. Uses the new signature, so it is part of the fix commit; red state shown by mutants F1a/F1b.

Both integration tests pass on hot=12 with the round-1 code as well. The defect is specific to the sync path, as the review said [T].

Mutants [T]

Each mutant ran on head against Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|1690|1809|RunStartup|Evict|Regions.

Mutant Result Killed by
F1a: drop the retention floor (the round-1 behaviour) killed …DBRowsBelowRetentionFloorNoTick, …ConcurrentIngestAtRetentionEdge, …EdgeFlooredAtRetention
F1b: always use the floor, even when the load or eviction edge is higher (overclaim) killed …MemoryEvictionDuringBackgroundLoad, …EvictionBetweenCountsRetries, …EdgeFlooredAtRetention
D: drop tx.ID <= maxID in the in-memory count killed …ConcurrentIngestAtRetentionEdge hot=0 (row polled between counts: 208 of 207)
J: drop AND id <= ? from the SQL count killed …ConcurrentIngestAtRetentionEdge hot=12 (rows written during the background fill, not yet polled: 204 of 207)

Local runs [T]

  • cd cmd/server && go vet . && go test -count=1 ./...: ok (49.5 s).
  • cd cmd/ingestor && go test -count=1 ./...: ok (211 s).
  • go test -race -count=3 on Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|NoSQLUnderStoreLock|RunStartup|ReadOnly|1690|1809|Evict: ok (135 s).
  • sh test-all.sh: 259 passed, 0 failed (259 files). node test-frontend-helpers.js: 719 passed, 0 failed.
  • E2E ran against a local Go server on a scratch copy of e2e-fixture.db, prepared as in CI: freshen, the inline Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL, corescope-migrate, then seeds 2073, 199, 245 and 122. The server was stopped by its pid.
    • test-node-analytics-coverage-e2e.js: 6/6.
    • test-issue-304-my-mesh-e2e.js: pass.
    • test-issue-2027-my-mesh-node-page-e2e.js: 25/25.
    • test-regions-map-e2e.js: pass.
    • test-e2e-playwright.js: 154/157, 3 skipped (fixture-dependent). The local F5 timeout did not appear in this environment.
  • [K] The fixture's sync load is still capped (500 of 545, coverage 91.7%), so the E2E does not exercise the proof. Same as round 1.

#495 lock rule [T][A]

  • The SQL count still runs between two RLock sections. The only addition under RLock is one time comparison in the edge function.
  • TestNoSQLUnderStoreLock passes.
  • The new tests call IngestNewFromDB and MaxTransmissionID from the hook while no lock is held. Clean under -race.

Gates [T]

  • cmd/server stays read-only: no new SQL. The INSERTs are in the test file, against a temp DB.
  • No interface{} added by the round-2 commits.
  • No frontend changes.
  • No closing keywords.
  • All commits are by dborup <kontakt@meshview.dk>.
  • The PR is still a draft.

CI per job [T]

Run 39384953553 on 9f2d9ff0, attempt 1. No reruns were needed, and the known flaky tests #271 and #301 did not fail.

Job Result
Go Build & Test pass
Go Server Tests 1/3, 2/3, 3/3 pass
Go Ingestor Tests pass
Go Libraries & CLI pass
Verify Go Test Assignments pass
JavaScript Unit Tests & Lint pass
Script & Container Unit Checks pass
Playwright E2E 1/3, 2/3, 3/3 pass
Verify E2E Shards & Coverage pass
Validate Docker Image pass
Build & Publish Docker Image pass
Release Artifacts, Publish Badges & Summary, Deploy Staging skipping

Not verified

  • Staging behaviour across restarts (F3, coordinator).
  • [A] With a retention floor, the claimed start can only rise relative to round 1: max(…, floor). The coverage readers clamp to the floor at read time anyway, so no API value moves earlier.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent3 PR#511 — head 9f2d9ff

Dom: APPROVE med nits

Evidence labels: [T] tested in this review · [A] analysis of source/code paths · [K] known/pre-existing, not caused by this PR.

This is the re-review after round 2. Both round-1 findings are resolved. F1: the verification edge is now floored at the retention floor, and the reviewer's repro is in the suite. It was red on the round-1 code and is green now. F2: mutants D and J are both killed. I found no path that claims coverage for a capped, failed or still-loading store. One nit remains: the floor on the background path is not pinned by a test (my mutant R2 survives). The outcome without it would only be conservative.

Findings

# Severity Finding Evidence
F1 (r1) Resolved The edge is now max(oldestLoaded, evictedFrom, now − retentionHours), rounded up to a whole second. now is taken once per verification. [T][A]
F2 (r1) Resolved TestCoverage509ConcurrentIngestAtRetentionEdge kills D (hot=0) and J (hot=12). [T]
N1 Nit The retention floor in retainedHistoryEdgeLocked is only pinned for the sync path. Mutant R2 (floor only when hotStartupHours <= 0) survives. On the background path oldestLoaded ends at the bg-start target, which is just below the verify-time floor. The floor only matters there if the ingestor prunes rows in that sliver while they are still in memory, before a server tick evicts them. Without the floor, that race gives "unknown" (memory > DB). It never overclaims. A test is optional. [T] mutant, [A]
I1 Info The 90% done gate is unchanged, as the PR says. If retention evicts more than 10% of the counted rows during a background load, backgroundLoadFailed is still set, although the new proof would pass. Pre-existing and out of scope. [A][K]
I2 Info test-e2e-playwright.js "Version info lives on Perf dashboard, not in navbar" times out locally (#navStats). A server built from origin/master f56ef97c fails identically on the same prepared fixture, and CI is green. This is my environment, not this PR. [T][K]
F3 (r1) Open The issue's "verify on staging across several restarts" is still open (coordinator). —

Answers to the focus points

F1: does the edge ever sit below the retention floor? No, when retentionHours > 0 [A]. retainedHistoryEdgeLocked(now) raises from to now − retentionHours (history_coverage.go:39-43). Readers clamp to the read-time floor again (node_activity_24h.go), so no API value can start below it. Safety: on the sync path, LoadChunked selects by last_seen >= hotCutoffUnix, and on test DBs without the column by first_seen >= cutoff. The cutoff is taken before verification, and the edge is rounded up to a whole second. A row with first_seen >= edge therefore has last_seen >= cutoff and was selected. The exception is a last_seen = 0 or last_seen < first_seen row, which yields "unknown", as UnloadedRowKeepsCoverageUnknown keeps pinning. With unlimited retention there is no floor, and the edge stays at the load floor (EdgeFlooredAtRetention third case). [T] TestCoverage509DBRowsBelowRetentionFloorNoTick and …ConcurrentIngestAtRetentionEdge fail on 5b061973 (round-1 code plus the new tests) with retained coverage start=0001-01-01… at ratio 1.0000 on hot=0, and pass on head.

Is there a test with concurrent ingest at the retention edge? Yes [T][A]. …ConcurrentIngestAtRetentionEdge seeds rows at floor ± 2 min, re-heard rows and plain 55h rows. It writes 3 rows during the load, through OnChunkLoaded on hot=0 and loadChunkScannedHook on hot=12. It then writes a 4th row and runs a real poller tick (IngestNewFromDB) between the SQL and the in-memory count. The rows at floor + 2 min must be in memory, or the counts differ. The rows at floor − 2 min must not be counted. The test checks that the poller actually reached the new id, so the hook cannot silently skip the scenario.

F2: do D and J die? Yes [T]. D (drop tx.ID <= maxID): hot=0, 208 of 207. J (drop AND id <= ?): hot=12, 204 of 207. This matches the author's report.

No false coverage for capped, failed or loading [T][A]:

  • Capped and failed: retainedHistoryEdgeLocked refuses h.cap || h.failed. The background path only calls publishRetainedHistory when chunkErrors == 0 && totalInDB >= 0 (short-circuit), so retainedHistoryFrom stays zero. Readers also check backgroundLoadFailed, and my mutant R3, which drops that check, is killed. On the prepared E2E fixture (sync load capped at 500 of 545) /api/regions/history reports coverage.start: null, as before.
  • Loading: retainedHistoryFrom is set before backgroundLoadDone.Store(true) on all three terminal paths. Readers also gate on backgroundLoadDone; my mutant R4 removes that gate and is killed by TestRetainedHistoryCoverageForRegions.
  • Overclaim needs two offsetting errors, for example a row missing from memory plus an in-memory row the DB no longer counts under the same predicate. I found no realistic single cause. Every single mismatch, count error or parse error yields zero [A].

Callers [A]:

  • GetNodeHealth → nodeActivityCoverageStartLocked (activity24h / My Mesh). The 24h contract is unchanged; unlimited retention with a hot window under 24h stays unknown.
  • computeRepeaterRelaySnapshot → retainedHistoryCoverageStartLocked, which feeds /api/regions/observed and /api/regions/history (coverageStart). loading is unchanged.
  • fix(nodes): calculate analytics over available retained history #494 analytics through analyticsHistory.failed = … || (totalInDB > 0 && !verified) and the evictedFrom change.
  • /api/perf (store.go:2974) still reads loadCoverageRatio under bgErrMu, unchanged.
  • No other readers of retainedHistoryFrom.

#495 lock rule [T][A]:

  • The SQL COUNT(*) still runs between two RLock sections. Round 2 only adds one time comparison under RLock.
  • All three publishRetainedHistory call sites run with no store lock held.
  • bgErrMu is no longer nested from the readers.
  • TestNoSQLUnderStoreLock passes, and the -race run below is clean.

Behaviour beyond the purpose [A]: The round-2 diff (5b061973..9f2d9ff0) touches only history_coverage.go (edge floor, now parameter) and the test file. The edge can only rise relative to round 1, and readers already clamped to the floor, so no API value moves earlier.

Tests [T]

Merged tree = git merge-tree --write-tree origin/master 9f2d9ff0… against origin/master f56ef97c, giving tree 6bbeb1b6. That is identical to the head tree, because head already merges f56ef97c. The trees were extracted with git archive into scratch.

  • cd cmd/server && go vet . && go test -count=1 ./...: ok (54.7 s).
  • cd cmd/ingestor && go test -count=1 ./...: ok (230.9 s).
  • go test -race -count=3 -run 'Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|NoSQLUnderStoreLock|RunStartup|ReadOnly|1690|1809|Evict|RepeaterRelay' (head): ok (148 s).
  • sh test-all.sh: 259 passed, 0 failed. node test-frontend-helpers.js: 719 passed, 0 failed.
  • Red-before (round 2): the two new integration tests fail on 5b061973 and pass on head.
  • E2E against a local Go server on a scratch copy of e2e-fixture.db, prepared as in CI: freshen-fixture.sh, the inline Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL, corescope-migrate, then seeds 2073, 199, 245 and 122. Both servers were stopped by the pid listening on their port.
    • test-node-analytics-coverage-e2e.js: 6/6.
    • test-issue-304-my-mesh-e2e.js: pass.
    • test-issue-2027-my-mesh-node-page-e2e.js: 25/25.
    • test-regions-map-e2e.js: pass.
    • test-e2e-playwright.js (fail-fast removed in a scratch copy): 153/157, 3 skipped, 1 failed (I2). A server built from origin/master gives the identical result.
    • [K] The fixture's sync load is capped (500 of 545), so the E2E does not exercise the proof. That path is covered by the Go integration tests only.

Mutants [T]

Each mutant ran on a copy of head with -run 'Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|1690|1809|RunStartup|Evict|Regions'.

Mutant Result Killed by
D: drop tx.ID <= maxID in the in-memory count killed …ConcurrentIngestAtRetentionEdge (hot=0)
J: SQL AND (id <= ? OR 1) killed …ConcurrentIngestAtRetentionEdge (hot=12)
R1 (own): floor taken from analyticsHistory.loadCutoff instead of now − retentionHours killed 8 × Coverage509…, including …DBRowsBelowRetentionFloorNoTick, …ConcurrentIngestAtRetentionEdge, …EdgeFlooredAtRetention
R2 (own): apply the floor only when hotStartupHours <= 0 survived — (N1)
R3 (own): reader ignores backgroundLoadFailed killed TestNodeActivity24hCoverageRequiresLoadedHistory, TestRetainedHistoryCoverageForRegions
R4 (own): reader ignores backgroundLoadDone killed TestRetainedHistoryCoverageForRegions

Gates [T]

  • cmd/server stays read-only: no new SQL in round 2. The only new production SQL in the PR is the one SELECT COUNT(*). The INSERTs and DELETEs are in the test file, against temp DBs. The read-only invariant test passes in the full suite.
  • No new map[string]interface{}: 0 added interface{} lines in origin/master...head.
  • No frontend or .github changes. No hardcoded colours. scripts/check-xss-sinks.sh --diff origin/master (scratch clone at head): "no public/**/*.{js,html} changes to scan", exit 0.
  • Fork guards: 9 in deploy.yml and 1 in release-fast-path.yml, the same as master.
  • No closing keywords in the PR body or the commits, and closingIssuesReferences is empty. The PR is still a draft.
  • All five commits are authored and committed by dborup <kontakt@meshview.dk>.
  • CI run 39384953553 on 9f2d9ff0, attempt 1: every job passes (Go Build & Test, Go Server 1–3/3, Go Ingestor, Go Libraries & CLI, Verify Go Test Assignments, JS Unit & Lint, Script & Container, Playwright 1–3/3, Verify E2E Shards, Validate Docker Image, Build & Publish Docker Image). Release Artifacts, Publish Badges and Deploy Staging are skipped as expected. flaky: #226 Hash Stats adopters sort E2E — server sorts multiByteNodes on packets only, ties come out in random order #256 and flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 did not show up.
  • The remote head was 9f2d9ff0… before and after this review.

Not verified

  • Staging behaviour across restarts (F3). I had no staging access, by design.
  • The new proof against a large real DB. The E2E fixture is capped, so only the Go integration tests exercise it.
  • Whether N1's race (ingestor prune in the bg-start → verify sliver, before a server tick) occurs in practice. This is analysis only, and the outcome would be conservative.
  • The cause of the local I2 timeout. It reproduces identically on master, so it is not this PR.

@dborup

dborup commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Staging verification — master f56ef97c + #510 + #511 (three restarts)

Staging config: retentionHours 48, hotStartupHours 6, maxMemoryMB 1800, packetDays 30, background loading. Each round waited for loading to finish plus one relay recompute (5.5 min).

== round 1 at 08:49
 loadCoverageRatio 0.9990375360923965 bgDone True evicted 326
 regions coverage.start None loading False evidence capped/unprovable/excluded 1077 0 7 truncated False
 routes steps 6303 stepsTruncated False
 node activity24h complete False None
mem 1.796GiB / 2.734GiB
 errors: 0
 restarted
== round 2 at 09:01
 loadCoverageRatio 0.9987226468432625 bgDone True evicted 243
 regions coverage.start None loading False evidence capped/unprovable/excluded 1068 0 7 truncated False
 routes steps 6345 stepsTruncated False
 node activity24h complete False None
mem 1.702GiB / 2.734GiB
 errors: 0
 restarted
== round 3 at 09:12
 loadCoverageRatio 1.000188371216878 bgDone True evicted 313
 regions coverage.start None loading False evidence capped/unprovable/excluded 1096 0 7 truncated False
 routes steps 6319 stepsTruncated False
 node activity24h complete False None
mem 1.757GiB / 2.734GiB
 errors: 0

[exited with code 0]

#511 / #509 is not fixed on this configuration. Regions coverage.start stays null and node activity24h.complete stays false in all three rounds, including round 3 where loadCoverageRatio is 1.0002. The round-2 review noted that the retention floor is pinned only on the sync path (mutant R2 survives). Staging uses the background path, so that path, or a gate other than the ratio, still blocks the proof.

#510 / #508 is partly fixed. Directed steps are no longer truncated (about 6,300 steps, stepsTruncated: false). But coverage.evidence.capped is still about 1,070–1,100 (all in #dk), so the #dk and "all scopes" rates are still withheld. Something in membership capture still caps on real data. Candidates are the per-transmission distinct-path limit (128), the path-bytes limit (8192) and the membership entries/nodes limits. Identify which one with the new headroom diagnostics.

Memory is fine: RSS 1.70–1.80 GiB with a 2.73 GiB cap. There are 0 panic, fatal, concurrent map or SQLITE_BUSY entries.

Both PRs need another round before merge.

dborup and others added 2 commits October 20, 2026 09:24
… the background load (#509)

Staging shape: retentionHours 48, hotStartupHours 6, a memory cap and the
background fill, with retention and memory-cap ticks, the poller and new
ingest during the load. The control without re-hearing proves the
retained history; with transmissions re-heard before their window is
read, the ingestor moves last_seen to now, out of every window still to
be read, and the poller skips their observations. The proof then fails
("1871 of 1875"), so coverage stays unknown at ratio 0.997.

Also pins the retention floor on the background path (round-2 N1,
mutant R2): rows pruned from the DB in the sliver between the background
floor and the verification floor must not count as surplus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…509)

The startup windows select by last_seen, and the ingestor moves
last_seen to the time of each new observation. A transmission heard
again before its background window was read, or after the hot cursor
passed it, fell out of every window still to be read, and the poller
skips observations of transmissions that are not in memory. It stayed
missing until the next restart, so the retained-history proof refused
coverage on every background-fill startup with live traffic.

LoadChunked records the highest observation id when the load begins.
After the last background window, loadReheardTransmissions loads the
transmissions observed since then (id <= maxTxID) that are not in
memory, through the window loader's merge (loadChunk is split into
loadChunkWhere). Ids above maxTxID stay with the poller, which still
ingests and broadcasts them as new. Errors only leave rows missing,
which the proof reports as unknown. The SQL runs without s.mu (#495).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#511 runde-3 — head afc27e9

Review feedback addressed (commit afc27e91)

  1. Staging: coverage stays unknown on the background path (blocking). The gate was found and reproduced, and the fix is in afc27e91. The proof itself is not the problem. Transmissions re-heard during the startup load are never loaded:
    • The windows select by last_seen. The ingestor moves last_seen to the time of each new observation (stmtBumpTxLastSeen). Since bug(channels): /api/channels list-row lastActivity/lastMessage/lastSender use FirstSeen — show stale heartbeat instead of genuinely-newest message Kpa-clawbot/CoreScope#1370 that time is server ingest time, not the observer's clock.
    • A transmission heard again before its background window is read, or after the hot cursor passed it, falls out of every window still to be read.
    • The poller (IngestNewObservations) skips observations of transmissions that are not in memory (tx == nil → continue), so the row stayed missing until the next restart.
    • The proof then sees DB > memory and refuses coverage, and it was right to: those rows really were missing. [A][T]
    • Fix: LoadChunked records the highest observation id when the load begins. After the last background window, loadReheardTransmissions loads the transmissions observed since then (id <= maxTxID) that are not in memory, through the same merge as a window (loadChunk is split into loadChunkWhere).
  2. Round-2 N1 / mutant R2: the retention floor was not pinned on the background path. Pinned by TestCoverage509BackgroundEdgeFlooredAtRetention (commit 3294f958). R2 is now killed (203 of 200). [T]
  3. Round-2 I1 (90% done gate): no change. It is pre-existing and out of scope, as both reviews said. At staging's ratios (0.9987–1.0002) it does not trip. [A][K]
  4. Round-2 I2 (local #navStats timeout): no change. It did not reproduce here: test-e2e-playwright.js gave 154/157 with 3 skipped. [T][K]

Evidence labels: [T] tested in this run · [A] analysis of source/code paths · [K] known/pre-existing, not caused by this PR.

Finding the gate

TestCoverage509StagingShapeBackgroundLoad uses the staging configuration: retentionHours 48, hotStartupHours 6, a memory cap and the background fill. During the load it runs a retention tick (evicts the 5 re-heard rows loaded by the hot window), a memory-cap tick (128 rows), a poller tick and new ingest. It has two subtests:

Subtest Isolates On 9f2d9ff0 [T] On afc27e91 [T]
reheard=false (control) Eviction, retention, oldestLoaded at the background start, evictedFrom and the 90% gate pass (ratio 0.9990, coverage known) pass
reheard=true Plus 1 row re-heard behind the hot cursor and 3 rows re-heard before the oldest window fail: retained history unverified: 1871 of 1875, ratio 0.9970, all 4 re-heard rows missing from memory pass: background chunk (4 re-heard) merged: 4 tx, 8 obs

None of the gates named in the task (background path, oldestLoaded at the background start, evictedFrom, the 90% gate) blocks on its own. The control proves the history with all of them active. The test's ratios (0.997–0.999) match staging's. Why I expect this on real traffic [A]: the Kpa-clawbot#1690 comment records that only 0.3% of the rows in the load window had first_seen inside it on prod, so re-hearing older hashes is the normal case. Any background load lasting minutes with live traffic will therefore miss some.

The staging-shape test also pins two more points:

  • The re-heard rows end up in memory with both observations.
  • A row written after the poller's last tick stays the poller's: the poller still ingests and broadcasts it as new.

Why the fix stays conservative [A]

  • Capped and failed loads are still never verified, because retainedHistoryEdgeLocked is unchanged. A catch-up error is logged and only leaves rows missing, which the proof then reports as unknown. It does not mark the load failed, and backgroundLoadDone/backgroundLoadFailed are unchanged.
  • Candidates are the transmissions with an observation id greater than the one recorded at load start. Every last_seen bump follows an observation insert, so nothing re-heard is missed, whatever the timestamp.
  • No double merge. Only ids <= maxTxID that are not in byTxID are loaded. Ids above maxTxID stay with the poller. An id at or below maxTxID that the load skipped is not loaded by anything else: the poller only reads ids above its own cursor, which starts at the DB maximum when it starts, during the hot load.
  • loadStartObsID defaults to −1 in NewPacketStore. A background fill that is called directly, or the legacy Load(), therefore skips the step. The sync path (hotStartupHours = 0) is unaffected: it selects by the retention cutoff, so a bump cannot move a retained row out of it.

Mutants [T]

Each mutant ran on head with -run 'Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|1690|1809|RunStartup|Evict|Regions'.

Mutant Result Killed by
M1: remove the loadReheardTransmissions call killed StagingShapeBackgroundLoad/reheard=true (1871 of 1875, re-heard rows missing)
M2: load every observed id, including those in memory killed StagingShapeBackgroundLoad (both subtests; duplicates make memory > DB, ratio 1.0005)
M3: drop transmission_id <= maxTxID killed StagingShapeBackgroundLoad (both; the poller no longer ingests the newest row as new)
M4: take the observation id at the background start instead of the load start killed StagingShapeBackgroundLoad/reheard=true (row re-heard behind the hot cursor is missing)
R2 (round 2): retention floor only when hotStartupHours <= 0 killed BackgroundEdgeFlooredAtRetention (203 of 200)

Commits (normal pushes, no force-push, no rebase)

Commit Content
3294f958 Tests only: the staging-shape test (red on 9f2d9ff0) and the background floor pin (green; red under R2).
afc27e91 Fix: loadStartObsID, loadReheardTransmissions, the loadChunk → loadChunkWhere split, plus two assertions in the staging-shape test that pin M3 and the second observation.

origin/master is still f56ef97c, which is already merged, so no new merge commit was needed. Both commits are authored and committed by dborup <kontakt@meshview.dk>. The PR body was updated: a re-heard bullet, a performance note and two test-table rows.

Local runs [T]

  • cd cmd/server && go vet . && go test -count=1 ./...: ok (49.0 s, final code).
  • cd cmd/ingestor && go test -count=1 ./...: ok (232.6 s). The ingestor is unchanged.
  • go test -race -count=3 -timeout 30m -run 'Coverage509|NodeActivity|RegionsObserved|RetainedHistory|351F|NodeAnalytics|NoSQLUnderStoreLock|RunStartup|ReadOnly|1690|1809|Evict|RepeaterRelay|BackgroundChunk|LoadChunk|DistanceLazy': ok (170.7 s).
  • go test -race -count=1 -timeout 30m -run HotStartup: ok (278.8 s).
    • [K] A first -race run that added HotStartup to the -count=3 set hit go test's default 10-minute timeout inside TestHotStartup_ConcurrentQueryDuringBackgroundLoad. It reported no data race.
    • Run alone under -race, that test takes 151 s on 9f2d9ff0 and 167 s on afc27e91. It uses the legacy Load(), so the new step does not run in it.
  • sh test-all.sh: 259 passed, 0 failed. node test-frontend-helpers.js: 719 passed, 0 failed.
  • E2E against a local Go server built from head, on a scratch copy of e2e-fixture.db prepared as in CI: freshen, the inline Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL, corescope-migrate, then seeds 2073, 199, 245 and 122. The server was stopped by its pid.
    • test-node-analytics-coverage-e2e.js: 6/6.
    • test-issue-304-my-mesh-e2e.js: pass.
    • test-issue-2027-my-mesh-node-page-e2e.js: 25/25.
    • test-regions-map-e2e.js: pass.
    • test-e2e-playwright.js: 154/157, 3 skipped.
    • [K] The fixture's sync load is capped (500 of 545, hotStartupHours = 0), so the E2E exercises neither the proof nor the new step. Both are covered by the Go integration tests only.

#495 lock rule [T][A]

  • All new SQL runs with no store lock held: the MAX(id) in LoadChunked, and the SELECT DISTINCT and the batched IN loads in loadReheardTransmissions.
  • Under RLock the step only reads maxTxID and does one byTxID lookup per id.
  • The merge is the window loader's unchanged per-batch Lock sections.
  • TestNoSQLUnderStoreLock passes, and the -race runs above are clean.

Performance [A]

The step runs once per startup, after the last background window:

  • One primary-key MAX(id) lookup.
  • One rowid-range scan over the observations written during the load, with DISTINCT transmission_id.
  • One map lookup per distinct id under RLock.
  • The missing transmissions are loaded in batches of 500 ids.

Its size is bounded by the observations written during the startup load. Nothing is added to ingest, eviction or request paths.

Gates [T]

  • cmd/server stays read-only: the new SQL is SELECT only. The INSERT/UPDATE/DELETE statements are in the test file, against temp DBs, with PREFLIGHT annotations.
  • No map[string]interface{} added. The only []interface{} lines are database/sql arguments; three of them moved unchanged from the old loadChunk.
  • No frontend or .github changes.
  • No closing keywords. The PR is still a draft.

CI per job [T]

Run 39404819947 on afc27e91, attempt 1. No reruns were needed, and the known flaky tests #271 and #301 did not fail.

Job Result
Go Build & Test pass
Go Server Tests 1/3, 2/3, 3/3 pass
Go Ingestor Tests pass
Go Libraries & CLI pass
Verify Go Test Assignments pass
JavaScript Unit Tests & Lint pass
Script & Container Unit Checks pass
Playwright E2E 1/3, 2/3, 3/3 pass
Verify E2E Shards & Coverage pass
Validate Docker Image pass
Build & Publish Docker Image pass
Release Artifacts, Publish Badges & Summary, Deploy Staging skipped

Not verified

  • Staging. This session had no staging access, by design. The mechanism is reproduced locally and matches staging's configuration and ratios, but it is not confirmed on staging. On re-verification, look for these server log lines:
    • background chunk (N re-heard) merged after background load complete.
    • retained history unverified: X of Y …, which should be absent.
    • If that second line still appears, X > Y versus X < Y tells the next round which side is off.
  • The size of the re-heard set on real traffic (expected to be hundreds to low thousands per restart, from the bug(perf): packet store loads only 0.3% of DB rows after restart; backgroundLoadComplete=true is misleading Kpa-clawbot/CoreScope#1690 0.3% figure) [A].

@dborup

dborup commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Review — CS-Coordinator PR#511 — head afc27e9

Verdict: APPROVE

Round 3 finds the real cause of #509. Transmissions re-heard during the startup or background load move their last_seen out of windows that have not been read yet, so they were never loaded and retained history could not be proven. loadReheardTransmissions loads, after the load, the transmissions observed since the load's starting observation id that are at or below maxTxID and not in memory. It works in batches of 500, with SQL outside s.mu and only short RLocks for maxTxID and the membership check. The poller only ingests new ids and skips observations of transmissions not in memory, so nothing is merged twice. Errors only leave rows missing, and those are reported as unknown.

Staging verification (master 78640faf + #510 r3 + this head, three restarts, retentionHours 48, hotStartupHours 6, maxMemoryMB 1800, background loading): loadCoverageRatio was 0.9988, 0.9976 and 0.9989, and in all three rounds Regions coverage.start was known and node activity24h.complete was true. There were 0 errors. CI is green. Merging.

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.

2 participants