fix: harden gossip, HLC restarts, watcher races, MCP args, pagination (#3)
Implements the top findings from the codebase review, verified with tests and live CLI/MCP checks. **Gossip integrity** - Push validation: 4 MiB body cap, 1000-row batch cap; rows claiming the local node id (vector-poisoning), empty node ids, and negative HCLs rejected (internal/watch/gossip.go, internal/db/gossip.go) - Reconcile-on-pull: Run returns the pulled count, syncGossip rebuilds derived state when > 0 — entry-count comparison could never fire, so synced observations never materialized into searchable entries **Data-layer safety** - HLC resumed from MAX(hcl) at Open (hlc.SeekTo): a restart with a regressed wall clock cannot reissue values the (node_id, hcl) locator and pull cursors depend on - Writer serialization: _txlock=immediate DSN + SetMaxOpenConns(1) + per-KnoxDB mutex around RecordObservation's check-then-insert dedup (closes duplicate-row race) **Watch daemon** - Ticker guard flags now atomic.Bool (was a cross-goroutine data race) - Trailing-edge per-path debounce (timer-based, pruned on fire/delete) - Recursive watches (startup tree walk + watcher.Add on dir Create); Rename re-ingests, Remove cancels pending ingests **MCP + CLI** - Strict arg validation, no silent clamping: thread_id 0 errors instead of renaming thread #1; empty knox_thread_link {} errors instead of false success; thread existence checked before writes; golden-thread tool nil-safe - --page 0 errors instead of panicking; query/recent pagination actually pages (page x limit) **Tests** (new internal/hlc and internal/db packages): SeekTo monotonicity, concurrent dedup race, push validation, reopen HCL monotonicity, batch caps, self-spoof rejection, idempotency on observation counts. Verified: go build, go vet, full suite with -race, live MCP stdio transcripts against a scratch DB. Reviewed-on: #3 Co-authored-by: David Gwilliam <dhgwilliam@gmail.com> Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
+20
-1
@@ -22,6 +22,12 @@ type KnoxDB struct {
|
||||
db *sql.DB
|
||||
nodeID string
|
||||
clock *hlc.Clock
|
||||
|
||||
// writeMu serializes the check-then-insert dedup in RecordObservation within
|
||||
// this process. Cross-process serialization comes from _txlock=immediate (the
|
||||
// write lock is taken at BEGIN, before the dedup read) plus a single
|
||||
// connection per pool.
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
@@ -93,10 +99,13 @@ func Open(path string) (*KnoxDB, error) {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_txlock=immediate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
// One connection per process: WAL has a single writer; serializing on one
|
||||
// connection avoids pool contention surfacing as busy_timeout errors.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(Schema); err != nil {
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
@@ -135,6 +144,13 @@ func Open(path string) (*KnoxDB, error) {
|
||||
if _, err := db.Exec("UPDATE observations SET hcl=id WHERE hcl IS NULL"); err != nil {
|
||||
return nil, fmt.Errorf("backfill hcl: %w", err)
|
||||
}
|
||||
// Resume this node's HLC from its persisted max: a restart with a regressed
|
||||
// wall clock must not reissue already-persisted values (see hlc.SeekTo).
|
||||
var maxHCL int64
|
||||
if err := db.QueryRow(`SELECT COALESCE(MAX(hcl), 0) FROM observations WHERE node_id=?`, nodeID).Scan(&maxHCL); err != nil {
|
||||
return nil, fmt.Errorf("seed hlc: %w", err)
|
||||
}
|
||||
kdb.clock.SeekTo(maxHCL)
|
||||
|
||||
// Locator uniqueness: (node_id, hcl) is the merge key for gossip; a given
|
||||
// node's HCL is strictly monotonic so this never throws a false conflict.
|
||||
@@ -204,6 +220,9 @@ func (k *KnoxDB) ObservationEntryEstimate() int {
|
||||
// Idempotent: if the latest observation for this fingerprint has an identical
|
||||
// content signature, nothing is recorded — re-ingesting unchanged content is a no-op.
|
||||
func (k *KnoxDB) RecordObservation(o ObservationRecord) (obsID int64, isNew bool, err error) {
|
||||
k.writeMu.Lock()
|
||||
defer k.writeMu.Unlock()
|
||||
|
||||
tx, err := k.db.Begin()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user