Skip to content

Bug: Unbounded SQLite knowledge graph nodes table causes progressive 184% CPU freeze (was: heartbeat thread leak) #6

Description

@Dvalin21

Issue #6 - ROOT CAUSE CORRECTION

Bug: AgentRuntime heartbeat threads leak from MCP server cache eviction causing thread explosion and 90% CPU spin

Repo: RyjoxTechnologies/Octopoda-OS


⚠️ CRITICAL UPDATE: The original root cause was INCORRECTLY DIAGNOSED

The original issue blamed heartbeat thread leaks for the 90% CPU spin. That was a symptom, not the root cause. After extensive debugging across two sessions (including a full system freeze), the actual root cause was found.


ACTUAL ROOT CAUSE: Unbounded SQLite knowledge graph nodes table

The nodes table in ~/.synrix/data/synrix.db grew to 4,145,862 rows / 3.86 GB (plus a 2.2 GB WAL file = 6 GB total).

Every single Octopoda operation — remember(), heartbeat writes, daemon event tracking, metrics — creates a new row in the nodes table via SynrixSQLiteClient.add_node(). The add_node() method NEVER overwrites in place. It always marks the old version as valid_until=now and inserts a new row with an incremented version number. Old versions accumulate forever.

Growth mechanism

Every write → add_node() → INSERT new row, UPDATE old row as invalid
                            ↓
                    Old rows NEVER deleted
                            ↓
                    nodes table: 4,145,862 rows

Production database snapshot

Key prefix Row count Description
runtime:agents:* 2,202,903 Heartbeats + state + timestamps — highest volume
runtime:events:* 1,782,490 Crash events, agent state transitions
agents:* 343 Actual user memories
Other 160,126 Metrics, audit, shared memory, alerts

Growth rate: ~12,000 nodes/hour (~3.3 writes/second)

Why the previous fix didn't work

The thread leak fix (cache eviction + shared heartbeat) stopped thread count growth. But the database was ALREADY 4 GB. The 5+ daemon background threads all call query_prefix("runtime:agents:*", 500) which scans the nodes table. With 4M rows, each query takes seconds. Threads pile up on SQLite _write_lock. CPU spirals. System freezes.

The real chain of failure

1. Heartbeat writes every 15s → new node version (never GC'd)
2. Daemon background threads (5+) query growing table:
   - heartbeat_monitor: every 3s → query_prefix scans 4M rows
   - anomaly_detector: every 5s → query_prefix scans 4M rows
   - metrics_aggregator: every 10s → query_prefix scans 4M rows
   - recovery_watchdog: every 5s → query_prefix scans 4M rows
   - cold_start_recovery: once → query_prefix scans 4M rows
3. Each query takes SECONDS with 4M rows
4. Threads pile up on SQLite _write_lock
5. CPU spirals to 184%+ → system freezes

NONE of this was a thread leak. Even with the shared heartbeat fix, the CPU freeze persisted because the 4 GB database was never addressed.


Architecture root causes

1. SynrixSQLiteClient.add_node() (sqlite_client.py:413)

  • Always creates a NEW version by hashing {collection}:{name}:v{version}
  • Sets old version's valid_until=now but never deletes it
  • Every heartbeat write to the SAME key creates a new row
  • No MAX_VERSIONS_PER_KEY limit

2. GarbageCollector.run_gc() (core/gc.py)

  • Only cleans: metrics:, runtime:events:, alerts:, audit:, snapshots
  • DOES NOT clean runtime:agents:* — the highest-volume prefix
  • Default interval: 6 hours
  • Even when it runs, it uses delete_prefix_before() which only hits the KV store, not the nodes table

3. The daemon starts 5+ background threads (core/daemon.py)

self._start_thread("heartbeat_monitor", self._heartbeat_monitor_loop)
self._start_thread("anomaly_detector", self._anomaly_detector_loop)
self._start_thread("metrics_aggregator", self._metrics_aggregator_loop)
self._start_thread("recovery_watchdog", self._recovery_watchdog_loop)
self._start_thread("garbage_collector", self._gc_loop)
  • ALL call query_prefix("runtime:agents:*", 500) or similar scans
  • Original intervals: 3s, 5s, 10s, 5s, 6h
  • None have backpressure or rate limiting

4. FTS5 index grows unboundedly with nodes

  • nodes_fts is a standalone FTS5 table (not external content)
  • _sync_fts only handles individual node updates, not bulk operations
  • When nodes are bulk-deleted from nodes, the FTS content table retains stale entries (4.2M entries for 133K current nodes)
  • REBUILD command doesn't delete stale content rows — only the segment index

Fixes applied locally

All in /home/keith/.local/bin/octopoda-http.py:

Database maintenance

  • Startup prune: background thread deletes runtime:agents:*, runtime:events:*, metrics:* nodes older than 1h
  • Periodic maintenance: same prune every 30 minutes
  • Non-blocking: runs in daemon thread, server starts immediately

Daemon loop intervals (monkey-patched at import time)

Loop Before After Reduction
heartbeat_monitor 3s 15s 5x
anomaly_detector 5s 30s 6x
metrics_aggregator 10s 60s 6x
recovery_watchdog 5s 30s 6x
heartbeat timeout 10s 120s 12x

Standalone cleanup script

/home/keith/.local/bin/octopoda-clean-db.py — for manual emergency use


Results after cleanup

Metric Before After
Total nodes 4,145,862 133,269
DB file size 3.86 GB + 2.2 GB WAL 79 MB
CPU (idle) 184%+ (spiraling) 7-8% (stable)
Threads spinning 4 threads at 54-88% CPU All sleeping on futex
RSS ~132 MB 113 MB
MCP responsiveness Timeout/dead Milliseconds

Upstream fixes required

1. add_node() must GC old versions

  • Add max_versions_per_key parameter (default: 5)
  • When exceeded, DELETE oldest versions instead of just marking valid_until

2. Add runtime:agents:* to GC scope

  • The GC cleans metrics:, runtime:events:, alerts:, audit:, snapshots
  • But NOT runtime:agents:* — the highest-volume prefix (2.2M rows)
  • Heartbeat keys older than 1 hour have ZERO value

3. Daemon background threads need backpressure

  • 3-5 second polling on SQLite is aggressive
  • Make intervals configurable via env vars
  • Add jitter to prevent thundering herd

4. FTS5 should use external content or have bulk-sync

  • nodes_fts is standalone → content table grows unboundedly
  • Option A: Use FTS5 content=nodes, content_rowid=id external content table
  • Option B: Add rebuild_fts_after_vacuum to GC

5. WAL management

  • WAL file grew to 2.2 GB alongside 3.9 GB main file
  • Need PRAGMA wal_auto_checkpoint=1000 and periodic PRAGMA wal_checkpoint(TRUNCATE)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions