fix: harden gossip, HLC restarts, watcher races, MCP args, pagination

- gossip: validate push batches (4 MiB / 1000-row caps); reject rows
  claiming the local node id (vector-poisoning), empty ids, negative HCLs
- gossip: reconcile derived state after pulls (pulls only append to the
  observation log, so entry-count comparison could never trigger it)
- hlc: seek clock from persisted MAX(hcl) at Open so a restart with a
  regressed wall clock cannot reissue values (locator/cursor safety)
- db: serialize writers via BEGIN IMMEDIATE DSN, single conn per pool,
  and a per-KnoxDB mutex around RecordObservation's dedup
- watch: atomic ticker guards (was a cross-goroutine data race),
  trailing-edge per-path debounce, recursive directory watches,
  rename re-ingest, remove cancels pending ingests
- mcp: strict argument validation (no silent clamping), thread existence
  checks before writes, nil-safe golden-thread tool
- cli: --page 0 no longer panics; query/recent pagination actually pages
- tests: hlc SeekTo monotonicity, concurrent dedup race, push validation,
  batch caps, idempotency on observation counts
This commit is contained in:
2026-09-17 01:52:06 -07:00
parent 876d2aa45f
commit 6845975b7b
10 changed files with 733 additions and 116 deletions
+15 -2
View File
@@ -98,10 +98,16 @@ func nextCursor(rows []db.GossipObservation) int64 {
return rows[len(rows)-1].HCL
}
// maxBatchRows bounds the number of observations a peer may push in one POST.
// Pull already pages at 500 rows, so any larger batch is at best redundant and
// at worst a flood; capping keeps memory and insert work bounded.
const maxBatchRows = 1000
func (n *Node) handleBatch(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, "batch too large or unreadable", http.StatusBadRequest)
return
}
var rows []db.GossipObservation
@@ -109,6 +115,10 @@ func (n *Node) handleBatch(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
if len(rows) > maxBatchRows {
http.Error(w, fmt.Sprintf("batch too large: %d rows (max %d)", len(rows), maxBatchRows), http.StatusBadRequest)
return
}
inserted, err := n.Kdb.PushObservations(rows)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -273,7 +283,7 @@ func (c *Client) Diff() (*DiffSummary, error) {
// node pulls/pushes directly with every other node it learns about.
//
// m, when non-nil, receives gossip event counters (nil for one-shot CLI runs).
func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) int {
myID := kdb.NodeID()
// Seed the work queue with static config plus persisted discoveries.
@@ -284,6 +294,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
seen := make(map[string]bool) // addr → handled (also suppresses self)
queue := 0
pulledTotal := 0
for queue < len(work) {
addr := strings.TrimSpace(work[queue])
queue++
@@ -338,6 +349,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
pulled += n
}
}
pulledTotal += pulled
if m != nil {
m.IncrementPull(pulled)
}
@@ -356,6 +368,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
log.Printf("[gossip] synced with %s (%s): in sync (pushed %d)", p.NodeID, addr, pushed)
}
}
return pulledTotal
}
func kdbVectorGet(kdb *db.KnoxDB, nodeID string) int64 {