# Memory Leak Remediation Plan — Remaining Fixes Applied fixes (this commit): - **Fix 1**: SQLite connection leak — `get_connection()` now a contextmanager that closes on exit (`storage.py`) - **Fix 2**: WS handler no longer blocks on balance snapshot — deferred to single-worker ThreadPoolExecutor (`services_orders.py`) --- ## Fix 3 — WS reconnect backoff (`bitstamp_private_ws.py`) **Problem**: `private_ws_main` retries with a fixed 5-second delay after any exception. If Bitstamp drops the connection repeatedly (rate limit, network blip), this creates a tight reconnect loop. Each reconnect calls `list_accounts` (opens a DB connection) and `_get_token` (HTTP call to Bitstamp), amplifying load. **Fix**: Replace the fixed `WS_RECONNECT_SECONDS` sleep with exponential backoff: - Start at 5s, double on each consecutive failure, cap at 60s. - Reset to 5s after a successful connection that lasts > 30 seconds. - Log the backoff delay so tight loops are visible in logs. **Location**: `private_ws_main` and `_run_once` in `bitstamp_private_ws.py`. --- ## Fix 4 — `_CACHE_LOCKS` unbounded growth (`services_bitstamp.py`) **Problem**: `_CACHE_LOCKS` dict grows every time a new `cache_key` is used (per account, per market symbol, etc.). Locks are never removed, even when the account is deleted or the cache entry expires. **Fix options** (pick one): - **A**: Use `weakref.WeakValueDictionary` — locks get GC'd when no caller holds a reference. Simplest, but the `with _cache_lock(cache_key):` pattern keeps a strong reference during use, so the lock survives the call and is collected after. - **B**: Add a `_remove_cache_lock(cache_key)` call in `invalidate_account_cache` and account deletion paths. More explicit but requires wiring up cleanup at every deletion site. **Location**: `_CACHE_LOCKS`, `_cache_lock` in `services_bitstamp.py:24-46`. --- ## Fix 5 — `api_cache` table eviction (`repo.py` / `server.py`) **Problem**: `cache_put` does an upsert, so existing keys don't duplicate. But expired rows are never deleted — `cache_get` skips them, but they stay in the SQLite file. Over weeks, the table grows and the file bloats. Same applies to `order_records` and `account_balance_snapshots` (no retention policy). **Fix**: Add a `_evict_expired_cache()` function in `repo.py`: ```python def evict_expired_cache() -> int: now = utc_now_iso() with get_connection() as conn: count = conn.execute("DELETE FROM api_cache WHERE expires_at <= ?", (now,)).rowcount conn.commit() return count ``` Call it from the existing metadata refresh loop (`_metadata_refresh_loop` in `server.py`) every cycle — it already runs every 24 hours, which is sufficient for cache eviction. For `order_records` and `account_balance_snapshots`, consider a configurable retention (e.g., keep last 90 days, or last 10,000 rows per account). This is a bigger change and should be a separate discussion. **Location**: `repo.py`, `_metadata_refresh_loop` in `server.py`. --- ## Note — Price lookups must remain uncached Price lookups via `crypto_client.py` must always return fresh data. Caching is handled upstream. Do not add a local TTL cache here. --- ## Deployment & Monitoring 1. After deploying Fixes 1+2, monitor container RSS — should stabilize at 50-150 MB instead of climbing to GBs. 2. Check logs for "private websocket supervisor failed" frequency — if reconnects are frequent, Fix 3 is needed urgently. 3. Check SQLite file size (`data/exec_mcp.sqlite3`) — if growing, Fix 5 is needed. 4. Check `docker stats` for the container after 24-48 hours of running to confirm memory is flat.