Add periodic cyclic GC to the service loop to stop unbounded memory growth - #1265
Add periodic cyclic GC to the service loop to stop unbounded memory growth#1265SimonHeybrock wants to merge 2 commits into
Conversation
…rowth Per-chunk sciline/cyclebane task-graph updates leave ~5 self-referential networkx graphs behind per chunk per job, each transitively holding that chunk's detector arrays. CPython's generation-2 collector is triggered by object counts, not bytes, so in steady state it never runs and the purely reclaimable garbage grows without bound (~60 MB/cycle on TBL/Timepix3) until the service is OOM-killed. Make collection deterministic instead: gc.freeze() at loop start moves process-lifetime service state into the permanent generation, and a full gc.collect() runs at a fixed time interval (default 1 s, matching the batch cadence). With the freeze, the per-collection cost is ~0.5 ms instead of ~84 ms. Collection durations are logged periodically so cost drift can be observed. This is a mitigation for garbage that should not exist; the root cause is tracked in #1264 and upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUhFAixW77K2fPL1ySQ5L8
Duration and reclaimed count are logged together because the pair is what diagnoses: rising duration at a flat reclaimed count means the live set is growing, which no amount of collection can help. The existing text framed duration drift as a benign consequence of accumulating jobs, which invites exactly the wrong reading -- that drift was the signal that a second, unrelated leak was present.
|
Closing without merging. The two upstream fixes remove the garbage at its source, which makes this mitigation redundant for the leak it was written for: scipp/cyclebane#33 (networkx cached views no longer hold a back-reference, so the discarded graphs are refcount-collectable) and scipp/ess#732 ( It is also worth recording that this PR did not fix the production symptom. The staging deploy on 2026-08-31 was still OOM-killed 54 min after the Timepix3 job activated. Its own Beyond redundancy, keeping it has costs:
The one part that earned its place was the diagnostic, not the collection: logging collection duration alongside reclaimed count is what identified the second leak, since rising duration at a flat reclaimed count means the live set is growing. That signal does not need a 1 Hz #1264 stays open for the upstream work. |
Summary
Mitigation for #1264:
detector_data(and, by the same mechanism, every service driving aStreamProcessor) accumulates purely reclaimable cyclic garbage — ~5 self-referentialnetworkxgraphs per chunk per job, each transitively holding that chunk's detector arrays — and CPython's count-based generation-2 collector never runs in steady state, so the service grows ~60 MB/cycle until OOM-killed.This PR makes collection deterministic instead of count-triggered:
core/cyclic_gc.pywithPeriodicGarbageCollector: a fullgc.collect()at a fixed time interval (default 1 s, matching the batch cadence), bounding retained garbage to roughly one cycle's worth.gc.freeze()once at loop start, moving process-lifetime service state into the permanent generation so each collection only walks objects allocated since — measured ~0.5 ms per collection instead of ~84 ms (the difference matters against a Timepix3 view update that already costs ~840 ms of the ~1 s budget). The freeze happens before any job starts, so per-job state remains collectable and stopping a job does not leak.gc_collect_stats), so the predicted upward drift of collection cost as jobs accumulate is observed rather than assumed.Service;collect_garbage=Falseopts out, and a collector instance can be injected for testing.This is a symptom fix, as the issue itself insists: it makes the collector run often enough to reclaim garbage that should not be created. It must not close_ #1264 — the root cause (cyclebane's
Graph.__setitem__leaving self-referential intermediates, andess.reduce'sStreamProcessorusing the task graph as a per-chunk data container) is tracked there and upstream.Verification
sciline==25.11.1/cyclebane==24.10.0: onepipeline[key] = valueleaves exactly 5 refcount-immortalDiGraphs behind, held only by self-referential cached views (edges,in_edges,out_degree); onegc.collect()frees all of them. The issue's minimal reproduction reproduces: unbounded sawtooth growth without collection, flat RSS (188 MB) with per-cycle collection.tests/core/cyclic_gc_test.pycover: cyclic garbage reclaimed bymaybe_collect, interval gating (with injected clock), freeze exempting pre-existing objects, and end-to-end throughService._run_loopwith the automatic collector disabled — including the control showing the loop leaks cycles without the collector.dream/estiaworkflow roundtrip and reduction tests) are environmental — they need to download calibration files blocked by this sandbox's proxy and fail identically on unmodifiedmain.ruff checkandruff formatclean.Refs #1264
🤖 Generated with Claude Code
https://claude.ai/code/session_01EUhFAixW77K2fPL1ySQ5L8
Generated by Claude Code