Files
noema/tests/perf/perf_test.go
T
Andy Dixon d9ae91e406 Close the plan: screenshots and a measured performance pass
- docs/screenshots: nineteen pages captured with headless Chromium from a
  running instance; README gains a screenshot section
- tests/perf: opt-in load test (NOEMA_PERF=1) seeding 60k events, 6k
  memories and 1.5k beliefs across two minds, timing sixteen hot reads
  against a 400 ms ceiling and rejecting sequential-scan plans
- migration 0016: indexes the test found missing (memories by mind and
  recency, beliefs by mind and confidence, events by mind and category,
  belief versions by mind and time)
- docs/performance.md records the numbers and how to rerun
2026-07-06 23:18:31 +01:00

192 lines
7.4 KiB
Go

// Package perf seeds a mind with a realistic volume of state and times the
// hot read paths. It runs only with NOEMA_PERF=1 because seeding takes a
// while; CI runs it nightly, developers run it before touching queries.
//
// NOEMA_PERF=1 NOEMA_TEST_DATABASE_URL=... go test -run TestHotPaths -v ./tests/perf/
package perf
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/beliefs"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
const (
nEvents = 60_000
nMemories = 6_000
nBeliefs = 1_500
ceiling = 400 * time.Millisecond // any hot read slower than this is a regression
)
var vocab = strings.Fields("payments database migration latency deploy atlas server disk cpu alert go rust python andy project service host backup replica queue cache timeout retry release rollback metric dashboard incident postmortem")
func phrase(i int) string {
var w []string
for k := 0; k < 8; k++ {
w = append(w, vocab[(i*7+k*3)%len(vocab)])
}
return fmt.Sprintf("%s (item %d)", strings.Join(w, " "), i)
}
func TestHotPaths(t *testing.T) {
if os.Getenv("NOEMA_PERF") == "" {
t.Skip("set NOEMA_PERF=1 to run the performance pass")
}
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
mem := memory.NewStore(ctx, pool, nil)
bs := beliefs.NewStore(pool)
m, err := ms.Create(ctx, minds.CreateParams{Slug: "perf", DisplayName: "Perf"})
if err != nil {
t.Fatal(err)
}
other, _ := ms.Create(ctx, minds.CreateParams{Slug: "noise", DisplayName: "Noise"})
seed := time.Now()
batch := make([]events.New, 0, 500)
flush := func(mind ids.ID) {
if len(batch) == 0 {
return
}
if _, err := es.Append(ctx, batch...); err != nil {
t.Fatal(err)
}
batch = batch[:0]
}
types := []events.Type{events.Observation, events.ThoughtSelected, events.MemoryRetrieved, events.BeliefUpdated, events.WorkspaceBroadcast, events.CycleCompleted}
for i := 0; i < nEvents; i++ {
mind := m.ID
if i%5 == 0 {
mind = other.ID // a second mind so scoping matters
}
batch = append(batch, events.New{MindID: mind, Type: types[i%len(types)], Source: "perf", OccurredAt: seed.Add(-time.Duration(nEvents-i) * time.Second), Summary: phrase(i), Salience: float64(i%10) / 10, Confidence: 0.8})
if len(batch) == 500 {
flush(mind)
}
}
flush(m.ID)
for i := 0; i < nMemories; i++ {
kind := concepts.Episodic
if i%4 == 0 {
kind = concepts.Semantic
}
if _, err := mem.Create(ctx, pool, memory.New{MindID: m.ID, Kind: kind, Epistemic: concepts.Observation, Content: phrase(i), Source: "perf", Trust: events.TrustUser, Confidence: 0.7, Importance: float64(i%10) / 10, OccurredAt: seed.Add(-time.Duration(i) * time.Minute)}); err != nil {
t.Fatal(err)
}
}
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := es.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
return out[0], nil
}
for i := 0; i < nBeliefs; i++ {
if _, err := bs.Create(ctx, pool, emit, beliefs.New{MindID: m.ID, Proposition: phrase(i * 3), Subject: vocab[i%len(vocab)], Epistemic: concepts.Claim, Source: "perf",
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: ids.New().String(), Source: "user", Weight: 0.4, Credibility: 0.8}}}); err != nil {
t.Fatal(err)
}
}
t.Logf("seeded %d events, %d memories, %d beliefs in %s", nEvents, nMemories, nBeliefs, time.Since(seed).Round(time.Millisecond))
type probe struct {
name string
fn func() error
}
probes := []probe{
{"events recent 50", func() error { _, err := es.Query(ctx, evstore.Filter{MindID: m.ID, Limit: 50}); return err }},
{"events by type", func() error {
_, err := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.BeliefUpdated}, Limit: 50})
return err
}},
{"events after seq (cursor)", func() error {
_, err := es.Query(ctx, evstore.Filter{MindID: m.ID, AfterSeq: 1000, Ascending: true, Limit: 200})
return err
}},
{"events search", func() error {
_, err := es.Query(ctx, evstore.Filter{MindID: m.ID, Search: "rollback", Limit: 50})
return err
}},
{"events stats", func() error { _, err := es.Stats(ctx, m.ID, time.Time{}); return err }},
{"memory list 50", func() error { _, err := mem.List(ctx, m.ID, memory.Filter{Limit: 50}); return err }},
{"memory text search", func() error {
_, err := mem.List(ctx, m.ID, memory.Filter{Search: "payments latency", Limit: 50})
return err
}},
{"memory fused retrieve", func() error {
_, err := mem.Retrieve(ctx, m.ID, memory.Query{Text: "database migration latency alert", Limit: 10})
return err
}},
{"memory stats", func() error { _, err := mem.Stats(ctx, m.ID); return err }},
{"beliefs list", func() error { _, err := bs.List(ctx, m.ID, beliefs.Filter{Limit: 50}); return err }},
{"beliefs search", func() error { _, err := bs.Search(ctx, m.ID, "deploy rollback", 20); return err }},
{"beliefs contested", func() error { _, err := bs.Contested(ctx, m.ID, 20); return err }},
{"beliefs uncertain", func() error { _, err := bs.Uncertain(ctx, m.ID, 20); return err }},
{"beliefs stats", func() error { _, err := bs.Stats(ctx, m.ID); return err }},
{"beliefs recent changes", func() error { _, err := bs.RecentChanges(ctx, m.ID, seed.Add(-time.Hour), 50); return err }},
{"snapshot capture", func() error { _, err := snapshots.Capture(ctx, pool, es, m); return err }},
}
failed := false
for _, p := range probes {
p.fn() //nolint:errcheck // warm the cache once; we time the steady state
start := time.Now()
var err error
for i := 0; i < 3; i++ {
err = p.fn()
}
d := time.Since(start) / 3
mark := ""
if d > ceiling {
mark, failed = " <-- over ceiling", true
}
t.Logf("%-28s %8s%s", p.name, d.Round(100*time.Microsecond), mark)
if err != nil {
t.Errorf("%s: %v", p.name, err)
}
}
// Plans: the hot event and memory queries must be index-driven.
plans := map[string]string{
"events recent": `EXPLAIN SELECT id FROM cognitive_events WHERE mind_id=$1 ORDER BY seq DESC LIMIT 50`,
"events cursor": `EXPLAIN SELECT id FROM cognitive_events WHERE mind_id=$1 AND seq > 1000 ORDER BY seq ASC LIMIT 200`,
"memory list": `EXPLAIN SELECT id FROM memories WHERE mind_id=$1 AND status='active' ORDER BY occurred_at DESC, id DESC LIMIT 50`,
"beliefs list": `EXPLAIN SELECT id FROM beliefs WHERE mind_id=$1 AND status NOT IN ('superseded') ORDER BY confidence DESC LIMIT 50`,
}
for name, q := range plans {
rows, err := pool.Query(ctx, q, m.ID)
if err != nil {
t.Fatal(err)
}
var plan []string
for rows.Next() {
var line string
rows.Scan(&line) //nolint:errcheck
plan = append(plan, strings.TrimSpace(line))
}
rows.Close()
joined := strings.Join(plan, " | ")
if strings.Contains(joined, "Seq Scan on cognitive_events") || strings.Contains(joined, "Seq Scan on memories") || strings.Contains(joined, "Seq Scan on beliefs") {
t.Errorf("%s uses a sequential scan: %s", name, joined)
}
t.Logf("plan %-14s %s", name, plan[0])
}
if failed {
t.Fatalf("a hot read exceeded %s", ceiling)
}
}