fix: derived-state determinism, error visibility, metrics naming, HTTP hardening
- rebuild: fold observations per fingerprint in a total order (hcl DESC, node_id DESC, id DESC) so two nodes with identical logs rebuild identical entries (was: arbitrary bare-column row, merge-order dependent) - threads: CreateThreadCluster uses INSERT OR IGNORE + existing-id fallback — concurrent auto-threaders converge instead of hitting UNIQUE - errors surfaced instead of swallowed: scanEntries returns rows.Err(), Stats() fails fast on query errors, AutoLinkThreadObservations / linkTemporalNeighbors / golden-thread linking propagate failures, AddThreadNote + LinkObservationToThread write under one tx, watch records session upsert failures - gossip client: push checks HTTP status and reports errors (a broken push direction no longer looks like a silent success); gossip diff gets a 10s timeout so a dead peer cannot hang the CLI - ingest: failed source sweeps (obsidian/browser/gitea) are logged, and -d's help text now states its file-only scope - watch --quiet: fatal errors go to stderr instead of io.Discard - main: cobra SilenceErrors/SilenceUsage (errors print once, usage is not dumped on runtime failures); knox mcp exits 0 on SIGINT/SIGTERM - metrics: drop _total suffix from gauges (knox_observations, knox_entries, knox_projects, knox_sessions, knox_peers, knox_threads); _total stays on counters per Prometheus convention - http: ReadHeaderTimeout + IdleTimeout on gossip, metrics, and web servers tests: concurrent cluster-create idempotency, HCL-order rebuild fold (both merge orders), push HTTP-error surfacing; full suite + -race pass, gofmt clean
This commit is contained in:
@@ -154,3 +154,89 @@ func TestOpenReopenHCLMonotonicAcrossRestart(t *testing.T) {
|
||||
t.Errorf("unexpected row above max: %s", after[0].Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateThreadClusterConcurrentIdempotent: two concurrent creators of the
|
||||
// same cluster key must converge on one thread (one created=true, one false)
|
||||
// with no UNIQUE constraint error — the INSERT OR IGNORE path.
|
||||
func TestCreateThreadClusterConcurrentIdempotent(t *testing.T) {
|
||||
k := tmpDB(t)
|
||||
const key = "cluster:race"
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]int64, 2)
|
||||
created := make([]bool, 2)
|
||||
errs := make([]error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ids[i], created[i], errs[i] = k.CreateThreadCluster("Race thread", "m", "medium", "[]", `{}`, key)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i := range errs {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("creator %d: %v", i, errs[i])
|
||||
}
|
||||
}
|
||||
if ids[0] != ids[1] {
|
||||
t.Errorf("concurrent creators got different ids: %d vs %d", ids[0], ids[1])
|
||||
}
|
||||
if created[0] == created[1] {
|
||||
t.Errorf("exactly one creator should report created=true, got %v %v", created[0], created[1])
|
||||
}
|
||||
threads, err := k.ListThreads("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, th := range threads {
|
||||
if th.Title == "Race thread" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("want exactly 1 race thread, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildEntriesDeterministicFold: two nodes holding the same observations
|
||||
// in different merge orders must rebuild identical derived state — content
|
||||
// fields come from the highest-HCL observation per fingerprint (spec 6.2),
|
||||
// never from an arbitrary (merge-order-dependent) row.
|
||||
func TestRebuildEntriesDeterministicFold(t *testing.T) {
|
||||
const foreign = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
rows := []GossipObservation{
|
||||
{NodeID: foreign, HCL: 10, Fingerprint: "fp:fold", SourceID: "test", Title: "old title", Summary: "old", CollectedAt: "2026-08-29T00:00:00Z", Confidence: 0.5},
|
||||
{NodeID: foreign, HCL: 20, Fingerprint: "fp:fold", SourceID: "test", Title: "new title", Summary: "new", CollectedAt: "2026-08-29T01:00:00Z", Confidence: 0.9},
|
||||
}
|
||||
results := make([]Entry, 2)
|
||||
for i, order := range [][]GossipObservation{{rows[0], rows[1]}, {rows[1], rows[0]}} {
|
||||
k := tmpDB(t)
|
||||
if _, err := k.PushObservations(order); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if _, _, err := k.RebuildEntriesFromObservations(); err != nil {
|
||||
t.Fatalf("rebuild: %v", err)
|
||||
}
|
||||
e, err := k.FindEntry("fp:fold")
|
||||
if err != nil || e == nil {
|
||||
t.Fatalf("find entry: %v", err)
|
||||
}
|
||||
results[i] = *e
|
||||
}
|
||||
if results[0].Title != "new title" {
|
||||
t.Errorf("order [old,new]: title = %q, want %q", results[0].Title, "new title")
|
||||
}
|
||||
if results[1].Title != "new title" {
|
||||
t.Errorf("order [new,old]: title = %q, want %q", results[1].Title, "new title")
|
||||
}
|
||||
if results[0] != results[1] {
|
||||
t.Errorf("derived state diverged between merge orders:\n%+v\n%+v", results[0], results[1])
|
||||
}
|
||||
if results[0].RefCount != 2 {
|
||||
t.Errorf("ref_count = %d, want 2", results[0].RefCount)
|
||||
}
|
||||
if results[0].FirstSeen != "2026-08-29T00:00:00Z" || results[0].LastSeen != "2026-08-29T01:00:00Z" {
|
||||
t.Errorf("first/last seen = %q/%q", results[0].FirstSeen, results[0].LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user