Repository navigation
fix(nodes): place nodes in regions by zero-hop home regions (#1879) - #2190
Conversation
|
Pushed one more change before review: nodes never heard zero-hop now go by fewest hops instead of every region that heard them (red On our network that old fallback was most of the leak, since only ~27% of nodes ever get heard directly. Now a node goes in whichever region heard it in the fewest hops, or in no region if even that's more than 3 hops (it still shows unfiltered). We've been running it in production today: nodes outside the selected region dropped SAT 241 → 7, DFW 76 → 7, HOU 47 → 2, AUS 226 → 48. Description is updated with the details and a benchmark (~50 ms per 5-min recompute at 220k adverts). |
efiten
left a comment
There was a problem hiding this comment.
Thanks for the production numbers. SAT going from 241 to 7 out-of-region nodes makes the case, so I accept the fewest-hops fallback with the 3-hop cap, even though it differs from what we agreed in #1879. Two things before merge.
What I checked
- Shape. It follows what #1879 settled on:
- zero-hop counting is one function (
nodeZeroHopRegionCounts) shared with #804 analytics; - the filter returns a set, not a single winner;
TestBoundaryNodeStaysInEachHomeRegionpins a node that sits on a region boundary.
- zero-hop counting is one function (
- Red/green. At
752c9540three tests fail with the stated assertion, at998219f1the two fallback tests fail, and atfb81de23everything passes.go vetandgofmtare clean, andgo test ./cmd/serverpasses. - The recomputer follows
bridge_recomputer.go: it prewarms, uses an atomic snapshot and recovers from panics. On the request path this adds two atomic loads and one pass over all nodes on a miss of the 30 s cache. Observers are read befores.mu(#2146). - Scope. There are no DB writes. Only
/api/nodes?region=changes meaning. The "show all" toggle (nodesRegionQueryString) still bypasses it, and "All regions" sends no parameter.
Requested changes
-
Show which rule placed a node. With the cap, a node can now be in no region at all, and from the response alone an operator can't tell that apart from the other outcomes. There are four:
- zero-hop home regions;
- fewest-hop regions;
- no region (more than 3 hops);
- the observer scan, for nodes newer than the last recompute.
Only the first one sets
home_regions(node_home_regions.go:216); the other three all look like "field absent". #1879 asked that the response say which rule answered. A smallregion_basisenum, or afallback_regionsarray (empty meaning no region), would cover it. If it becomes more than one field, please use a named struct. -
The recompute cost in the description covers only half of the work.
- The "~50 ms per 5-min recompute" comes from
BenchmarkNodeFewestHopsByRegion. - But
recomputeNodeHomeRegionSets(:95-111) also runsnodeZeroHopRegionCountsover all ofs.packets, and both run unders.mu.RLock. - I measured the whole function on 220k adverts × 8 observations plus 1M non-advert transmissions: about 162 ms/op. On the same machine
BenchmarkNodeFewestHopsByRegiontook 71 ms/op. - Please time
recomputeNodeHomeRegionSetsitself and state that number.
- The "~50 ms per 5-min recompute" comes from
Also
- Please make
regionFallbackMaxHopsa config value, as you offered. Per AGENTS.md rule 8, note the customizer as a later step.
Notes, not blocking
- Transport-direct zero-hop adverts are not counted as zero-hop (
store.go:3977-3989, from before this PR).- For
ROUTE_TYPE_TRANSPORT_DIRECT,RawHex[2:4]is the first transport-code byte, notpath_len(firmwaredocs/packet_format.md). The comment that it matches the analytics decoder is not accurate:store.go:9161usespbOffset. - The fallback still places such a node, because
path_json[]counts as 0 hops, buthome_regionsstays empty. - This could be its own PR.
- For
pathHopCount("")returns 0 (node_home_regions.go:150), so a legacy observation with NULLpath_jsoncounts as a direct hearing.- The current ingestor always writes
"[]", so this is rare. - The fixture relies on it, though:
pk_farinnode_home_regions_test.go:39has a relayed raw packet with an emptypath_json. - Treating "" as unknown and giving
pk_faran explicit path would be more accurate.
- The current ingestor always writes
- The prewarm covers only the first chunk. It runs after
FirstChunkReady. For up to one interval, a node whose zero-hop advert sits in a later chunk can get a partial or empty set. The other recomputers behave the same way, so a recompute when the background load finishes, or a comment, would do. - This adds about two
map[string]interface{}occurrences: theenrichNodeHomeRegionssignature and the OpenAPI entry. They follow the existing node-row pattern, but AGENTS.md asks the count not to grow in touched files. - Two tests would help:
- the observer-scan path for a node newer than the snapshot;
- a request that lists every region code explicitly (nodes in no region drop out even then).
…pa-clawbot#1879) Red: a node whose relayed advert reached another region's observer must stay out of that region; a node heard zero-hop in two regions belongs to both; a node never heard zero-hop falls back to the observer-based check; /api/nodes reports home_regions when zero-hop data placed the node. Stubs keep the package compiling.
…bot#1879) The region filter put a node in every region where any observer heard any of its adverts, so flooded adverts leaked nodes into neighbouring regions. A recomputer now keeps each node's set of home regions from zero-hop DIRECT adverts (Kpa-clawbot#804's rule, counted by the shared nodeZeroHopRegionCounts); RegionNodePubkeys places those nodes by their set and keeps the observer-based check only for nodes never heard zero-hop. /api/nodes reports home_regions when zero-hop data placed a node.
…ps (Kpa-clawbot#1879) Tests plus a stub GetNodeFallbackRegionSets; fails on assertions: the fallback still puts a node in every region that heard it.
…t 3 (Kpa-clawbot#1879) The fallback for nodes never heard zero-hop put them in every region that heard any of their adverts. Adverts flood the whole mesh, so on a statewide deployment most nodes landed in every region. Such nodes now go in the regions that heard them in the fewest hops, and in none when even that is over 3 hops. The recomputer computes this alongside the home regions; the observer-based scan decides only for nodes newer than the last recompute. Perf: one extra pass over in-memory advert observations per recompute (5 min), hop counts from path_json without decoding. 220k adverts x 8 observers: ~50ms under the read lock. RegionNodePubkeys adds one map lookup per heard advert (BenchmarkRegionNodePubkeys ~20ms, unchanged).
…awbot#1879) BenchmarkRecomputeNodeHomeRegionSets times recomputeNodeHomeRegionSets itself (zero-hop count + fewest-hops pass + snapshots) at 220k adverts x 8 observations plus 1M channel messages, the scale from the Kpa-clawbot#2190 review. - nodeZeroHopRegionCounts walks byPayloadType[ADVERT] instead of every packet; it already skipped everything but adverts. Same counts, so Kpa-clawbot#804 analytics is unchanged. - nodeFewestHopsByRegion looks up a node's per-region map once per advert instead of once per observation. Apple M5 (Docker, linux/arm64), -count 3: RecomputeNodeHomeRegionSets 72 ms -> 55 ms/op NodeFewestHopsByRegion 52 ms -> 42 ms/op
…#1879) Review on Kpa-clawbot#2190: with the 3-hop cap a node can be in no region, and only the zero-hop rule shows in the response (home_regions); fewest hops, no region, and the observer scan all look like the field being absent. Tests for a region_placement object on each node (basis zero_hop, fewest_hops, none, or observer_scan, plus the regions and the fewest hop count), over the store snapshot and /api/nodes. Also pins two cases the review asked for, which already pass: the observer scan places a node newer than the last recompute, and listing every region code explicitly still leaves out a node in no region. pk_far gets an explicit one-hop path instead of relying on an empty path_json. Stub types and GetNodeRegionPlacements; fails on assertions.
Replaces home_regions, which only showed when zero-hop data placed a node.
region_placement says which of the four rules placed it:
{"basis":"zero_hop","regions":["AUS"]}
{"basis":"fewest_hops","regions":["HOU","SAT"],"hops":2}
{"basis":"none","hops":5}
{"basis":"observer_scan"}
The recomputer now builds one snapshot of NodeRegionPlacement (a named
struct) instead of the home and fallback region-set maps, and
RegionNodePubkeys reads it the same way. The OpenAPI component is a typed
openAPISchema rather than nested map literals, and enrichNodeHomeRegions
is gone, so the touched files carry no more map[string]interface{} than
upstream master.
…awbot#1879) Review on Kpa-clawbot#2190 asked for the 3-hop cap as a config value. Tests for the accessor (unset or negative keeps 3, 0 is allowed, parsed from JSON) and for the recompute honoring it. Stub RegionFallbackConfig and an accessor that ignores it; fails on assertions.
…t#1879) regionFallback.maxHops in config.json, read by the recomputer through Config.RegionFallbackMaxHops on every run; unset or negative keeps 3. Documented in config.example.json and the region_placement schema. Per AGENTS.md rule 8 this belongs in the customizer later; tracked in the PR plan, not done here.
…1879) Review on Kpa-clawbot#2190: pathHopCount("") returns 0, so a legacy observation with NULL path_json counts as a direct hearing in the fewest-hops fallback. "[]" is the real zero-hop path. Fails on assertions.
…awbot#1879) pathHopCount reports whether the hop count is known: "" and "null" (legacy NULL path_json) are unknown and skipped, "[]" is still zero hops. A node whose every hearing lacks a path has no placement, so the observer scan decides for it, as it did before the fallback.
Kpa-clawbot#1879) Review on Kpa-clawbot#2190: the prewarm runs after FirstChunkReady, so for up to one interval a node whose zero-hop advert is in a later chunk gets a partial or no placement. Fails: nothing recomputes until the next tick.
…Kpa-clawbot#1879) The recomputer waits on StartupLoadDone alongside its ticker and recomputes once when it closes, as recomputeWhenLoaded does for the analytics recomputers, so placements cover the whole store right after the background fill instead of up to one interval later.
fb81de2 to
de0fe4c
Compare
|
Review feedback addressed (commits 3dc3cb7..de0fe4c, rebased onto master 2cdb18e). Thanks for the thorough read. Requested changes
Notes
|
efiten
left a comment
There was a problem hiding this comment.
Thanks, all three requested changes are in. Approving and merging.
What I checked at de0fe4cd
- Red/green. Each pair fails for the stated reason and then passes: d4154ee/261178fe, 862a451/47b763f1, 16dcd8d/b8a8c1f8 and 95f2aca/de0fe4cd.
region_placementis on list and detail rows (routes.go:1627,:1834). Nothing inpublic/,docs/ortests/still readshome_regions.- The
byPayloadType[ADVERT]walk gives the same counts as the walk over all packets. Every insertion path appends tos.packetsandbyPayloadTypetogether (store.go:1047,:1563,:3071,chunked_load.go:598), and eviction removes from both. Only the order differs, and the tally does not depend on order. - Config. A missing or negative
regionFallback.maxHopsgives 3. map[string]interface{}. The count in each touched file is the same as on master.- Checks.
go vetandgofmtare clean.go test .passes incmd/serverandcmd/ingestor.
For the description, not blocking
- The benchmark is machine-dependent.
BenchmarkRecomputeNodeHomeRegionSetstakes 159-171 ms/op here (-benchtime 3x, then10x -count=2), against the 54 ms in the description, and all of it runs unders.mu.RLock. Please state which machine gave 54 ms, so readers know the read lock can be held about three times longer on slower hardware. - Two of the four tests in d4154ee were already green at that commit:
TestRegionFilterObserverScanForNodesNewerThanRecomputeandTestRegionFilterListingEveryRegionOmitsUnplacedNodes. They are the characterization tests I asked for, so that is fine, but they are not red tests. - The deferred transport-direct
path_lenoffset is now tracked in #2225.
Minor, no action needed
regionNodesCache(30 s TTL) is not cleared when a recompute installs a new snapshot. A filter can therefore show the previous placements for up to 30 s, which is within the existing TTL contract.
…2234) Red commit: 3bb8125 (fails on assertions both ways: `zero-hop counts = map[pk_relayed:map[SAT:1]], want map[pk_transport:map[AUS:1]]`) Fixes #2225 ## What this does `nodeZeroHopRegionCounts` read the path byte at offset 1 for `ROUTE_TYPE_TRANSPORT_DIRECT` adverts. Transport routes carry 4 transport-code bytes between the header and `path_length` (firmware `docs/packet_format.md`), so offset 1 is the first byte of `transport_code_1`. Two effects, both fixed: - A transport-direct zero-hop advert counted as zero-hop only when that code byte's low 6 bits happened to be 0, so the node usually got `fewest_hops` instead of `zero_hop`, and #804 analytics missed it. - A relayed transport-direct advert whose first code byte was `0x00` (or `0x40`, `0x80`, …) counted as zero-hop. ## How - New `packetpath.PathByte(rawHex) (routeType int, pathByte byte, ok bool)` applies the transport-code offset in one place, next to the existing `IsTransportRoute`, `HashSize` and `DecodePathFromRawHex`. - Every reader of the path byte from `raw_hex` in `store.go` now uses it: - `nodeZeroHopRegionCounts` (the bug); - `computeAnalyticsHashSizes`; - `computeNodeHashSizeInfo`; - `computeMultiByteCapability`. The last three already had the right offset, each with its own copy of the rule, so their behavior doesn't change. `packetpath.HashSize` uses `PathByte` too. ## Performance - `PathByte` runs once per advert on every recompute, so it must not allocate. `hexByteAt` now decodes the two hex nibbles itself instead of calling `hex.DecodeString`; `TestPathByteDoesNotAllocate` pins it at 0 allocs. - Without that, `BenchmarkRecomputeNodeHomeRegionSets` went from 17,232 to 457,232 allocs/op. With it, the count is unchanged from master. - Time on the same machine (Apple M5, Docker linux/arm64, `-count 3`): ~61 ms/op on master, ~54 ms/op here. Your hardware measured ~3x higher on #2190, so expect proportionally more there; the change itself only removes work. ## Tests - `TestZeroHopCountsReadTransportDirectPathByte`: a transport-direct zero-hop advert whose first code byte is `0xaa` counts, and a relayed one (path byte `0x41`) whose first code byte is `0x00` doesn't. Checked through both `nodeZeroHopRegionCounts` and `computeNodeHomeRegions` (#804). - `TestPathByte` covers all four route types, upper-case hex, and too-short or malformed input. `TestPathByteDoesNotAllocate` pins the allocation count. - `go test` passes for `cmd/server`, `cmd/ingestor` and `internal/packetpath` (`CGO_ENABLED=1`). `gofmt` and `go vet` are clean. No API or UI change, no new `map[string]interface{}`, and no config.
Red commit: cd546b2 (fails on assertions, e.g.
region "SAT": got [pk_aus pk_edge pk_far], want [pk_edge pk_far])Red commit (fewest-hops fallback): a66a7f3 (fails on assertions, e.g.
region "AUS": got [pk_cap pk_gulf pk_near pk_tie], want [pk_cap])Red commits (review round 1): d4154ee (
region_placementmissing), 862a451 (zero: RegionFallbackMaxHops() = 3, want 0), 16dcd8d (pk_mixed:{fewest_hops [AUS] hops=0}, want[SAT] hops=2), 95f2aca (pk_late not placed after the startup load finished)Fixes #1879
What this does
/api/nodes?region=put a node in every region where any observer heard any of its adverts. Adverts flood, so a node showed up in every region its adverts got relayed into, not just where it actually is.Now, as agreed in the issue:
Nodes heard zero-hop are placed by their home regions. Same rule as Bug/feature: Use actual repeaters region data and not observer based #804: a zero-hop DIRECT advert can't have been relayed, so the observer that heard it is in RF range. This is the tally
computeNodeHomeRegions()already does, pulled out intonodeZeroHopRegionCounts()so analytics and the filter share one definition.It's a set, not a winner. A node heard zero-hop in two regions is in both. So per-region counts don't add up to the node total anymore, on purpose.
Everything else goes by fewest hops. A node never heard zero-hop is in the region(s) whose observers heard it in the fewest hops, and in no region when even that is more than
regionFallback.maxHops(config, default 3); it still shows unfiltered. The old fallback (every region that heard it at all) turned out to be most of the leak: see the numbers at the bottom. Hearings with nopath_json(legacy NULL rows) are unknown, not zero hops, and are skipped.Only nodes the last recompute didn't cover (first advert newer than it, or no hearing with a path) still use the old observer check.
region_placementon every node row (list and detail) says which rule placed it, so an operator can tell the four outcomes apart:{"basis": "zero_hop", "regions": ["AUS"]} {"basis": "fewest_hops", "regions": ["HOU", "SAT"], "hops": 2} {"basis": "none", "hops": 5} {"basis": "observer_scan"}It's a named struct (
NodeRegionPlacement), documented as its own OpenAPI component. It replaces the earlierhome_regionsfield.How
node_home_regions.go: a recomputer in thebridge_recomputer.goshape (sync prewarm, ticker onAnalyticsDefaultRecomputeInterval(), atomic snapshot, panics swallowed), started frommain.gonext to the bridge one. It also recomputes once whenStartupLoadDone()closes, likerecomputeWhenLoadeddoes for the analytics recomputers, because the prewarm only sees the first chunk.NodeRegionPlacement, fromnodeZeroHopRegionCountsplusnodeFewestHopsByRegion. Hop counts come frompath_jsonwithout decoding (pathHopCount: hop hashes hold no commas).RegionNodePubkeysloads the snapshot (atomic, no lock), skips placed nodes in the observer scan, then adds the ones whose regions intersect the request. The 30 s region cache is unchanged.computeNodeHomeRegions()keeps its plurality output for analytics; only the counting moved. The Bug/feature: Use actual repeaters region data and not observer based #804 / fix(#804): attribute analytics by repeater home region, not observer #1025 tests are unchanged and pass.map[string]interface{}: the node rows setregion_placementto the struct, the OpenAPI component uses a small typedopenAPISchema, and the oldenrichNodeHomeRegionshelper is gone. Touched files are at the upstream count.Performance
RegionNodePubkeysadds one pass over the snapshot per cache miss, so O(nodes), well under the existing advert scan it sits next to.BenchmarkRegionNodePubkeysstays ~19 ms.s.mu.RLock:BenchmarkRecomputeNodeHomeRegionSetstimes the whole function at 220k adverts × 8 observations over 0-7 hop paths (one in eight zero-hop direct) plus 1M channel messages: ~54 ms/op on an Apple M5 (Docker, linux/arm64), of which the fewest-hops pass (BenchmarkNodeFewestHopsByRegion) is ~40 ms.nodeZeroHopRegionCountswalksbyPayloadType[ADVERT]instead of every packet (same counts), and the fewest-hops pass looks up a node's map once per advert instead of once per observation. Before them the whole recompute was ~72 ms on the same machine.Tests
TestRegionNodePubkeysUsesZeroHopHomeRegions: a node whose relayed advert reached SAT stays out of SAT; the boundary node is in both; the never-zero-hop node is placed by the fallback.TestBoundaryNodeStaysInEachHomeRegion: the two-region case on its own, so a later refactor can't quietly collapse it back to one winner.TestNodeRegionPlacementsZeroHop: the snapshot for zero-hop and fallback nodes.TestHandleNodesRegionPlacement:/api/nodesreturnszero_hop,noneandobserver_scanplacements for seeded nodes.TestRegionFallbackUsesFewestHops: fewest hops wins, ties keep both, exactly the cap is placed, past it is in no region (and the observer scan doesn't re-add it), a closer later advert counts.TestRegionFallbackMaxHopsConfig: the config accessor (unset or negative keeps 3, 0 allowed, parsed from JSON) and the recompute honoring it.TestRegionFallbackIgnoresUnknownPaths: an emptypath_jsondoesn't count as zero hops;[]still does.TestRegionFilterObserverScanForNodesNewerThanRecompute: the observer-scan path for a node newer than the snapshot.TestRegionFilterListingEveryRegionOmitsUnplacedNodes: naming every region explicitly still leaves out a node in no region.TestNodeRegionsRecomputeWhenStartupLoadDone: placements cover a later chunk once the startup load is done.cd cmd/server && go test ./...andcd cmd/ingestor && go test ./...pass (CGO_ENABLED=1).gofmtandgo vetare clean.No UI change; the API change is covered by the handler test above.
Config
regionFallback.maxHops(default 3), documented inconfig.example.json.Not in this PR
nodeZeroHopRegionCounts): forROUTE_TYPE_TRANSPORT_DIRECT,RawHex[2:4]is the first transport-code byte, notpath_len. The fallback still places such nodes (theirpath_jsonis[]), but asfewest_hops, notzero_hop. I'll send that as its own PR since it changes Bug/feature: Use actual repeaters region data and not observer based #804 analytics too.node_regionstable kept by the ingestor (theUpdateNodeDefaultScopepattern) would remove the window; happy to do that as a follow-up if you want it.Fallback numbers. On our network only ~27% of nodes are ever heard directly, so the fallback decides most of the filter. With "every region that heard it", our San Antonio filter showed 241 nodes whose GPS puts them outside SAT against 149 inside, and Austin picked up Louisiana and Gulf Coast nodes heard 3-10 hops out. We've been running the fewest-hops/cap-3 rule in production since 2026-10-14; nodes with GPS outside the selected region went SAT 241 → 7, DFW 76 → 7, HOU 47 → 2, AUS 226 → 48. About 240 nodes are in no region (they still show unfiltered).
For context, on our network (Texas, ~1,500 nodes) the same idea with the broader "heard directly" rule took San Antonio from 543 nodes to 482. The 61 removed were all Austin-area nodes whose adverts get relayed south, and only 3 nodes statewide are heard directly in two regions.