Skip to content

Commit 90b42e4

Browse files
aksOpsclaude
andauthored
fix(graphrag): bound SignalStore.LogClusters (TTL + per-tenant cap) (#120)
LogClusters was the one unbounded structure on the in-memory hot path: an insert-only map keyed by service×Drain-template-ID, explicitly skipped by Prune. Template IDs shift as Drain generalizes and the key carries the service, so the map grew with every distinct (service, template-id) pair ever seen — unbounded by the shared 50k Drain LRU and by log volume. Under a sustained high-shape log stream (exactly the case when INFO is kept in-memory but not persisted) this leaks RAM. Extend SignalStore.Prune to bound LogClusters the same way it bounds Metrics: - TTL: evict clusters with LastSeen older than the 24h signalRetention cutoff (ages out the template-ID accretion as orphaned clusters go stale). - Cap: maxLogClustersPerTenant=10000 backstop, oldest-LastSeen first. - Evicted clusters' EMITTED_BY / LOGGED_DURING edges are swept (collected by cluster id; removed even when not yet time-stale, e.g. cap-evicted). Prune signature gains maxLogClusters; refresh.go passes the new constant. Tests: rewrote _SweepsStaleLogClusterEdgesOnly (which asserted the old never-pruned behavior) into _DropsStaleLogClustersKeepsFresh; added _LogClusterCapEvictsOldest. Verified: go build/vet, golangci-lint exit 0, go test -race ./internal/graphrag (109), full suite 679, gofmt clean. Closes the last unbounded structure on the in-memory path. CPU stays bounded by the fixed 16-worker pool + drop-when-full event queue; RAM by GOMEMLIMIT (75% budget) + per-tenant caps/TTLs on TraceStore (500k+TTL), SignalStore metrics (2k) + now log clusters (10k), topology LRU (100k), TSDB cardinality, and 24h idle-tenant eviction. Claude-Session: https://claude.ai/code/session_01LDQVJrixs2nJoea67a8pEG Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b45ddbe commit 90b42e4

4 files changed

Lines changed: 105 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ The `internal/graphrag/` package is the core intelligence layer. It replaces the
7070
|-------|-------|-------|-----|
7171
| `ServiceStore` | ServiceNode, OperationNode | CALLS, EXPOSES | Permanent |
7272
| `TraceStore` | TraceNode, SpanNode | CONTAINS, CHILD_OF | Configurable (default 1h) |
73-
| `SignalStore` | LogClusterNode, MetricNode | EMITTED_BY, MEASURED_BY, LOGGED_DURING | Permanent |
73+
| `SignalStore` | LogClusterNode, MetricNode | EMITTED_BY, MEASURED_BY, LOGGED_DURING | 24h TTL + per-tenant caps |
7474
| `AnomalyStore` | AnomalyNode | PRECEDED_BY, TRIGGERED_BY | 24h |
7575

7676
### Node Types (7)
@@ -219,7 +219,7 @@ Key settings in `internal/config/config.go`:
219219
- `LOG_FTS_ENABLED` — when truthy (`true`/`yes`/`on`/`1`), provisions the SQLite FTS5 `logs_fts` virtual table + sync triggers at startup; when false, log-search uses a 24h-clamped LIKE fallback. **Defaults to `true` when `DB_DRIVER=sqlite`** (BM25 is dramatically faster than LIKE on the kept `search_logs` MCP tool) and `false` otherwise. Toggle off and reclaim the ~30% disk overhead via `POST /api/admin/drop_fts` (refused while the flag is on). The vectordb-backed semantic-search path was removed on 2026-05-24.
220220
- `DLQ_MAX_FILES` (1000), `DLQ_MAX_DISK_MB` (500), `DLQ_MAX_RETRIES` (10)
221221
- `GRAPHRAG_WORKER_COUNT` (16), `GRAPHRAG_EVENT_QUEUE_SIZE` (100000; **10000 on SQLite**) — sized for 100–200 services; raise further if `otelcontext_graphrag_events_dropped_total` climbs
222-
- `GRAPHRAG_TRACE_TTL` (`1h`; **`30m` on SQLite**), `GRAPHRAG_MAX_SPANS_PER_TENANT` (500000), `GRAPHRAG_TENANT_IDLE_TTL` (`24h`) — in-memory GraphRAG memory bounds. Spans past the per-tenant cap are skipped from the graph only (DB unaffected; metered as `otelcontext_graphrag_events_dropped_total{signal="span_capacity"}`); tenant store slices idle past the TTL are evicted (default tenant immune, self-healing via the 60s rebuild). SignalStore metrics are bounded to 2000/tenant + 24h TTL (constants).
222+
- `GRAPHRAG_TRACE_TTL` (`1h`; **`30m` on SQLite**), `GRAPHRAG_MAX_SPANS_PER_TENANT` (500000), `GRAPHRAG_TENANT_IDLE_TTL` (`24h`) — in-memory GraphRAG memory bounds. Spans past the per-tenant cap are skipped from the graph only (DB unaffected; metered as `otelcontext_graphrag_events_dropped_total{signal="span_capacity"}`); tenant store slices idle past the TTL are evicted (default tenant immune, self-healing via the 60s rebuild). SignalStore is bounded per-tenant by a 24h TTL + a cap: metrics 2000/tenant, log clusters 10000/tenant (constants; the log-cluster cap was added because clusters key on service×Drain-template-ID and would otherwise grow unbounded as template IDs churn).
223223
- `PPROF_ADDR` (`127.0.0.1:6060`) — `net/http/pprof` on a dedicated loopback listener (never the public mux); empty disables. Startup also sets a soft `GOMEMLIMIT` (honors the env var, else 75% of the cgroup/host budget via `internal/membudget`).
224224
- `INGEST_MIN_SEVERITY` (`INFO`), `STORE_MIN_SEVERITY` (**defaults to `"WARN"` for all drivers**; `""` falls back to same-as-ingest) — two-tier log severity gate. The ingest gate runs at the OTLP receiver and **drops the log entirely** below the threshold (no in-memory enrichment either). The store gate runs at the persist boundary inside the async pipeline (`internal/ingest/pipeline.go:process`) and **only skips the DB row write** — the log still flows through `LogCallback` so GraphRAG Drain template mining and span/trace correlation see it. By default (`STORE_MIN_SEVERITY=WARN`, `INGEST_MIN_SEVERITY=INFO`) INFO logs reach GraphRAG/Drain in-memory but are **not** persisted — the DB only grows with WARN+. To also analyse DEBUG in-memory (not just INFO), set `INGEST_MIN_SEVERITY=DEBUG` (raises in-memory event volume). Setting `STORE_MIN_SEVERITY``INGEST_MIN_SEVERITY` is a no-op (logged as a warning at startup). Drops surface via `Pipeline.Stats().StoreFiltered`.
225225
- `INGEST_ASYNC_ENABLED` (true), `INGEST_PIPELINE_QUEUE_SIZE` (50000), `INGEST_PIPELINE_WORKERS` (8), `INGEST_PIPELINE_MAX_BYTES` (536870912 = 512 MB; **128 MB on SQLite**) — async ingest pipeline (`internal/ingest/pipeline.go`). Hybrid backpressure: <90% accept all, 90–100% drop healthy batches (errors/slow always pass), 100% return gRPC `RESOURCE_EXHAUSTED`. The byte cap bounds queue memory regardless of item count — at the cap even priority batches get `RESOURCE_EXHAUSTED`/429 (a 429 is recoverable, an OOM kill is not); watch `otelcontext_ingest_pipeline_queue_bytes` and reason `bytes_full`. Set `INGEST_ASYNC_ENABLED=false` to revert to synchronous DB writes inside `Export()`. Drops surface as `otelcontext_ingest_pipeline_dropped_total{signal,reason}`.

internal/graphrag/refresh.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ const (
1515
// maxMetricsPerTenant caps each tenant's SignalStore metric map; past
1616
// the cap the oldest-LastSeen series are evicted first.
1717
maxMetricsPerTenant = 2000
18+
// maxLogClustersPerTenant caps each tenant's SignalStore LogCluster map.
19+
// Clusters are keyed by service×Drain-template-ID, so without a cap the
20+
// map outgrows the (shared) Drain template LRU as template IDs churn and
21+
// services multiply. The 24h signalRetention TTL is the primary bound;
22+
// this cap is the backstop for high-shape-cardinality streams (~1KB/node
23+
// => ~10MB/tenant worst case).
24+
maxLogClustersPerTenant = 10000
1825
// rebuildRowLimit caps how many span rows a single per-tenant rebuild
1926
// pass loads. Hitting it is logged — topology lags until the next tick.
2027
rebuildRowLimit = 50000
@@ -45,17 +52,17 @@ func (g *GraphRAG) refreshLoop(ctx context.Context) {
4552
case <-ticker.C:
4653
g.rebuildAllTenantsFromDB(ctx)
4754
pruned := 0
48-
prunedMetrics := 0
55+
prunedSignals := 0
4956
signalCutoff := time.Now().Add(-signalRetention)
5057
for _, stores := range g.snapshotTenants() {
5158
pruned += stores.traces.Prune()
52-
prunedMetrics += stores.signals.Prune(signalCutoff, maxMetricsPerTenant)
59+
prunedSignals += stores.signals.Prune(signalCutoff, maxMetricsPerTenant, maxLogClustersPerTenant)
5360
}
5461
if pruned > 0 {
5562
slog.Debug("GraphRAG pruned expired traces/spans", "count", pruned)
5663
}
57-
if prunedMetrics > 0 {
58-
slog.Debug("GraphRAG pruned stale/over-cap metrics", "count", prunedMetrics)
64+
if prunedSignals > 0 {
65+
slog.Debug("GraphRAG pruned stale/over-cap signals (metrics+clusters)", "count", prunedSignals)
5966
}
6067
g.pruneOldAnomalies()
6168
if evicted := g.evictIdleTenants(); evicted > 0 {

internal/graphrag/signal_prune_test.go

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestSignalStore_Prune_DropsStaleMetricsKeepsFresh(t *testing.T) {
1717
ss.UpsertMetric("cpu", "old-svc", 1.0, stale)
1818
ss.UpsertMetric("cpu", "new-svc", 1.0, now)
1919

20-
if got := ss.Prune(now.Add(-24*time.Hour), 0); got != 1 {
20+
if got := ss.Prune(now.Add(-24*time.Hour), 0, 0); got != 1 {
2121
t.Fatalf("Prune removed %d metrics, want 1", got)
2222
}
2323
if _, ok := ss.Metrics["cpu|old-svc"]; ok {
@@ -46,7 +46,7 @@ func TestSignalStore_Prune_CapEvictsOldestFirst(t *testing.T) {
4646
ss.UpsertMetric(fmt.Sprintf("m%d", i), "svc", 1.0, now.Add(time.Duration(i)*time.Minute))
4747
}
4848

49-
if got := ss.Prune(now.Add(-24*time.Hour), 2); got != 3 {
49+
if got := ss.Prune(now.Add(-24*time.Hour), 2, 0); got != 3 {
5050
t.Fatalf("Prune removed %d metrics, want 3", got)
5151
}
5252
for i := 0; i < 3; i++ {
@@ -65,10 +65,12 @@ func TestSignalStore_Prune_CapEvictsOldestFirst(t *testing.T) {
6565
}
6666
}
6767

68-
// TestSignalStore_Prune_SweepsStaleLogClusterEdgesOnly proves LogClusters
69-
// themselves are untouched (they are Drain-bounded upstream) while their
70-
// stale EMITTED_BY / LOGGED_DURING edges are swept.
71-
func TestSignalStore_Prune_SweepsStaleLogClusterEdgesOnly(t *testing.T) {
68+
// TestSignalStore_Prune_DropsStaleLogClustersKeepsFresh proves the TTL bound on
69+
// LogClusters: a cluster idle past the cutoff is removed together with its
70+
// EMITTED_BY / LOGGED_DURING edges, while a fresh cluster and its edge survive.
71+
// LogClusters were previously never pruned (only their stale edges were swept),
72+
// which leaked the map under a sustained high-shape log stream.
73+
func TestSignalStore_Prune_DropsStaleLogClustersKeepsFresh(t *testing.T) {
7274
ss := newSignalStore()
7375
now := time.Now()
7476
stale := now.Add(-48 * time.Hour)
@@ -77,19 +79,53 @@ func TestSignalStore_Prune_SweepsStaleLogClusterEdgesOnly(t *testing.T) {
7779
ss.AddLoggedDuringEdge("c-old", "span-old", stale)
7880
ss.UpsertLogCluster("c-new", "tmpl", "ERROR", "svc2", now)
7981

80-
ss.Prune(now.Add(-24*time.Hour), 0)
82+
ss.Prune(now.Add(-24*time.Hour), 0, 0)
8183

82-
if len(ss.LogClusters) != 2 {
83-
t.Fatalf("LogClusters len = %d, want 2 — Prune must not touch clusters", len(ss.LogClusters))
84+
if _, ok := ss.LogClusters["c-old"]; ok {
85+
t.Errorf("stale LogCluster survived Prune")
86+
}
87+
if _, ok := ss.LogClusters["c-new"]; !ok {
88+
t.Errorf("fresh LogCluster was pruned")
8489
}
8590
if _, ok := ss.Edges[edgeKey(EdgeEmittedBy, "c-old", "svc")]; ok {
86-
t.Errorf("stale EMITTED_BY edge survived")
91+
t.Errorf("evicted cluster's EMITTED_BY edge survived")
8792
}
8893
if _, ok := ss.Edges[edgeKey(EdgeLoggedDuring, "c-old", "span-old")]; ok {
89-
t.Errorf("stale LOGGED_DURING edge survived")
94+
t.Errorf("evicted cluster's LOGGED_DURING edge survived")
9095
}
9196
if _, ok := ss.Edges[edgeKey(EdgeEmittedBy, "c-new", "svc2")]; !ok {
92-
t.Errorf("fresh EMITTED_BY edge was swept")
97+
t.Errorf("fresh cluster's EMITTED_BY edge was swept")
98+
}
99+
}
100+
101+
// TestSignalStore_Prune_LogClusterCapEvictsOldest proves the cap backstop: when
102+
// the cluster map still exceeds maxLogClusters after the TTL pass, the
103+
// oldest-LastSeen clusters are evicted first — with their edges — even though
104+
// all of them are fresh.
105+
func TestSignalStore_Prune_LogClusterCapEvictsOldest(t *testing.T) {
106+
ss := newSignalStore()
107+
now := time.Now()
108+
109+
for i := 0; i < 5; i++ {
110+
id := fmt.Sprintf("c%d", i)
111+
ss.UpsertLogCluster(id, "tmpl", "ERROR", "svc", now.Add(time.Duration(i)*time.Minute))
112+
}
113+
114+
ss.Prune(now.Add(-24*time.Hour), 0, 2)
115+
116+
for i := 0; i < 3; i++ {
117+
id := fmt.Sprintf("c%d", i)
118+
if _, ok := ss.LogClusters[id]; ok {
119+
t.Errorf("oldest cluster %s survived cap eviction", id)
120+
}
121+
if _, ok := ss.Edges[edgeKey(EdgeEmittedBy, id, "svc")]; ok {
122+
t.Errorf("cap-evicted cluster %s left a dangling EMITTED_BY edge", id)
123+
}
124+
}
125+
for i := 3; i < 5; i++ {
126+
if _, ok := ss.LogClusters[fmt.Sprintf("c%d", i)]; !ok {
127+
t.Errorf("newest cluster c%d was evicted — cap must drop oldest first", i)
128+
}
93129
}
94130
}
95131

@@ -110,7 +146,7 @@ func TestSignalStore_UpsertRefreshesEdgeTimestamps(t *testing.T) {
110146
ss.AddLoggedDuringEdge("c1", "span-1", stale)
111147
ss.AddLoggedDuringEdge("c1", "span-1", now)
112148

113-
ss.Prune(now.Add(-24*time.Hour), 0)
149+
ss.Prune(now.Add(-24*time.Hour), 0, 0)
114150

115151
for _, ek := range []string{
116152
edgeKey(EdgeMeasuredBy, "cpu|svc", "svc"),

internal/graphrag/store.go

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -603,15 +603,16 @@ func (ss *SignalStore) LogClustersForService(service string) []*LogClusterNode {
603603
return out
604604
}
605605

606-
// Prune bounds the SignalStore (modeled on TraceStore.Prune): MetricNodes
607-
// whose LastSeen predates cutoff are removed; if the map still exceeds
608-
// maxMetrics (<=0 = uncapped) the oldest-LastSeen overflow is evicted. Each
609-
// removed metric takes its MEASURED_BY edge with it. Finally, Edges whose
610-
// UpdatedAt predates cutoff are swept — the upsert paths refresh edge
611-
// timestamps, so live correlations survive. LogClusters are NOT pruned
612-
// here: they are bounded upstream by the Drain template LRU; only their
613-
// stale edges go. Returns the number of metrics removed.
614-
func (ss *SignalStore) Prune(cutoff time.Time, maxMetrics int) int {
606+
// Prune bounds the SignalStore (modeled on TraceStore.Prune): MetricNodes and
607+
// LogClusterNodes whose LastSeen predates cutoff are removed; if either map
608+
// still exceeds its cap (maxMetrics / maxLogClusters; <=0 = uncapped) the
609+
// oldest-LastSeen overflow is evicted. Each removed node takes its edges with
610+
// it — metric MEASURED_BY edges inline, evicted-cluster EMITTED_BY /
611+
// LOGGED_DURING edges in the final sweep — and any edge whose UpdatedAt
612+
// predates cutoff is swept too (upsert paths refresh edge timestamps, so live
613+
// correlations survive). Returns the number of nodes (metrics + clusters)
614+
// removed.
615+
func (ss *SignalStore) Prune(cutoff time.Time, maxMetrics, maxLogClusters int) int {
615616
ss.mu.Lock()
616617
defer ss.mu.Unlock()
617618

@@ -640,8 +641,40 @@ func (ss *SignalStore) Prune(cutoff time.Time, maxMetrics int) int {
640641
pruned++
641642
}
642643
}
644+
// LogClusters: TTL + cap, mirroring the metric bound above. This map was
645+
// previously unbounded — keyed by service×Drain-template-ID with no cap, it
646+
// grew with every distinct (service, template-id) pair ever seen (template
647+
// IDs shift as Drain generalizes), leaking under a sustained high-shape log
648+
// stream. The TTL ages out that accretion; the cap is the backstop. Evicted
649+
// cluster IDs are collected so their EMITTED_BY / LOGGED_DURING edges
650+
// (FromID = cluster id) are swept below even when not yet time-stale.
651+
evictedClusters := make(map[string]struct{})
652+
for id, lc := range ss.LogClusters {
653+
if lc.LastSeen.Before(cutoff) {
654+
delete(ss.LogClusters, id)
655+
evictedClusters[id] = struct{}{}
656+
pruned++
657+
}
658+
}
659+
if maxLogClusters > 0 && len(ss.LogClusters) > maxLogClusters {
660+
type clusterAge struct {
661+
id string
662+
seen time.Time
663+
}
664+
byAge := make([]clusterAge, 0, len(ss.LogClusters))
665+
for id, lc := range ss.LogClusters {
666+
byAge = append(byAge, clusterAge{id, lc.LastSeen})
667+
}
668+
sort.Slice(byAge, func(i, j int) bool { return byAge[i].seen.Before(byAge[j].seen) })
669+
for _, e := range byAge[:len(byAge)-maxLogClusters] {
670+
delete(ss.LogClusters, e.id)
671+
evictedClusters[e.id] = struct{}{}
672+
pruned++
673+
}
674+
}
675+
643676
for ek, e := range ss.Edges {
644-
if e.UpdatedAt.Before(cutoff) {
677+
if _, fromEvicted := evictedClusters[e.FromID]; e.UpdatedAt.Before(cutoff) || fromEvicted {
645678
delete(ss.Edges, ek)
646679
}
647680
}

0 commit comments

Comments
 (0)