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)
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
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
nodestable in~/.synrix/data/synrix.dbgrew 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 thenodestable viaSynrixSQLiteClient.add_node(). Theadd_node()method NEVER overwrites in place. It always marks the old version asvalid_until=nowand inserts a new row with an incremented version number. Old versions accumulate forever.Growth mechanism
Production database snapshot
runtime:agents:*runtime:events:*agents:*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
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){collection}:{name}:v{version}valid_until=nowbut never deletes itMAX_VERSIONS_PER_KEYlimit2.
GarbageCollector.run_gc()(core/gc.py)metrics:,runtime:events:,alerts:,audit:, snapshotsruntime:agents:*— the highest-volume prefixdelete_prefix_before()which only hits the KV store, not thenodestable3. The daemon starts 5+ background threads (core/daemon.py)
query_prefix("runtime:agents:*", 500)or similar scans4. FTS5 index grows unboundedly with nodes
nodes_ftsis a standalone FTS5 table (not external content)_sync_ftsonly handles individual node updates, not bulk operationsnodes, the FTS content table retains stale entries (4.2M entries for 133K current nodes)REBUILDcommand doesn't delete stale content rows — only the segment indexFixes applied locally
All in
/home/keith/.local/bin/octopoda-http.py:Database maintenance
runtime:agents:*,runtime:events:*,metrics:*nodes older than 1hDaemon loop intervals (monkey-patched at import time)
Standalone cleanup script
/home/keith/.local/bin/octopoda-clean-db.py— for manual emergency useResults after cleanup
Upstream fixes required
1.
add_node()must GC old versionsmax_versions_per_keyparameter (default: 5)valid_until2. Add
runtime:agents:*to GC scopemetrics:,runtime:events:,alerts:,audit:, snapshotsruntime:agents:*— the highest-volume prefix (2.2M rows)3. Daemon background threads need backpressure
4. FTS5 should use external content or have bulk-sync
nodes_ftsis standalone → content table grows unboundedlycontent=nodes, content_rowid=idexternal content tablerebuild_fts_after_vacuumto GC5. WAL management
PRAGMA wal_auto_checkpoint=1000and periodicPRAGMA wal_checkpoint(TRUNCATE)