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
+6
View File
@@ -142,6 +142,12 @@ func Open(path string) (*KnoxDB, error) {
return nil, fmt.Errorf("create locator index: %w", err)
}
// Thread idempotency index must come after the cluster_key migration (a
// fresh DB has the column from Schema; an existing DB gets it above).
if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_cluster ON threads(cluster_key) WHERE cluster_key IS NOT NULL AND cluster_key != ''`); err != nil {
return nil, fmt.Errorf("create cluster_key index: %w", err)
}
return kdb, nil
}
+67
View File
@@ -141,6 +141,18 @@ func (k *KnoxDB) UpsertPeer(peerID, addr, name string, maxHCL int64) error {
return err
}
// MergePeer records a peer discovered indirectly (via another peer's ping).
// Unlike UpsertPeer it never clobbers the cursor — a freshly learned address
// has no known knowledge yet; the anti-entropy pull will set it on contact.
func (k *KnoxDB) MergePeer(peerID, addr, name string) error {
_, err := k.db.Exec(
`INSERT INTO peers (peer_id, addr, name) VALUES (?, ?, ?)
ON CONFLICT(peer_id) DO UPDATE SET addr=?, name=?`,
peerID, addr, name, addr, name,
)
return err
}
// ListPeers returns known peers ordered by first-seen.
func (k *KnoxDB) ListPeers() ([]Peer, error) {
rows, err := k.db.Query(`SELECT peer_id, COALESCE(addr,''), COALESCE(name,''), COALESCE(last_handshake,''), COALESCE(cursor,0), COALESCE(created_at,'') FROM peers ORDER BY created_at`)
@@ -181,6 +193,61 @@ type Peer struct {
CreatedAt string
}
// PeerInfo is the shareable (non-secret) subset of a peer that /v1/ping
// advertises so other nodes can discover the swarm.
type PeerInfo struct {
PeerID string `json:"peer_id"`
Addr string `json:"addr"`
Name string `json:"name"`
}
// ShareablePeers returns the peers this node knows about, for dissemination in
// ping responses. Self and peers without an address are excluded.
func (k *KnoxDB) ShareablePeers() ([]PeerInfo, error) {
rows, err := k.db.Query(
`SELECT peer_id, COALESCE(addr,''), COALESCE(name,'') FROM peers WHERE addr<>'' AND peer_id<>? ORDER BY peer_id`,
k.NodeID(),
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PeerInfo
for rows.Next() {
var p PeerInfo
if err := rows.Scan(&p.PeerID, &p.Addr, &p.Name); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// MaxHCLForNode returns the maximum HCL this node holds for a given origin
// node, or 0 if none.
func (k *KnoxDB) MaxHCLForNode(nodeID string) int64 {
var m int64
if err := k.db.QueryRow(`SELECT MAX(hcl) FROM observations WHERE node_id=?`, nodeID).Scan(&m); err != nil {
return 0
}
return m
}
// SwarmPeerAddrs returns the addresses of all known peers (shareable set). It
// is what the sweep iterates after a bootstrap join.
func (k *KnoxDB) SwarmPeerAddrs() ([]string, error) {
peers, err := k.ShareablePeers()
if err != nil {
return nil, err
}
addrs := make([]string, 0, len(peers))
for _, p := range peers {
addrs = append(addrs, p.Addr)
}
return addrs, nil
}
// DistinctFingerprints returns the set of all observed fingerprints — the
// ground-truth index of what this node knows. Used by gossip diff.
func (k *KnoxDB) DistinctFingerprints() (map[string]bool, error) {
-2
View File
@@ -105,8 +105,6 @@ CREATE TABLE IF NOT EXISTS threads (
cluster_key TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_cluster ON threads(cluster_key) WHERE cluster_key IS NOT NULL AND cluster_key != '';
-- SETTINGS: key-value store for runtime configuration
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,