feat: swarm membership discovery via peer-list gossip

Refs #1

- /v1/ping now advertises the node's known peers (peer_id, addr, name)
- Run sweeps static KNOX_PEERS + persisted discovered peers, enqueueing
  newly-learned addresses for direct sweeps (membership-only relay; no
  observation relay)
- db: ShareablePeers, SwarmPeerAddrs, MergePeer (cursor-preserving
  discovery upsert), MaxHCLForNode
- integration test: a node configured with a single seed discovers and
  pulls from other swarm members without direct configuration
This commit is contained in:
2026-08-29 05:41:52 -07:00
parent 48e9c39d7b
commit 25a7112d8a
6 changed files with 204 additions and 20 deletions
+49 -12
View File
@@ -31,10 +31,11 @@ type Node struct {
// pingResponse is the anti-entropy summary returned by /v1/ping.
type pingResponse struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
Vector map[string]int64 `json:"vector"` // node_id → max hcl
MaxHCL *int64 `json:"max_hcl,omitempty"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Vector map[string]int64 `json:"vector"` // node_id → max hcl
Peers []db.PeerInfo `json:"peers"` // swarm membership this node knows
MaxHCL *int64 `json:"max_hcl,omitempty"`
}
func (n *Node) Handler() http.Handler {
@@ -52,10 +53,16 @@ func (n *Node) handlePing(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
peers, err := n.Kdb.ShareablePeers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, pingResponse{
NodeID: n.Kdb.NodeID(),
Name: n.Name,
Vector: vector,
Peers: peers,
})
}
@@ -255,17 +262,32 @@ func (c *Client) Diff() (*DiffSummary, error) {
return &d, nil
}
// Run executes one anti-entropy sweep against the given peer addresses.
func Run(kdb *db.KnoxDB, peers []string) {
// Run executes one anti-entropy + membership sweep.
//
// Peers are a fusion of the statically configured list (KNOX_PEERS) and peers
// previously discovered and persisted in the peers table (swarm join): syncing
// with any one node reveals who else is in the swarm, and those nodes are then
// swept too. There is no relay of observations — only membership is shared; each
// node pulls/pushes directly with every other node it learns about.
func Run(kdb *db.KnoxDB, static []string) {
myID := kdb.NodeID()
for _, addr := range peers {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
if strings.HasPrefix(addr, kdb.NodeID()+":") {
// Seed the work queue with static config plus persisted discoveries.
persisted, _ := kdb.SwarmPeerAddrs()
work := make([]string, 0, len(static)+len(persisted))
work = append(work, static...)
work = append(work, persisted...)
seen := make(map[string]bool) // addr → handled (also suppresses self)
queue := 0
for queue < len(work) {
addr := strings.TrimSpace(work[queue])
queue++
if addr == "" || seen[addr] {
continue
}
seen[addr] = true
c := &Client{Addr: addr, Timeout: 10 * time.Second}
p, err := c.Ping()
if err != nil {
@@ -276,6 +298,21 @@ func Run(kdb *db.KnoxDB, peers []string) {
continue // never talk to ourselves (or an aliased address)
}
// Membership discovery: learn who else is in the swarm and enqueue
// their addresses for direct sweeps.
for _, pi := range p.Peers {
if pi.PeerID == "" || pi.PeerID == myID || pi.Addr == "" {
continue
}
if err := kdb.MergePeer(pi.PeerID, pi.Addr, pi.Name); err != nil {
log.Printf("[gossip] merge peer %s: %v", pi.PeerID, err)
continue
}
if !seen[pi.Addr] {
work = append(work, pi.Addr)
}
}
pulled := 0
for remoteNode, remoteHCL := range p.Vector {
localHCL := kdbVectorGet(kdb, remoteNode)
+71 -2
View File
@@ -84,8 +84,77 @@ func TestGossipConvergence(t *testing.T) {
}
}
// TestGossipDiff ensures /v1/diff reports per-node observation fingerprints and
// tombstoned thread divergence.
// TestGossipSwarmDiscovery: C only knows A. A knows B. When C sweeps A, it must
// learn about B through A's ping, enqueue B, and pull B's observations — with no
// direct configuration of B (no relay of data, only membership).
func TestGossipSwarmDiscovery(t *testing.T) {
a := tmpKnoxDB(t)
b := tmpKnoxDB(t)
c := tmpKnoxDB(t)
seedObs(a, "AAA")
seedObs(b, "BBB")
seedObs(c, "CCC")
nodeA := &Node{Kdb: a, Name: "A"}
sa := httptest.NewServer(nodeA.Handler())
defer sa.Close()
nodeB := &Node{Kdb: b, Name: "B"}
sb := httptest.NewServer(nodeB.Handler())
defer sb.Close()
nodeC := &Node{Kdb: c, Name: "C"}
sc := httptest.NewServer(nodeC.Handler())
defer sc.Close()
// A discovers B (A pings B) so A can advertise B to the swarm.
Run(a, []string{sb.URL})
// C only knows A. A single sweep should surface B (membership in ping)
// and pull B's observations directly.
Run(c, []string{sa.URL})
// C must know B and hold all three origin logs.
peers, err := c.ListPeers()
if err != nil {
t.Fatalf("list peers: %v", err)
}
foundB := false
for _, p := range peers {
if p.PeerID == b.NodeID() {
foundB = true
}
}
if !foundB {
t.Fatalf("C did not discover B via A's membership list; peers=%v", peers)
}
vec, err := c.KnowledgeVector()
if err != nil {
t.Fatalf("c vector: %v", err)
}
if len(vec) != 3 {
t.Errorf("C should hold 3 origin logs (A, B, C), got %v", vec)
}
// C's copy of B's log must match B's own max hcl.
bmax := c.MaxHCLForNode(b.NodeID())
bm, err := bNodeMax(b)
if err != nil {
t.Fatal(err)
}
if bmax != bm {
t.Errorf("C max hcl for B=%d, B reports %d", bmax, bm)
}
}
// bNodeMax is a helper reading the max hcl B holds for its own node.
func bNodeMax(b *db.KnoxDB) (int64, error) {
rows, err := b.KnowledgeVector()
if err != nil {
return 0, err
}
return rows[b.NodeID()], nil
}
func TestGossipDiff(t *testing.T) {
a := tmpKnoxDB(t)
b := tmpKnoxDB(t)