diff --git a/cmd/server/node_home_regions.go b/cmd/server/node_home_regions.go index 2569109d..0fe9985b 100644 --- a/cmd/server/node_home_regions.go +++ b/cmd/server/node_home_regions.go @@ -27,6 +27,10 @@ import ( const nodeHomeRegionsRecomputerDefaultInterval = 5 * time.Minute +// regionFallbackMaxHops bounds the fallback for nodes never heard zero-hop: a +// node whose closest hearing took more hops than this is in no region. +const regionFallbackMaxHops = 3 + var ( nodeHomeRegionsRecompStartedMu sync.Mutex nodeHomeRegionsRecompStarted bool @@ -129,3 +133,10 @@ func enrichNodeHomeRegions(node map[string]interface{}, regions []string) { } node["home_regions"] = regions } + +// GetNodeFallbackRegionSets returns the current fallback snapshot (pubkey → +// sorted regions, empty for a node in no region), or an empty map before the +// first compute. Read-only by convention. +func (s *PacketStore) GetNodeFallbackRegionSets() map[string][]string { + return map[string][]string{} +} diff --git a/cmd/server/node_home_regions_test.go b/cmd/server/node_home_regions_test.go index 0e7ec210..89202c34 100644 --- a/cmd/server/node_home_regions_test.go +++ b/cmd/server/node_home_regions_test.go @@ -135,3 +135,81 @@ func TestHandleNodesHomeRegions(t *testing.T) { t.Fatalf("expected both seeded nodes in the response, saw %d", seen) } } + +// mkPathAdvert is a relayed flood advert heard by each observer over the given +// path (JSON array of hop hashes, "[]" for a direct hearing). +func mkPathAdvert(id int, pubkey string, hearings map[string]string) *StoreTx { + tx := mkHomeRegionAdvert(id, pubkey, homeRelayedFlood) + i := 0 + for oid, path := range hearings { + tx.Observations = append(tx.Observations, &StoreObs{ + ID: id*10 + i, TransmissionID: id, ObserverID: oid, PathJSON: path, + }) + i++ + } + return tx +} + +// A node never heard zero-hop used to be in every region that heard any of its +// adverts. Adverts flood the whole mesh, so on a statewide deployment that put +// most nodes in every region. It now goes in the regions that heard it in the +// fewest hops, and in none when even that is over regionFallbackMaxHops. +func TestRegionFallbackUsesFewestHops(t *testing.T) { + ps := newRegionTestStore(t, map[string]string{"o-aus": "AUS", "o-sat": "SAT", "o-hou": "HOU"}) + addToStore(ps, + // One hop from SAT, relayed four hops to AUS: SAT only. + mkPathAdvert(1, "pk_near", map[string]string{"o-sat": `["aa"]`, "o-aus": `["aa","bb","cc","dd"]`}), + // Tied fewest hops: both. + mkPathAdvert(2, "pk_tie", map[string]string{"o-sat": `["aa","bb"]`, "o-hou": `["cc","dd"]`, "o-aus": `["aa","bb","cc"]`}), + // Exactly regionFallbackMaxHops is still placed. + mkPathAdvert(3, "pk_cap", map[string]string{"o-aus": `["aa","bb","cc"]`}), + // Closest hearing beyond the cap: no region. + mkPathAdvert(4, "pk_gulf", map[string]string{"o-aus": `["aa","bb","cc","dd"]`, "o-hou": `["aa","bb","cc","dd","ee","ff"]`}), + // A later advert heard closer counts. + mkPathAdvert(5, "pk_gulf2", map[string]string{"o-hou": `["aa","bb","cc","dd","ee"]`}), + mkPathAdvert(6, "pk_gulf2", map[string]string{"o-hou": `["aa"]`}), + ) + ps.recomputeNodeHomeRegionSets() + + cases := []struct { + region string + want []string + }{ + {"SAT", []string{"pk_near", "pk_tie"}}, + {"AUS", []string{"pk_cap"}}, + {"HOU", []string{"pk_gulf2", "pk_tie"}}, + } + for _, c := range cases { + got, ok := ps.RegionNodePubkeys(c.region) + if !ok { + t.Fatalf("region %q: ok=false", c.region) + } + if g := sortedKeys(got); !reflect.DeepEqual(g, c.want) { + t.Errorf("region %q: got %v, want %v", c.region, g, c.want) + } + } + + want := map[string][]string{ + "pk_near": {"SAT"}, + "pk_tie": {"HOU", "SAT"}, + "pk_cap": {"AUS"}, + "pk_gulf": {}, + "pk_gulf2": {"HOU"}, + } + if got := ps.GetNodeFallbackRegionSets(); !reflect.DeepEqual(got, want) { + t.Errorf("fallback region sets = %v, want %v", got, want) + } +} + +// Zero-hop home regions still win: a node with any zero-hop hearing is not in +// the fallback snapshot at all. +func TestRegionFallbackSkipsZeroHopNodes(t *testing.T) { + ps := newHomeRegionStore(t) + fb := ps.GetNodeFallbackRegionSets() + if _, has := fb["pk_aus"]; has { + t.Errorf("pk_aus has zero-hop home regions but is in the fallback: %v", fb) + } + if !reflect.DeepEqual(fb["pk_far"], []string{"SAT"}) { + t.Errorf("pk_far fallback = %v, want [SAT]", fb["pk_far"]) + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 1b1e1177..2a3b54d8 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -448,6 +448,9 @@ type PacketStore struct { // #1879: pubkey → regions whose observers heard it zero-hop, for the // /api/nodes region filter. Written by the node-home-regions recomputer. nodeHomeRegionSets atomic.Pointer[map[string][]string] + // #1879: pubkey → regions that heard it in the fewest hops, for nodes + // never heard zero-hop; empty when even that is over regionFallbackMaxHops. + nodeFallbackRegionSets atomic.Pointer[map[string][]string] // Coverage + Redundancy axes (issue #672 axes 3 & 4 of 4): atomic // snapshots of pubkey → 0..1 score over the current neighbor graph.