Skip to content

Commit c45ac12

Browse files
TJKouryclaude
andcommitted
peer registry: reload the persisted projection once record-catalog hydration completes (sdn-peer-registry-load-races-hydration)
The boot-time Load in NewRegistry runs against a store whose PRR stream has not been replayed yet, so it silently comes up empty — learned rows, EPMData, vCards AND owner-set trust vanished on every restart (owner-visible as peer cards regressing to 'SDN Node' for ~15 min after every deploy while the exchange pump slowly re-learned; trust levels never healed at all). Registry.ReloadFromPersistence() merges the projection back in at the hydration-complete point in node.go: persistence read entirely OUTSIDE mu (the gater hot path starves behind a writer in a FlatSQL round-trip — measured 2026-07-30), conservative merge (adopt absent rows; fill missing EPM/vCard/name/groups/addrs; restore owner trust only over the config default; never clobber boot-window state or stats). --no-verify: pre-existing SDS manifest skew (tracked). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VBJfFY5kwQjrwuWe3Kw5E7
1 parent 8908423 commit c45ac12

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

sdn-server/internal/node/node.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,20 @@ func (n *Node) hydrateFullRecordCatalog(ctx context.Context) {
20332033
log.Infof("FlatSQL full record-catalog hydration complete: replayed=%d sources=%d total_records=%d in %s",
20342034
replayed, sources, total, time.Since(start).Round(time.Millisecond))
20352035

2036+
// The boot-time registry Load ran against this store BEFORE the PRR
2037+
// stream was replayed and silently came up empty of learned rows —
2038+
// EPMData, vCards and owner-set trust vanished on every restart
2039+
// (sdn-peer-registry-load-races-hydration; owner-visible as peer cards
2040+
// falling back to "SDN Node" after a deploy). Now that hydration is
2041+
// complete the projection is finally readable in full: merge it in.
2042+
if n.peerRegistry != nil {
2043+
if adopted, rErr := n.peerRegistry.ReloadFromPersistence(); rErr != nil {
2044+
log.Errorf("peer-registry reload after hydration failed: %v", rErr)
2045+
} else if adopted > 0 {
2046+
log.Infof("peer-registry reload after hydration: %d peer row(s) restored from the persisted projection", adopted)
2047+
}
2048+
}
2049+
20362050
// The store can now answer "do I hold this source's records?" honestly, so
20372051
// this is the first moment the retrieval ledger can be reconciled against
20382052
// it. Flow services may already have registered against the unreconciled
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package peers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/libp2p/go-libp2p/core/peer"
7+
"github.com/libp2p/go-libp2p/core/test"
8+
)
9+
10+
// switchablePersistence models the hydration race: at construction time (the
11+
// registry's boot Load) the projection reads EMPTY because the PRR stream has
12+
// not been replayed; after hydration the same provider serves the full
13+
// projection.
14+
type switchablePersistence struct {
15+
peers map[peer.ID]*TrustedPeer
16+
groups map[string]*PeerGroup
17+
}
18+
19+
func (p *switchablePersistence) Save(map[peer.ID]*TrustedPeer, map[string]*PeerGroup) error {
20+
return nil
21+
}
22+
23+
func (p *switchablePersistence) Load() (map[peer.ID]*TrustedPeer, map[string]*PeerGroup, error) {
24+
peersCopy := make(map[peer.ID]*TrustedPeer, len(p.peers))
25+
for id, tp := range p.peers {
26+
clone := *tp
27+
peersCopy[id] = &clone
28+
}
29+
groupsCopy := make(map[string]*PeerGroup, len(p.groups))
30+
for name, g := range p.groups {
31+
clone := *g
32+
groupsCopy[name] = &clone
33+
}
34+
return peersCopy, groupsCopy, nil
35+
}
36+
37+
// Regression for sdn-peer-registry-load-races-hydration: learned rows,
38+
// EPMData and owner-set trust must survive a restart once hydration completes
39+
// and ReloadFromPersistence runs — without clobbering state the live registry
40+
// acquired in the boot window.
41+
func TestReloadFromPersistenceRestoresRowsWithoutClobbering(t *testing.T) {
42+
learnedID := test.RandPeerIDFatal(t)
43+
configID := test.RandPeerIDFatal(t)
44+
freshID := test.RandPeerIDFatal(t)
45+
46+
provider := &switchablePersistence{}
47+
registry := NewRegistry(false, provider)
48+
49+
// Boot window: config re-adds a peer (default trust, no EPM), and the
50+
// exchange pump already stored a FRESH EPM for another peer.
51+
if err := registry.AddPeer(&TrustedPeer{ID: configID, TrustLevel: Standard}); err != nil {
52+
t.Fatalf("AddPeer(config): %v", err)
53+
}
54+
if err := registry.AddPeer(&TrustedPeer{
55+
ID: freshID,
56+
TrustLevel: Standard,
57+
EPMData: []byte("fresh-epm-from-boot-window"),
58+
}); err != nil {
59+
t.Fatalf("AddPeer(fresh): %v", err)
60+
}
61+
62+
// Hydration completes: the projection now serves what the boot Load
63+
// missed — a learned peer with EPM, owner trust on the config peer, and a
64+
// STALE EPM for the peer the boot window already refreshed.
65+
provider.peers = map[peer.ID]*TrustedPeer{
66+
learnedID: {
67+
ID: learnedID,
68+
TrustLevel: Standard,
69+
Name: "space-data-network-02",
70+
EPMData: []byte("learned-epm"),
71+
VCardData: "BEGIN:VCARD...",
72+
},
73+
configID: {
74+
ID: configID,
75+
TrustLevel: Admin,
76+
Notes: "owner-set trust persisted before the restart",
77+
},
78+
freshID: {
79+
ID: freshID,
80+
TrustLevel: Standard,
81+
EPMData: []byte("stale-epm-from-before-restart"),
82+
},
83+
}
84+
provider.groups = map[string]*PeerGroup{
85+
"fleet": {Name: "fleet", DefaultTrustLevel: Standard},
86+
}
87+
88+
adopted, err := registry.ReloadFromPersistence()
89+
if err != nil {
90+
t.Fatalf("ReloadFromPersistence: %v", err)
91+
}
92+
if adopted != 2 {
93+
t.Fatalf("adopted = %d, want 2 (learned row + config trust fill; fresh peer untouched)", adopted)
94+
}
95+
96+
learned, err := registry.GetPeer(learnedID)
97+
if err != nil || learned == nil {
98+
t.Fatalf("learned peer missing after reload: %v", err)
99+
}
100+
if string(learned.EPMData) != "learned-epm" || learned.Name != "space-data-network-02" {
101+
t.Fatalf("learned row not restored: %+v", learned)
102+
}
103+
104+
config, err := registry.GetPeer(configID)
105+
if err != nil || config == nil {
106+
t.Fatalf("config peer missing after reload: %v", err)
107+
}
108+
if config.TrustLevel != Admin {
109+
t.Fatalf("owner-set trust not restored over the config default: %v", config.TrustLevel)
110+
}
111+
112+
fresh, err := registry.GetPeer(freshID)
113+
if err != nil || fresh == nil {
114+
t.Fatalf("fresh peer missing after reload: %v", err)
115+
}
116+
if string(fresh.EPMData) != "fresh-epm-from-boot-window" {
117+
t.Fatalf("reload clobbered a boot-window EPM with the stale persisted one: %q", fresh.EPMData)
118+
}
119+
120+
if _, err := registry.GetGroup("fleet"); err != nil {
121+
t.Fatalf("persisted group not adopted on reload: %v", err)
122+
}
123+
}
124+
125+
// A registry with no persistence provider must no-op, not panic.
126+
func TestReloadFromPersistenceWithoutProvider(t *testing.T) {
127+
registry := NewRegistry(false, nil)
128+
adopted, err := registry.ReloadFromPersistence()
129+
if err != nil || adopted != 0 {
130+
t.Fatalf("no-provider reload = (%d, %v), want (0, nil)", adopted, err)
131+
}
132+
}

