8c054094a1
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
182 lines
5.7 KiB
Go
182 lines
5.7 KiB
Go
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
|
|
} |