From b4583f23be04d78900c7f418009f2fbe9e8007f9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 27 Jun 2026 14:24:05 -0700 Subject: [PATCH] =?UTF-8?q?perf:=20growth-triggered=20GC=20instead=20of=20?= =?UTF-8?q?fixed=20timer=20=E2=80=94=20caps=20the=20peak=20(#802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the 60s timer overshot: browsing piled plexapi cyclic garbage faster than once-a-minute caught it, so RSS hit ~2.2GB before a sweep (then dropped to 1.2GB). switch to polling RSS cheaply (every 8s) and collecting as soon as it grows +250MB since the last sweep — so it fires DURING a heavy browse and caps the peak near floor+250MB instead of running to 2GB+. keeps a 120s backstop for slow idle accumulation. --- web_server.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/web_server.py b/web_server.py index f22335e1..8904554f 100644 --- a/web_server.py +++ b/web_server.py @@ -38549,25 +38549,39 @@ 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 + # Growth-triggered 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): + # parks those in gen2 and sweeps it rarely, so they accumulate and RSS climbs (measured: + # ~300MB fresh -> 1.8-2.2GB after browsing every page, most of it reclaimable cyclic + # garbage a full gc.collect() frees instantly). A FIXED timer overshoots — browsing piles + # garbage faster than once-a-minute catches it. Instead, poll RSS cheaply and collect as + # soon as it has grown GROW_MB since the last sweep, so it kicks in DURING a heavy browse + # and caps the peak near (floor + GROW_MB) instead of letting it run to 2GB+. A backstop + # interval still collects slow accumulation when idle. + def _growth_triggered_gc(): import gc + CHECK_SECONDS = 8 # cheap RSS poll cadence + GROW_MB = 250 # collect once RSS climbs this much since the last sweep + BACKSTOP_SECONDS = 120 # ...or at least this often regardless + try: + import psutil + proc = psutil.Process(os.getpid()) + rss_mb = lambda: proc.memory_info().rss / (1024 * 1024) + except Exception: + rss_mb = lambda: 0.0 + floor = rss_mb() + last = time.time() while True: - time.sleep(interval) + time.sleep(CHECK_SECONDS) try: - n = gc.collect() - if n: - logger.debug("periodic gc.collect() reclaimed %d cyclic objects", n) + if (rss_mb() - floor) >= GROW_MB or (time.time() - last) >= BACKSTOP_SECONDS: + gc.collect() + floor = rss_mb() + last = time.time() 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)") + threading.Thread(target=_growth_triggered_gc, daemon=True, name='gc-sweeper').start() + logger.info("GC sweeper started (collect on +250MB growth, backstop 120s)") # Register action handlers and start automation engine _register_automation_handlers()