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:
2026-08-29 04:53:26 -07:00
parent aa0dec68c1
commit 8c054094a1
10 changed files with 755 additions and 20 deletions
+182
View File
@@ -0,0 +1,182 @@
package db
import (
"database/sql"
"fmt"
)
// GossipObservation is the serializable wire form of an observation exchanged
// between nodes. It carries the locator (NodeID, HCL) so the receiver can
// idempotently INSERT OR IGNORE on the unique index.
type GossipObservation struct {
NodeID string
HCL int64
Fingerprint string
SourceID string
SourcePath string
Project string
ContentType string
Title string
Summary string
CollectedAt string
CreatedAt string
LineStart int
LineEnd int
Confidence float64
IngesterVersion string
Trigger string
Provenance string
}
// PushObservations inserts gossip rows idempotently. Rows whose (node_id, hcl)
// already exist are ignored (a node's HCL is locally monotonic, so a given
// locator is immutable). Returns the number newly inserted.
func (k *KnoxDB) PushObservations(rows []GossipObservation) (int, error) {
tx, err := k.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
inserted := 0
for _, o := range rows {
res, err := tx.Exec(
`INSERT OR IGNORE INTO observations
(fingerprint, source_id, source_path, project, content_type, title, summary,
collected_at, created_at, line_start, line_end, confidence, ingester_version, trigger, provenance,
node_id, hcl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
o.Fingerprint, o.SourceID, o.SourcePath, o.Project, o.ContentType, o.Title, o.Summary,
o.CollectedAt, o.CreatedAt, o.LineStart, o.LineEnd, o.Confidence, o.IngesterVersion, o.Trigger, o.Provenance,
o.NodeID, o.HCL,
)
if err != nil {
return inserted, fmt.Errorf("push observation: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
inserted++
}
}
return inserted, tx.Commit()
}
// ObservationsAfter returns gossip rows for one node after a given HCL cursor,
// in HCL order. after <= 0 means "everything" (initial handshake).
func (k *KnoxDB) ObservationsAfter(nodeID string, after int64, limit int) ([]GossipObservation, error) {
if limit <= 0 {
limit = 500
}
query := `SELECT node_id, hcl, fingerprint, source_id, COALESCE(source_path,''), COALESCE(project,''),
COALESCE(content_type,''), COALESCE(title,''), COALESCE(summary,''),
COALESCE(collected_at,''), COALESCE(created_at,''),
COALESCE(line_start,0), COALESCE(line_end,0), COALESCE(confidence,0.5),
COALESCE(ingester_version,''), COALESCE(trigger,''), COALESCE(provenance,'{}')
FROM observations WHERE node_id=?`
args := []any{nodeID}
if after > 0 {
query += ` AND hcl > ?`
args = append(args, after)
}
query += ` ORDER BY hcl ASC LIMIT ?`
args = append(args, limit)
rows, err := k.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []GossipObservation
for rows.Next() {
var o GossipObservation
if err := rows.Scan(&o.NodeID, &o.HCL, &o.Fingerprint, &o.SourceID, &o.SourcePath,
&o.Project, &o.ContentType, &o.Title, &o.Summary, &o.CollectedAt, &o.CreatedAt,
&o.LineStart, &o.LineEnd, &o.Confidence, &o.IngesterVersion, &o.Trigger, &o.Provenance); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// KnowledgeVector returns node_id → max HCL held locally. It is the anti-entropy
// summary exchanged at handshake.
func (k *KnoxDB) KnowledgeVector() (map[string]int64, error) {
rows, err := k.db.Query(`SELECT node_id, MAX(hcl) FROM observations WHERE node_id<>'' AND hcl IS NOT NULL GROUP BY node_id`)
if err != nil {
return nil, err
}
defer rows.Close()
kv := make(map[string]int64)
for rows.Next() {
var nid string
var hcl int64
if err := rows.Scan(&nid, &hcl); err != nil {
return nil, err
}
kv[nid] = hcl
}
return kv, rows.Err()
}
// ObservePairs returns all observations from other nodes (for consume/echo
// suppression checks).
func (k *KnoxDB) ObserveForeign(nodeID string) (int, error) {
var n int
err := k.db.QueryRow(`SELECT COUNT(*) FROM observations WHERE node_id<>? AND node_id<>''`, nodeID).Scan(&n)
return n, err
}
// UpsertPeer records a peer's last-handshake metadata.
func (k *KnoxDB) UpsertPeer(peerID, addr, name string, maxHCL int64) error {
_, err := k.db.Exec(
`INSERT INTO peers (peer_id, addr, name, last_handshake, cursor) VALUES (?, ?, ?, datetime('now'), ?)
ON CONFLICT(peer_id) DO UPDATE SET
addr=?, name=?,
last_handshake=datetime('now'),
cursor=?`,
peerID, addr, name, maxHCL, addr, name, maxHCL,
)
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`)
if err != nil {
return nil, err
}
defer rows.Close()
var peers []Peer
for rows.Next() {
var p Peer
if err := rows.Scan(&p.PeerID, &p.Addr, &p.Name, &p.LastHandshake, &p.Cursor, &p.CreatedAt); err != nil {
return nil, err
}
peers = append(peers, p)
}
return peers, rows.Err()
}
// GetPeerByAddr finds a peer whose address matches (statically configured).
func (k *KnoxDB) GetPeerByAddr(addr string) (*Peer, error) {
row := k.db.QueryRow(`SELECT peer_id, COALESCE(addr,''), COALESCE(name,''), COALESCE(last_handshake,''), COALESCE(cursor,0), COALESCE(created_at,'') FROM peers WHERE addr=?`, addr)
p := &Peer{}
err := row.Scan(&p.PeerID, &p.Addr, &p.Name, &p.LastHandshake, &p.Cursor, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
// Peer models a row in the peers table.
type Peer struct {
PeerID string
Addr string
Name string
LastHandshake string
Cursor int64
CreatedAt string
}
+10
View File
@@ -113,6 +113,16 @@ CREATE TABLE IF NOT EXISTS settings (
value TEXT
);
-- PEERS: other knox nodes known to this one (gossip ring)
CREATE TABLE IF NOT EXISTS peers (
peer_id TEXT PRIMARY KEY, -- remote node_id
addr TEXT, -- e.g. http://192.168.1.20:8931
name TEXT,
last_handshake TEXT,
cursor INTEGER DEFAULT 0, -- remote's max_hcl we've seen (knowledge)
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
CREATE TABLE IF NOT EXISTS thread_observations (