Repository navigation
fix(ingestor): repair resolved_path written with the transmission's first route type (#289 M2) - #590
adminopenclaw8-sketch wants to merge 3 commits into
Conversation
…irst route type (#289 M2) Relates to #289 (M2 of the plan on that issue; M1 shipped in #308). Until #308 the resolved-path backfill read t.route_type -- the route type of a transmission's first reception -- for every observation under that row, so a reception whose own frame carried the other route family was resolved with the wrong rule: a DIRECT reception anchored on the observer stored a relay it never heard the packet from, and a FLOOD reception treated as DIRECT stayed NULL with the backfill watermark already past it. The repair is an async ingestor migration (resolved_path_route_type_repair_v1). It walks observations.id from 0 in bounded windows, reads only the rows where the #308 helper and t.route_type disagree about flood-ness, re-resolves them outside the write transaction and rewrites the ones the route type changes. Every UPDATE is guarded on the value the batch read, and the watermark commits with the rewrites, so the pass is idempotent and resumable. Also adds scripts/resolved-path-route-repair-estimate.sh, a read-only sizing script for a snapshot copy, with unit tests and ShellCheck in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rapport — CS-Macmini PR#590 #289 — head 7f7126dStatus: #289 M2 implemented as the async ingestor migration Evidence tags: [T] measured or tested here, [A] analysis or inference, [K] read in code, firmware source or a measurement someone else recorded. Requirements
SizingSynthetic database built by the benchmark: 1,200,000 observations over 400,000 transmissions (3 receptions each), every third transmission carrying both route families in
p50 is the windows with no candidate (state write only); p95/p99 are the windows that rewrite rows. All well under the 500 ms slow-writer threshold, and under the #89 backfill's recorded p99 of 161 ms [K]. Extrapolated to ~6.5M observations (the count in the #89 sizing comment [K]): ~3 s of CPU plus 325 windows × 250 ms ≈ 81 s of deliberate pause, so about 1.5 min of wall clock warm [A]. Two caveats, both in the PR body: production at that size is a multi-GB file where the pass is cold-cache I/O bound (#89 measured 18–19.5 s cold for one full scan of Local verification
CI per jobAll 15 jobs green; 3 skipped by their own guards (release/fork). Run 40178051032. [T]
The one re-run, as noted. Playwright shard 3 failed on Mutants10 applied one at a time against the committed implementation, each reverted before the next; all killed. [T]
One further mutant was found equivalent and replaced: Residuals
|
Review — CS-MacBook PR#590 — head 7f7126dDom: REQUEST CHANGES Reviewed head Evidence tags: [T] tested or measured here, [A] analysis or inference, [K] read in code or in the PR/issue. The mechanics are solid: bounded windows, resolve outside the transaction, a guarded UPDATE, a resumable watermark, a readiness gate, and writer-hold numbers I reproduced. Two things block it. The pass overwrites or clears correct rows that live ingest wrote, and the rollback section says something about cleared rows that is not true. Both fixes are small. Findings
The points in the brief
Standing checks
Tests run (merged tree,
|
| Suite | Result |
|---|---|
cmd/ingestor: go vet ./... + go test -count=1 ./... |
ok, 116.7 s [T] |
cmd/ingestor: go test -race -run _289 |
ok [T] |
cmd/server go test ./... |
ok, 74.4 s [T] |
cmd/migrate, internal/dbschema, internal/packetpath |
ok [T] |
sh test-all.sh |
272 passed, 0 failed [T] |
node test-frontend-helpers.js |
725 passed, 0 failed [T] |
bash scripts/test-resolved-path-route-repair-estimate.sh + ShellCheck on both scripts |
all checks passed, ShellCheck clean [T] |
E2E against a local Go server on a free high port, with e2e-fixture.db prepared as in CI (freshen, Kpa-clawbot#1486 seed SQL, corescope-migrate, seeds 2073, 199, 245, 122) |
test-e2e-playwright.js 154/157 passed, 3 skipped; test-issue-165-grouped-hop-warn-e2e.js 12/12; test-e2e-532-route-hop-count.js pass; test-issue-315-estimated-positions-e2e.js (the CI crash suite, own server/migrate binaries and local Leaflet assets) PASS for default/enabled/disabled [T] |
| Server stopped by the pid of the port listener, after checking its command and cwd | done [T] |
The PR has no E2E of its own, and no UI or server change, so the E2E runs above are regression checks only.
Mutants (my own, each applied alone to the PR code and reverted)
| # | Mutant | Result |
|---|---|---|
| M1 | prefilter adds o.resolved_path IS NOT NULL |
killed: RepairsBothDirections, RepairsNullRowBelowBackfillWatermark, Idempotent, ResumesAfterStop, WaitsForIndexAndGraph |
| M2 | prefilter family bit & 1 instead of & 2 |
survives (F4) |
| M3 | no-frame + mixed-mask branch disabled (0 = 1 AND …) |
killed: RepairsBothDirections, Idempotent, ResumesAfterStop |
| M4 | persisted state ignored, ceiling/watermark re-pinned on every start (INSERT OR REPLACE) |
killed: Idempotent |
| M5 | a not-ready batch returns an error instead of retrying | survives (F5) |
| M9 | the pass writes no rows (updates = nil, i.e. master behaviour) |
killed: 6 tests |
Probe for F1, as a reviewer-only test file not pushed: TestReviewProbe_DriftedLiveIngestRowUntouched_289 on backfillFixture188, two rows as described in F1. It fails on head (one row cleared, one overwritten, repaired=1 cleared=1) and passes with the suggested r.stored != had guard. With that guard, all _289 tests in the package still pass. [T]
Not verified
- The real-data impact of F1. I ran the pass against a copy of the raw
test-fixtures/e2e-fixture.db(500 observations). It has 0 candidates, so it says nothing about how many rows on a real database are drifted live-ingest rows versus genuine pre-fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 defects. The sizing script cannot tell them apart either, because it does not resolve. Before this runs anywhere, run a resolve-aware count on a snapshot copy: candidates wherestored == hadversusstored != had. Not done here; no staging or production access. [A] - I did not run the full ingestor suite with the suggested F1 guard applied, only the
_289tests and the probe. - On macOS the Leaflet asset sha256 checks in
prepare-e2e-leaflet.shdid not run (the BSDsha256sumrejects the--checkusage). The files were downloaded and the Feature request: allow operators to disable estimated node positions via configuration #315 suite passed with them. - Cold-cache I/O cost on a multi-GB database: not measured. The PR's extrapolation is warm-cache only, as the PR itself says. [K]
- Firmware: no protocol claim beyond the route-type bit layout (0/1 flood, 2/3 direct) was re-checked against the MeshCore source in this review. [K]
Round 2 of the review on PR #590. F1 (high): the repair now writes a row only when the stored value is exactly what the pre-#308 rule produces today (stored == had), not merely when it differs from the new answer. A family mismatch is not by itself a #289 defect: live ingest has always resolved with the frame's own route type, so most mismatched rows hold a correct ingest value, which differs from today's `had` as soon as the neighbour graph or the prefix index has drifted. Those rows were being rewritten, or cleared to NULL. The reviewer's two probe rows on the #188 fixture are now a test. F2: the rollback section said a row cleared to NULL would be re-resolved by the #188 backfill. It would not — that backfill never revisits a row below its watermark — so a clear is permanent without a snapshot. The PR body says so now. F3: the pass is behind resolvedPathRouteRepair.enabled, default false. It overwrites values in place and keeps no copy, so it must not run on the first boot of a build that happens to contain it. Documented in config.example.json and docs/user-guide/configuration.md, both of which say to snapshot first. Customizer entry is a follow-up (rule 8). F4: tests for the transport route types 0 and 3, which differ from the other family's non-transport type in bit 1 only. They kill the mutant that reads bit 0 instead. F5: the batch's readiness check is a seam, so the loop's retry of a batch that finds the snapshot cold after the gate has passed is covered. F6: the sizing script splits the candidates by header family first (the bug direction), each direction then into stored and NULL rows, with unparseable headers on their own. One query instead of two scans. F7: the benchmark mix now includes rows that gain a path, not only rows that are cleared. 1.2M observations: 511 ms, p99 write hold 13.3 ms. Relates to #289 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rapport — CS-Macmini PR#590 runde-2 — head 69e71c0Review feedback addressed (commit Evidence tags: [T] measured or tested here, [A] analysis or inference, [K] read in code, in the PR/issue or in a measurement someone else recorded. Findings1. F1 (High) — the pass rewrote correct live-ingest rows after graph drift. Fixed. The write rule now requires the stored value to be exactly what the pre-#308 rule produces today, not merely that it differs from the new answer: had := marshalResolvedPath(resolvedPathRouteRepairResolve(hops, from, r.observerID, r.txRoute, graph, idx))
if want == had || r.stored != had {
continue
}
The reviewer's repro is now The residual the review named is documented as accepted, in the code and in the PR body: a row whose stored value is both a correct ingest value and exactly what the buggy rule produces today is still rewritten. Nothing in the database distinguishes the two writers. [A] 2. F2 (Medium) — the rollback section was wrong about cleared rows. Corrected. The claim that a NULL row would be re-resolved by the #188 backfill is gone. The section now says that every row the pass writes, clears included, is irreversible without a snapshot, and says why: the #188 backfill never revisits a row below its watermark (its own doc comment in 3. F3 (Medium, owner decision) — the repair is now opt-in, default off. New config block, the shape the review asked for: "resolvedPathRouteRepair": { "enabled": false }
4. F4 (Low) — transport route types tested; the reviewer's M2 now dies. Two cases added to the shared table: a TRANSPORT_DIRECT frame ( 5. F5 (Low) — the not-ready retry inside the loop is covered. The batch's readiness re-check is now a package var, Honest limit: this test is new code exercising a new seam, so on head 6. F6 (Nit) — the sizing script splits by direction, then by NULL/stored. The header split is now by the frame's own route family, which is the bug direction, and each direction reports its stored and NULL counts inside it. Rows whose second header character is not hex are reported on their own line instead of landing in the DIRECT bucket ( 7. F7 (Nit) — the benchmark measures rows that gain a path. Half the mixed transmissions now have a DIRECT first reception, so their candidates are NULL rows that gain a path. The 1.2M-row mix is 4,002 candidates: 1,334 cleared and 2,668 resolved (the 2:1 split is where the every-100th-row counter lands). The benchmark asserts both are non-zero and both counters match the seed. It did not change the order of magnitude — p99 write hold 13.3 ms against 17.0 ms in round 1. [T] Sizing, re-measured with the new mixApple M4, 10 cores, warm page cache, 311 MB synthetic database (1,200,000 observations / 400,000 transmissions). [T]
Extrapolation is unchanged in substance: 426 ns × 6.5M ≈ 3 s of CPU plus 325 windows × 250 ms ≈ 81 s of deliberate pause, warm cache, with the same two caveats in the PR body (production at that size is a multi-GB file where the pass is cold-cache I/O bound, and the candidate rate is synthetic). Nothing was run against staging or production. [T]/[A] Tests
E2E ran against a local Go server on port 13800 with MutantsSeven new mutants for the seven findings, each applied alone to the committed code and reverted before the next. All killed. [T]
One round-1 mutant changed meaning and is re-reported honestly: "the route-type-independent guard ( Standing checks
CI per jobRun 40184604864. Attempt 1: 12 pass, 3 fail, 3 skipped by their release/fork guards. Attempt 2 (the one re-run the policy allows, reasons below): all 15 jobs pass, 3 skipped. [T]
The shard-2 failure, and why it is not this PR. Not fixed, and why
|
Review — CS-MacBook PR#590 — head 69e71c0Dom: APPROVE with nits This re-review covers round 2. Head Evidence tags: [T] tested or measured here, [A] analysis or inference, [K] read in code or in the PR/issue. All seven round-1 findings are addressed. My F1 repro now leaves the drifted live-ingest rows alone, the rollback text is honest, and the pass is opt-in. What remains is non-blocking. The new switch is tested only from a struct literal downwards, so three mutants on the JSON/config path survive. There are also two documentation nits. Findings
The round-1 points
Standing checks
Tests run (merged tree,
|
| Suite | Result |
|---|---|
cmd/ingestor: go vet ./... + go test -count=1 ./... |
ok, 116.7 s [T] |
cmd/ingestor: go test -race -count=1 -run _289 |
ok, 7.7 s [T] |
cmd/server go test -count=1 ./... |
ok, 74.9 s [T] |
cmd/migrate, internal/dbschema, internal/packetpath |
ok [T] |
sh test-all.sh |
273 passed, 0 failed [T] |
node test-frontend-helpers.js |
725 passed, 0 failed [T] |
bash scripts/test-resolved-path-route-repair-estimate.sh + ShellCheck |
all checks passed; ShellCheck clean [T] |
gofmt -l on the touched Go files |
clean [T] |
Benchmarks (-benchtime 1x) |
see F7 above [T] |
E2E against a local Go server on a free high port, e2e-fixture.db prepared as in CI (freshen, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL taken verbatim from deploy.yml, corescope-migrate, seeds 2073, 199, 245, 122) |
test-e2e-playwright.js 154/157, 3 skipped; test-health-status-e2e.js 173 checks ×3 runs; test-issue-165-grouped-hop-warn-e2e.js 12/12; test-e2e-532-route-hop-count.js pass [T] |
| Server stopped by the pid of the port listener, after checking its command line and cwd | done [T] |
The PR has no E2E of its own and makes no UI or server change, so the E2E runs are regression checks only.
Mutants (my own, each applied alone to a copy of head)
| # | Mutant | Result |
|---|---|---|
| M1 | write guard back to the round-1 rule (r.stored == want) |
killed: LeavesDriftedLiveIngestRowsUntouched, RepairsBothDirections, Idempotent, ResumesAfterStop |
| M2 | want == had guard removed (if r.stored != had) |
killed: RepairsBothDirections, Idempotent |
| M3 | prefilter family bit & 1 instead of & 2 (round-1 M2) |
killed: RepairsBothDirections, Idempotent, ResumesAfterStop |
| M4 | not-ready batch returns the error instead of continue (round-1 M5) |
killed: RetriesABatchThatSeesAColdSnapshot |
| M8 | !enabled gate in StartResolvedPathRouteRepair disabled |
killed: NotScheduledWhenDisabled |
| M5 | JSON tag resolvedPathRouteRepair → resolvedPathRouteRepairX |
survives full suite (N1, fails safe) |
| M6 | main.go passes true instead of the config value |
survives full suite (N1, fails unsafe; main() wiring) |
| M7 | config.example.json ships "enabled": true |
survives full suite (N1, fails unsafe) |
Probes (reviewer-only test file, not pushed): TestReviewProbe_DriftedLiveIngestRowUntouched_289 (6 rows, 0/0) and TestReviewProbe_Residual_289 (repaired=1 cleared=1, as documented). Both were run on head and on the merged tree. [T]
Not verified
- The real-data split between defects and drifted live-ingest rows is still unmeasured. The fixture has 0 candidates, the sizing script does not resolve, and I have no staging or production access. A resolve-aware count (
stored == hadvsstored != had) on a snapshot copy remains the honest pre-flight number before anyone enables the switch. This is already listed as a residual in the PR body. [A] - Cold-cache cost on a multi-GB database: not measured. The benchmark is warm-cache only, as the PR says. [K]
- The CI flake was not reproduced locally (3/3 green). It was classified from the CI log and the diff only. [T]/[A]
- Firmware: no protocol claim beyond the route-type bit layout (0/1 flood, 2/3 direct; transport 0/3) was re-checked against the MeshCore source in this round. [K]
- Leaflet asset checksums in the E2E prep were not run (not needed for the suites above).
Relates to #289
Implements M2 of the plan on #289 (comment "Plan — CS-pve-agent3 #289", section 4). M1 — switching the two batch readers to each observation's own route type — shipped in #308. This PR repairs the
observations.resolved_pathrows those readers had already written with the wrong route type. No plan is posted separately; it is below.Round 2 (review "Review — CS-MacBook PR#590 — head 7f7126d"): the write rule now also requires the stored value to be what the pre-#308 rule produces, the pass is behind an opt-in config switch, and the rollback section is corrected. Details in each section.
Problem
DIRECT and FLOOD receptions of the same payload share one
transmissionsrow, because the content hash does not cover the header route bits (MeshCoresrc/Packet.cppcalculatePacketHash).t.route_typeis the route type of the first reception. Until #308 the resolved-path backfill used it for every observation under that row, so a reception carrying the other route family was resolved with the wrong rule, in both directions:resolved_pathleft NULLThe second direction is the reason this cannot be left to the corrected backfill: the #188 backfill moves its watermark past every row it scans, resolved or not, so it never revisits those NULL rows. This pass therefore scans
observations.idfrom 0, not from the backfill watermark.Plan / change
One new file,
cmd/ingestor/resolved_path_route_repair.go, plus eight lines of wiring inmain.go, one config field and its documentation. No schema change to existing tables, no server change, no frontend change.Opt-in.
resolvedPathRouteRepair.enabled, default false. The pass overwritesresolved_pathin place and keeps no copy of the previous value, so it must not run on the first boot of any build that happens to contain it; an operator turns it on deliberately, after taking a snapshot. With the switch off nothing is scheduled and nothing is recorded in_async_migrations, so turning it on later still runs the pass. Documented inconfig.example.jsonanddocs/user-guide/configuration.md, both of which say to snapshot first. The switch and the three bounds below are customizer candidates later (AGENTS.md rule 8).Async migration
resolved_path_route_type_repair_v1(RunAsyncMigration, the Same advert seen on flood and zero-hop routes keeps the route of the first inserted observation #89 pattern), started next to the feat(ingestor): resolve the last hop from the observer, relay-only prefix index, backfill NULL resolved_path #188 backfill inmain.go— after the ingest buffer is draining and the neighbour-edges builder is running — and cancelled on shutdown.Readiness gate. The pass resolves against the live prefix index and neighbour graph, so it waits for
resolvedPathBackfillReady(primed index, a post-build graph with at least one edge) before any batch, and re-checks per batch. Resolving against a cold graph would move the watermark past rows that cannot resolve yet. A batch that finds the snapshot unusable after the gate has passed writes nothing and the loop retries the same watermark.Candidates. A row is a candidate when the fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 helper
observationRouteType(header, txRoute, mask)disagrees witht.route_typeabout flood-ness — the only thingresolveObservationPathreads. That covers both directions above and the rows whose frame is gone but whoseroute_maskholds both families (where fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 now reports the route type as unknown and the pre-fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 backfill usedt.route_type). A SQL prefilter over a bounded id window selects a superset; Go re-checks each row with the helper itself:(
& 2separates the families — the plan's count query. Bit 1 is the only bit that does: the transport types, 0 and 3, share bit 0 with the other family's non-transport type, which is why the tests now carry a route-0 and a route-3 case. An unparseable second character yields-1, whose bit 1 is set, so such a row is never dropped by the prefilter whent.route_typeis a flood type.)What it writes. Each candidate is resolved twice outside any transaction:
wantwith the observation's own route type andhadwitht.route_type(what the pre-fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 reader would produce with today's graph). A row is written only when both:want != had— the route type actually changes the answer. A candidate whose answer is route-type-independent is left alone even if its stored value is stale for some other reason; that is not Plan: per-observation route type — backfill and neighbor-builder use the transmission's first route type #289, and rewriting it would turn this into an unbounded re-resolve of history.stored == had— the stored value is exactly what the pre-fix(ingestor): use each observation's own route type in backfill and neighbor builder (#289 M1) #308 rule produces, which is the signature of a row that rule wrote. A family mismatch on its own is not that signature (round 2, F1): live ingest has always resolved with the frame's own route type, so most mismatched rows hold a correct ingest value, which differs from today'shadas soon as the graph or the prefix index has drifted. Those rows are read and left alone, and so is a row an earlier run already repaired (whose stored value iswant).wantmay be empty; the row is then set back to NULL. That is the repair for a stored wrong relay.Bounds. Each batch scans at most
idWindowids (default 20,000) and reads at mostbatchSizecandidates (default 500), so a window with no candidates cannot become one unbounded range scan on the single SQLite connection. Resolution runs outside the transaction (flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267); oneWriterTxper batch underwriterMu, so a live insert queues behind a batch and not behind the run. 250 ms pause between batches. The three bounds are package vars — no new config block.Resume and idempotence. Row rewrites, the watermark and the counters commit in one transaction (
resolved_path_route_repair_state), so a crash or SIGTERM repeats at most the batch that did not commit; a cancel is returned as an error so_async_migrationsrecords the run unfinished and the next start resumes it. Every UPDATE is guarded onCOALESCE(resolved_path,'') = <the value the batch read>, so a repeat is a no-op and a row live ingest rewrote in between keeps ingest's value. The ceiling is pinned on the first run: rows above it come from live ingest, which has always used the frame's own route type.Sizing script.
scripts/resolved-path-route-repair-estimate.sh— read-only (immutable=1URI), counts the candidates split by header family first (which is the bug direction), each direction then split into stored and NULL rows, with unparseable headers reported on their own; reports any recorded run and estimates the pass. One query, so one scan. Mirrorsscripts/decode-backfill-estimate.sh(Backfill historical channel and region decoding when keys are added #380), with unit tests and ShellCheck wired into CI.cmd/serveris not touched and stays read-only. No newmap[string]interface{}. No colours, no frontend, so the XSS gate has nothing to scan.Sizing
BenchmarkResolvedPathRouteRepairPass_289builds a synthetic database of the shape the plan describes and runs one full pass. Mix: 1.2M observations over 400K transmissions (3 receptions per transmission), every third transmission carrying both route families inroute_mask, half of those with a DIRECT first reception so both repair directions are measured, 1 observation in 100 a #289 candidate (4,002 of 1.2M: 1,334 cleared and 2,668 that gain a path). Apple M4, 10 cores, warm page cache, 311 MB database.BenchmarkResolvedPathRouteRepairBatch_289)The p50 is the windows with no candidate (state write only); the p95/p99 are the windows that rewrite rows. Both are far below the 500 ms slow-writer threshold and below the #89 backfill's measured p99 of 161 ms. Adding the rows that gain a path (round 2, F7) did not change the order of magnitude: a repaired row writes a 66-byte JSON array where a cleared row writes a NULL.
Extrapolation to ~6.5M observations (the count in the #89 sizing comment): 426 ns/observation × 6.5M ≈ 3 s of CPU, plus 325 windows × 250 ms ≈ 81 s of deliberate pause — about 1.5 min of wall clock on a warm cache. Two honest caveats: this is a 311 MB database with everything in page cache, where production at 6.5M observations is a multi-GB file, so the real pass is cold-cache I/O bound (the #89 comment measured 18–19.5 s cold for a single full scan of
transmissionson a 4.95 GB copy); and the candidate rate is synthetic. Run the sizing script on a snapshot copy for the real candidate counts before scheduling this anywhere:Nothing was run against staging or production for this PR.
Tests
All in
cmd/ingestor, fileresolved_path_route_repair_289_test.go. "before" is a stub pass that reads and advances exactly as the real one but writes no row — master's behaviour, since no repair exists there.TestResolvedPathRouteRepair_RepairsBothDirections_289— 13 seeded rows in one table: DIRECT frame under FLOOD tx (wrong relay cleared), FLOOD frame under DIRECT tx (NULL resolved), TRANSPORT_DIRECT under FLOOD and TRANSPORT_FLOOD under DIRECT (the route-0/route-3 pair), no frame + mixed mask + FLOOD tx (cleared)TestResolvedPathRouteRepair_LeavesDriftedLiveIngestRowsUntouched_289— the reviewer's two probe rows on the #188 fixture: a DIRECT and a FLOOD reception whose stored relay is not what the pre-#308 rule gives. 0 repaired, 0 cleared, 2 candidates read. The same two rows are in the shared 13-row tableRepairsBothDirections_289— FLOOD/FLOOD, DIRECT/DIRECT, no frame + mixed mask + DIRECT tx, flood-only mask, NULL mask: all unchanged; plus a candidate whose answer is route-type-independent (["c355"]) keeps its stale stored valueTestResolvedPathRouteRepair_RepairsNullRowBelowBackfillWatermark_289— the watermark is set past the row,RunResolvedPathBackfillscans 0 rows, the repair fixes itTestResolvedPathRouteRepair_Idempotent_289— a second pass is a no-op, and a rescan from watermark 0 reads every candidate again and writes 0TestResolvedPathRouteRepair_ResumesAfterStop_289— cancelled mid-pass:context.Canceledis returned, the committed watermark is below the ceiling, the first row is repaired and a later one is not, and the resume finishesTestResolvedPathRouteRepair_ResolvesOutsideWriteTransaction_289— 0 resolutions underwriterMu, oneWriterTxper batchTestResolvedPathRouteRepair_DoesNotOverwriteAConcurrentWrite_289— a row rewritten between read and write keeps its new value, 0 repaired/clearedTestResolvedPathRouteRepair_WaitsForIndexAndGraph_289— on a cold index the pass writes nothing and the watermark stays 0; it completes once the graph is publishedTestResolvedPathRouteRepair_RetriesABatchThatSeesAColdSnapshot_289— the batch's readiness check is a seam; the first batch reports not-ready, the loop retries the same watermark and finishesTestResolvedPathRouteRepairEnabled_OptIn_289(nil config, no block, empty block,enabled: false→ off;enabled: true→ on) andTestResolvedPathRouteRepair_NotScheduledWhenDisabled_289(nothing in_async_migrations, no state row, the defect row untouched; then the same row is repaired once enabled)TestResolvedPathRouteRepair_RunsOnceAsAnAsyncMigration_289— statusdone, and a later start does not re-run itscripts/test-resolved-path-route-repair-estimate.sh— 21 checks: the split by header family (each direction holding both NULL and stored rows, so a NULL/non-NULL split cannot produce the numbers), the transport route types, the unparseable header, no-wal/-shm/-journalcreated, database byte-identical, recorded run reported, pre-Kpa-clawbot#881 degradation, argument handlingMutants (all killed)
t.route_typeinstead of the observation's route typestored == hadguard is gone (drifted live-ingest rows rewritten)LeavesDriftedLiveIngestRowsUntouched,RepairsBothDirections,Idempotent,ResumesAfterStop& 1instead of& 2RepairsBothDirections(the route-0 and route-3 rows)OptIn,NotScheduledWhenDisabledRetriesABatchThatSeesAColdSnapshottest-resolved-path-route-repair-estimate.shBenchmarkResolvedPathRouteRepairPass_289(its both-directions assertion)RepairsNullRowBelowBackfillWatermark+ 4ResolvesOutsideWriteTransactionDoesNotOverwriteAConcurrentWriteRepairsBothDirections,Idempotent,ResumesAfterStopWaitsForIndexAndGraphRepairsBothDirections,IdempotentResumesAfterStopOne mutant was found equivalent and replaced:
breakinstead ofreturn erron the loop'sctx.Err()check — theselectat the bottom of the same iteration still returnsctx.Err(), so the behaviour is identical. The version that mutates thatselectis the one in the table.Rollback
The migration overwrites data, so this is the part to read before running it anywhere. It is off by default (
resolvedPathRouteRepair.enabled), so a deployment that does nothing runs nothing.What can be undone by code: setting
resolvedPathRouteRepair.enabledback tofalse, or reverting this PR, stops any further repair. The_async_migrationsmarker andresolved_path_route_repair_stateare inert once the code is gone; no down-migration is needed and no schema change to an existing table has to be reversed.What cannot be undone: every row the pass wrote. The previous value is kept nowhere, and a database snapshot taken before the run is the only way back to it — so take one first. That applies to both directions, including the rows set back to NULL. The earlier text claimed a NULL row would be re-resolved by the #188 backfill and so was not a permanent loss; that was wrong (round 2, F2). The #188 backfill never revisits a row below its watermark — its own doc comment says so — and every row this pass clears sits below the pinned ceiling and normally below that watermark. A clear is permanent without a snapshot.
Mitigations that make the overwrite narrow: only rows where the route type demonstrably changes the resolve and whose stored value is exactly what the pre-#308 rule produces are touched, so a correct live-ingest value that has drifted from today's answer is read and left alone; the
COALESCE(resolved_path,'') = <value read>guard means a row live ingest wrote in the meantime is never clobbered;resolved_path_route_repair_staterecords exactly how many rows were repaired and how many cleared, so the blast radius is auditable after the fact, and the sizing script reports it.Re-arming after a rollback: a recorded migration is never re-run. If an ingestor is rolled back to a pre-#308 build, it writes new wrong rows that this pass will not revisit; clearing the
_async_migrationsrow (or bumping the migration name) re-arms it. Unlike the #89 backfill, there is no cheap boot-time check for new candidates — that would need a full scan at every start — so this is deliberately manual and documented rather than automatic.Server visibility is restart-only. The server indexes an observation's
resolved_pathwhen it first polls or loads the row, and a later rewrite reaches its indexes on the nextLoad. A change log likeroute_mask_changesis only worth adding if a production count shows the repaired volume is large.neighbor_edgesis not repaired here: false DIRECT-derived observer edges age out with the 5-day prune unless refreshed, and dropped edges come back with new flood traffic — the same approach #190 took.Residuals (outside this PR)
stored == hadversusstored != had) on a snapshot copy is the honest pre-flight number, and is what should be run before this is enabled anywhere. Not done here; no staging or production access.neighbor_edgesis not repaired, as above.route_maskkeep today's behaviour: the mask is a lower bound, and a variant whose frame no longer exists cannot be detected.resolved_pathfrom graph drift (not a Plan: per-observation route type — backfill and neighbor-builder use the transmission's first route type #289 defect) is deliberately left alone.resolvedPathRouteRepair.enabledor the three bounds; AGENTS.md rule 8, a follow-up.test-e2e-1267-mobile-vcr.jsfails locally on the bug(live): VCR controls disappear once node markers fully populate on the map Kpa-clawbot/CoreScope#1267 bottom-nav overlap assertion. It is unrelated to this PR (git diff origin/master -- public cmd/serveris empty) and is a macOS-vs-CI rendering difference; the suite also hardcodes/usr/bin/chromiumand needsCHROMIUM_PATHto run here at all.🤖 Generated with Claude Code