feat: M3 peer gossip protocol
Refs #1 - peers table (peer_id, addr, cursor, last_handshake) - watch serves HTTP API: GET /v1/ping (knowledge vector), GET /v1/log?node=&after= (cursor-paged pull), POST /v1/obs/batch - anti-entropy sweep (Run): ping, pull what we lack, push own obs; echo suppressed by node_id ownership; reconcile-on-pull - config via KNOX_PEERS / KNOX_PEER_ADDR; knox gossip status - db: GossipObservation wire type, PushObservations, ObservationsAfter, KnowledgeVector, peer upsert/list - integration tests: bidirectional convergence + idempotency
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/david/knox/internal/db"
|
||||
)
|
||||
|
||||
func tmpKnoxDB(t *testing.T) *db.KnoxDB {
|
||||
t.Helper()
|
||||
kdb, err := db.Open(filepath.Join(t.TempDir(), "index.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { kdb.Close() })
|
||||
return kdb
|
||||
}
|
||||
|
||||
func seedObs(k *db.KnoxDB, prefix string) {
|
||||
// Insert distinct observations only via RecordObservation so they get this
|
||||
// node's node_id and HCL.
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, err := k.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: prefix + ":" + string(rune('a'+i)),
|
||||
SourceID: "test",
|
||||
SourcePath: prefix,
|
||||
Project: "itest",
|
||||
ContentType: "test",
|
||||
Title: prefix + string(rune('a'+i)),
|
||||
Summary: "summary " + prefix + string(rune('a'+i)),
|
||||
CreatedAt: "2026-08-29T00:00:00Z",
|
||||
LineEnd: 0,
|
||||
Confidence: 0.9,
|
||||
IngesterVersion: "itest/v1",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGossipConvergence starts two nodes, seeds disjoint observations, and runs
|
||||
// a bidirectional handshake. Both must end with the concatenation of both logs.
|
||||
func TestGossipConvergence(t *testing.T) {
|
||||
a := tmpKnoxDB(t)
|
||||
b := tmpKnoxDB(t)
|
||||
|
||||
seedObs(a, "AAA")
|
||||
seedObs(b, "BBB")
|
||||
|
||||
// Wire a and b as peer HTTP servers.
|
||||
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()
|
||||
|
||||
// A pulls from B, then B pulls from A (bidirectional sweep).
|
||||
Run(a, []string{sb.URL})
|
||||
Run(b, []string{sa.URL})
|
||||
|
||||
av, err := a.KnowledgeVector()
|
||||
if err != nil {
|
||||
t.Fatalf("a vector: %v", err)
|
||||
}
|
||||
bv, err := b.KnowledgeVector()
|
||||
if err != nil {
|
||||
t.Fatalf("b vector: %v", err)
|
||||
}
|
||||
|
||||
if len(av) != 2 || len(bv) != 2 {
|
||||
t.Fatalf("expected both nodes to hold 2 source logs, got a=%v b=%v", av, bv)
|
||||
}
|
||||
|
||||
// Same maximum HCL per origin node on both sides.
|
||||
for nid, hcl := range av {
|
||||
if bv[nid] != hcl {
|
||||
t.Errorf("node %s max hcl mismatch: a=%d b=%d", nid, hcl, bv[nid])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGossipIdempotent ensures a repeated sweep does not duplicate observations.
|
||||
func TestGossipIdempotent(t *testing.T) {
|
||||
a := tmpKnoxDB(t)
|
||||
b := tmpKnoxDB(t)
|
||||
|
||||
seedObs(a, "AAA")
|
||||
|
||||
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()
|
||||
|
||||
Run(b, []string{sa.URL})
|
||||
before, err := b.EntryCount()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Run(b, []string{sa.URL})
|
||||
after, err := b.EntryCount()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != after {
|
||||
t.Errorf("second sweep changed entry count: %d -> %d", before, after)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user