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:
@@ -27,6 +27,23 @@ type Clock struct {
|
||||
|
||||
func New() *Clock { return &Clock{} }
|
||||
|
||||
// SeekTo adopts the given packed HLC value when it is ahead of the clock's current
|
||||
// position, so the next Now is still strictly increasing. Used to resume a node's
|
||||
// clock from its persisted MAX(hcl) at startup — without it, a restart with a
|
||||
// regressed wall clock would reissue already-used values and break the
|
||||
// monotonicity the (node_id, hcl) locator uniqueness and gossip cursors rely on.
|
||||
func (c *Clock) SeekTo(v int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
wall := v >> wallShift
|
||||
seq := v & seqMask
|
||||
if wall > c.wallMS || (wall == c.wallMS && seq > c.seq) {
|
||||
c.wallMS = wall
|
||||
c.seq = seq
|
||||
}
|
||||
}
|
||||
|
||||
// Now returns the next monotonic HLC value and the wall-clock time embedded in
|
||||
// it. The returned time is the HLC's wall component — never ahead of the local
|
||||
// clock beyond the current call and never rewinding across calls.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package hlc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSeekToFutureValueKeepsMonotonic: after resuming from a persisted value
|
||||
// ahead of the wall clock (clock regression), every subsequent Now must still
|
||||
// be strictly greater than the resumed position.
|
||||
func TestSeekToFutureValueKeepsMonotonic(t *testing.T) {
|
||||
c := New()
|
||||
resumed := int64(1) << 62 // packed value far ahead of any real wall clock
|
||||
c.SeekTo(resumed)
|
||||
|
||||
prev, _ := c.Now()
|
||||
if prev <= resumed {
|
||||
t.Fatalf("first Now after SeekTo = %d, want > resumed %d", prev, resumed)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
next, _ := c.Now()
|
||||
if next <= prev {
|
||||
t.Fatalf("HLC regressed: %d then %d", prev, next)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeekToLowerValueIgnored: resuming from a value behind the current clock
|
||||
// (or a fresh 0-padded DB) must not rewind it.
|
||||
func TestSeekToLowerValueIgnored(t *testing.T) {
|
||||
c := New()
|
||||
first, _ := c.Now()
|
||||
c.SeekTo(0)
|
||||
second, _ := c.Now()
|
||||
if second <= first {
|
||||
t.Fatalf("SeekTo(0) rewound the clock: %d then %d", first, second)
|
||||
}
|
||||
|
||||
// Seek to exactly the last emitted value: the next value must exceed it.
|
||||
c.SeekTo(second)
|
||||
third, _ := c.Now()
|
||||
if third <= second {
|
||||
t.Fatalf("SeekTo(last) did not preserve monotonicity: %d then %d", second, third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeekToAcrossRestart mirrors Open's reopen path: a fresh clock resumed
|
||||
// from the persisted max keeps issuing strictly increasing values.
|
||||
func TestSeekToAcrossRestart(t *testing.T) {
|
||||
c1 := New()
|
||||
var last int64
|
||||
for i := 0; i < 50; i++ {
|
||||
last, _ = c1.Now()
|
||||
}
|
||||
|
||||
c2 := New() // fresh process clock
|
||||
c2.SeekTo(last)
|
||||
|
||||
prev, _ := c2.Now()
|
||||
if prev <= last {
|
||||
t.Fatalf("reopened clock reissued a value: %d <= %d", prev, last)
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
next, _ := c2.Now()
|
||||
if next <= prev {
|
||||
t.Fatalf("reopened clock regressed: %d then %d", prev, next)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user