Applied fixes (this commit):
get_connection() now a contextmanager that closes on exit (storage.py)services_orders.py)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:
Location: private_ws_main and _run_once in bitstamp_private_ws.py.
_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):
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._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.
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:
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.
crypto_client.pyProblem: _price_for_asset calls get_crypto_price which calls get_price_sync, which creates a ThreadPoolExecutor + asyncio.run() + MCP SSE connection per asset, per call. A balance with 10 assets triggers 10 separate SSE connections. Now that this runs in the background (Fix 2), it won't block the WS loop, but it's still very wasteful.
Fix: Add a short-TTL in-memory price cache (e.g., 5 seconds) in crypto_client.py:
_PRICE_CACHE: dict[str, tuple[float, float]] = {} # symbol -> (price, expires_at)
_PRICE_CACHE_TTL = 5.0
def get_price_sync(symbol: str, base_url: str | None = None) -> dict[str, Any]:
now = time.monotonic()
cached = _PRICE_CACHE.get(symbol)
if cached and cached[1] > now:
return {"symbol": symbol.upper(), "price": cached[0]}
# ... existing fetch logic ...
if "price" in result:
_PRICE_CACHE[symbol] = (float(result["price"]), now + _PRICE_CACHE_TTL)
return result
This caps price lookups to at most 1 per 5 seconds per symbol, regardless of how many balance snapshots fire. Freshness is preserved (5s TTL is short enough for downstream services that poll balance info).
Location: crypto_client.py, _price_for_asset in services_bitstamp.py.
data/exec_mcp.sqlite3) — if growing, Fix 5 is needed.docker stats for the container after 24-48 hours of running to confirm memory is flat.