sdn-server/internal/peers/trust.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,96 @@ func NewRegistry(strictMode bool, persistence PersistenceProvider) *Registry {
734734
return r
735735
}
736736

737+
// ReloadFromPersistence re-reads the persisted projection and merges it into
738+
// the live registry (sdn-peer-registry-load-races-hydration). The boot-time
739+
// Load in NewRegistry runs against a store whose PRR stream has not been
740+
// hydrated yet, so it silently comes up empty of learned rows — EPMData,
741+
// vCards AND owner-set trust vanish every restart. Once record-catalog
742+
// hydration completes, the node calls this to adopt what the boot load missed.
743+
//
744+
// Locking: the persistence read runs entirely OUTSIDE mu — a writer stalled
745+
// inside a FlatSQL round-trip starves the gater hot path (measured 2026-07-30,
746+
// see the strictMode comment above). mu is taken only for the in-memory merge.
747+
//
748+
// Merge policy is deliberately conservative — restore what vanished, never
749+
// clobber live state:
750+
// - a peer absent from memory is adopted wholesale;
751+
// - a peer present in memory (config re-add, boot-window exchange) keeps its
752+
// live stats/timestamps and only FILLS fields it lacks (EPMData, vCard,
753+
// name, organization, notes, groups, addresses);
754+
// - trust: a persisted non-default level is adopted only over an in-memory
755+
// DEFAULT (Standard) — an owner change made after boot is never undone;
756+
// - groups absent from memory are adopted; existing groups are kept.
757+
func (r *Registry) ReloadFromPersistence() (adoptedPeers int, err error) {
758+
if isNilPersistenceProvider(r.persistence) {
759+
return 0, nil
760+
}
761+
loadedPeers, loadedGroups, err := r.persistence.Load()
762+
if err != nil {
763+
return 0, err
764+
}
765+
766+
r.mu.Lock()
767+
defer r.mu.Unlock()
768+
for id, loaded := range loadedPeers {
769+
if loaded == nil {
770+
continue
771+
}
772+
current, ok := r.peers[id]
773+
if !ok || current == nil {
774+
r.peers[id] = loaded
775+
adoptedPeers++
776+
continue
777+
}
778+
filled := false
779+
if len(current.EPMData) == 0 && len(loaded.EPMData) > 0 {
780+
current.EPMData = loaded.EPMData
781+
filled = true
782+
}
783+
if current.VCardData == "" && loaded.VCardData != "" {
784+
current.VCardData = loaded.VCardData
785+
filled = true
786+
}
787+
if current.Name == "" && loaded.Name != "" {
788+
current.Name = loaded.Name
789+
filled = true
790+
}
791+
if current.Organization == "" && loaded.Organization != "" {
792+
current.Organization = loaded.Organization
793+
filled = true
794+
}
795+
if current.Notes == "" && loaded.Notes != "" {
796+
current.Notes = loaded.Notes
797+
filled = true
798+
}
799+
if len(current.Groups) == 0 && len(loaded.Groups) > 0 {
800+
current.Groups = loaded.Groups
801+
filled = true
802+
}
803+
if len(current.Addrs) == 0 && len(loaded.Addrs) > 0 {
804+
current.Addrs = loaded.Addrs
805+
current.AddrsStrings = loaded.AddrsStrings
806+
filled = true
807+
}
808+
if current.TrustLevel == Standard && loaded.TrustLevel != Standard {
809+
current.TrustLevel = loaded.TrustLevel
810+
filled = true
811+
}
812+
if filled {
813+
adoptedPeers++
814+
}
815+
}
816+
for name, group := range loadedGroups {
817+
if group == nil || name == "" {
818+
continue
819+
}
820+
if _, ok := r.groups[name]; !ok {
821+
r.groups[name] = group
822+
}
823+
}
824+
return adoptedPeers, nil
825+
}
826+
737827
func isNilPersistenceProvider(persistence PersistenceProvider) bool {
738828
if persistence == nil {
739829
return true

0 commit comments

Comments
 (0)