| date | 2026-06-13 |
|---|---|
| tags | pandas memory-leak long-running-process gc.collect dataframe |
| one_liner | A pandas DataFrame held alive across a 24h cache rebuild cycle pushed RSS from ~62 MB to 1.2 GB on a constrained VPS. |
Severity: HIGH System layer: agent loop — predictive score module
The agent process RSS grew steadily over time, eventually reaching a peak of 1 222 MB on a VPS with 3.7 GB total RAM. The growth was not constant — it followed a sawtooth pattern tied to a 24h cache rebuild cycle. Memory climbed during the rebuild, then did not fully return to baseline. After several days, the process was visibly consuming a disproportionate share of available memory.
No OOM kill occurred, but the risk was real. No alert fired automatically; the issue was found during a manual memory audit.
The agent maintains a predictive score module that caches historical probability data. The cache has a 24h TTL. When it expires, the module fetches several thousand OHLCV candles, builds a probability matrix from the raw DataFrame, then writes the result to a JSON cache file.
The problem: the DataFrame was passed into build_probability_base(), the scalar results were extracted — but the original df variable was never explicitly released. Python's reference counting kept the object alive for the lifetime of the function scope, and CPython's cyclic GC did not reclaim it promptly in a long-running process context.
A secondary source: the exchange client was re-instantiated on each market data call (~58 times per day), each instantiation carrying a memory footprint of 3–5 MB that was slow to release.
After build_probability_base(df) returned, df remained reachable until the enclosing scope exited naturally — which in a background thread could be delayed. No explicit del df was called, and gc.collect() was not invoked to force a collection cycle.
# Before — df kept alive until scope exit, GC not forced
df = fetch_historical_data()
base = build_probability_base(df)
save_cache(base)
return baseThe same pattern existed in both the synchronous (load_or_build_base) and asynchronous (_async_rebuild) rebuild paths.
Explicit del df immediately after extraction, followed by gc.collect(), in both rebuild paths.
# After
df = fetch_historical_data()
base = build_probability_base(df)
del df
gc.collect()
save_cache(base)
return baseAdditionally, the exchange client was moved to a module-level singleton to eliminate per-call instantiation overhead.
- Measure before and after. VmPeak dropped from 1 222 MB to 284 MB (−77%). RSS at rest dropped from ~550 MB to ~62 MB. These numbers were only obtained by running
ps//proc/<pid>/statusmanually — there was no automated memory metric in place. - Know your rebuild schedule. Any operation that allocates hundreds of MB on a timer (here: ~200–400 MB every 24h) must explicitly release that allocation. Don't rely on scope exit in a long-running process.
- The async path is the dangerous one. The synchronous path exits quickly; the daemon thread path (
_async_rebuild) can hold references longer if the GC is not nudged. Audit both. - Don't remove the
del+gc.collect()pair without measuring the memory impact first — the saving is not theoretical. - A secondary check: if the cache TTL expires while the circuit breaker is active (suppressing LLM calls entirely),
get_score_predictif()is never called, the TTL check never runs, and the cache stays stale indefinitely. The refresh call must happen before the circuit breaker, not inside the LLM path.