From f383a62c4ff8a6c81d311d5989a2a4184a983a15 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 27 Jun 2026 14:17:58 -0700 Subject: [PATCH] perf: periodic full GC to bound RSS (plexapi cyclic Element trees) (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measured the 'resource hungry' / lockup issue: browsing every page grows RSS ~300MB -> 1.8GB and it stays. it's not a leak — it's deferred cyclic collection. plexapi parses Plex responses into XML Element trees whose nodes reference each other in cycles; Python's generational GC leaves them in gen2 and sweeps it rarely, so ~227k Element objects pile up. forcing gc.collect() reclaimed ~700MB instantly (1.8GB -> 1.1GB live), confirming. add a daemon that runs a full gc.collect() every 60s so the cyclic garbage is reclaimed on a cadence instead of climbing into lock-up. full collect is ~tens of ms; once a minute is negligible. this is the root of the reporter's 2GB + ramonskie's spike too. --- web_server.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web_server.py b/web_server.py index fb1ae9f1..f22335e1 100644 --- a/web_server.py +++ b/web_server.py @@ -38549,6 +38549,26 @@ def start_runtime_services(): # Initialize app start time for uptime tracking app.start_time = time.time() + # Periodic full garbage collection (#802 / resource usage). plexapi builds large XML + # Element trees whose nodes reference each other in cycles; Python's generational GC + # parks those in gen2 and only sweeps it rarely, so they accumulate and RSS climbs + # (measured: ~300MB fresh -> 1.8GB after browsing every page, ~700MB of it reclaimable + # cyclic garbage that a full gc.collect() frees instantly). A scheduled full collect + # reclaims them on a cadence so RSS stays bounded instead of climbing into lock-up. + # A full collect on this heap is ~tens of ms; once a minute is negligible CPU. + def _periodic_gc(interval=60): + import gc + while True: + time.sleep(interval) + try: + n = gc.collect() + if n: + logger.debug("periodic gc.collect() reclaimed %d cyclic objects", n) + except Exception: # noqa: S110 — best-effort housekeeping, never crash the app + pass + threading.Thread(target=_periodic_gc, daemon=True, name='periodic-gc').start() + logger.info("Periodic GC sweeper started (full collect every 60s)") + # Register action handlers and start automation engine _register_automation_handlers() if automation_engine: