|
| 1 | +import gc |
1 | 2 | import os |
2 | 3 | import shutil |
3 | 4 | import threading |
| 5 | +import tracemalloc |
4 | 6 | from argparse import ArgumentParser |
| 7 | +from collections import Counter |
5 | 8 |
|
6 | 9 | import numpy as np |
7 | 10 | import psutil |
8 | 11 | import panel as pn |
9 | 12 | from tornado.web import RequestHandler |
10 | 13 |
|
| 14 | +# Start tracemalloc as early as possible so allocation tracebacks include the |
| 15 | +# session-loading code paths. Frame depth 10 is enough to identify the call site |
| 16 | +# inside spikeinterface/zarr/fsspec without blowing up memory overhead. |
| 17 | +tracemalloc.start(10) |
| 18 | +_tracemalloc_baseline = tracemalloc.take_snapshot() |
| 19 | + |
11 | 20 | # 1. Run setup (replaces --setup flag) |
12 | | -from aind_ephys_portal.setup import * # noqa: F401,F403 |
13 | | -from aind_ephys_portal.panel.logging import ( |
| 21 | +from aind_ephys_portal.setup import * # noqa: F401,F403,E402 |
| 22 | +from aind_ephys_portal.panel.logging import ( # noqa: E402 |
14 | 23 | list_gui_sessions, |
15 | 24 | get_max_number_of_gui_sessions, |
16 | 25 | get_container_total_memory, |
|
23 | 32 | TARGET_CLEAR_TMP_ARR_SECONDS = 180 |
24 | 33 | TARGET_INFLATE_DELAY_SECONDS = 90 |
25 | 34 |
|
| 35 | +# Recycle signal: when a task is sitting idle (0 GUI sessions) but its RAM |
| 36 | +# stays above this percent, /health returns 503 so the ALB deregisters it and |
| 37 | +# the ECS service scheduler replaces it. Baseline (no sessions, fresh start) |
| 38 | +# is ~10%, so 40% means tolerating ~30 points of accumulated residue before |
| 39 | +# recycling. Only triggers with 0 sessions, so user sessions are never cut. |
| 40 | +RECYCLE_RAM_PERCENT_WHEN_IDLE = 40 |
| 41 | + |
26 | 42 |
|
27 | 43 | if LOG_DIR.is_dir(): |
28 | 44 | print(f"Cleaning up old log files in {LOG_DIR}...") |
@@ -76,6 +92,16 @@ def get(self): |
76 | 92 | task_id = get_ecs_task_id() |
77 | 93 | max_gui_sessions = get_max_number_of_gui_sessions() |
78 | 94 |
|
| 95 | + # Idle-but-bloated → ask ALB to deregister so ECS replaces us. |
| 96 | + # GUI-level enforcement protects against admitting too many sessions, |
| 97 | + # but only the load balancer can take a leaky task out of rotation. |
| 98 | + if len(gui_sessions) == 0 and mem_percent > RECYCLE_RAM_PERCENT_WHEN_IDLE: |
| 99 | + self.set_status(503) |
| 100 | + self.write( |
| 101 | + f"Unhealthy (recycle): Memory at {mem_percent:.1f}% with 0 sessions - Task ID: {task_id}" |
| 102 | + ) |
| 103 | + return |
| 104 | + |
79 | 105 | if mem_percent > TARGET_MEMORY_TRIGGER_PERCENT: |
80 | 106 | self.set_status(200) |
81 | 107 | self.write( |
@@ -124,6 +150,99 @@ def get(self): |
124 | 150 | self.redirect("/ephys_portal_app") |
125 | 151 |
|
126 | 152 |
|
| 153 | +class DebugMemoryHandler(RequestHandler): |
| 154 | + """Live introspection for diagnosing memory retention on a bloated task. |
| 155 | +
|
| 156 | + Returns top object counts (via gc.get_objects), top allocation sites |
| 157 | + (tracemalloc snapshot diff vs. boot baseline), and fsspec/s3fs instance |
| 158 | + cache sizes. Gated by DEBUG_MEMORY_TOKEN env var to avoid exposing |
| 159 | + process internals publicly. |
| 160 | + """ |
| 161 | + |
| 162 | + def get(self): |
| 163 | + token = os.environ.get("DEBUG_MEMORY_TOKEN") |
| 164 | + if token and self.request.headers.get("X-Debug-Token") != token: |
| 165 | + self.set_status(401) |
| 166 | + self.write("Unauthorized: missing or wrong X-Debug-Token header") |
| 167 | + return |
| 168 | + |
| 169 | + task_id = get_ecs_task_id() |
| 170 | + total_memory = get_container_total_memory() |
| 171 | + used_memory = get_container_used_memory() |
| 172 | + mem_percent = used_memory / total_memory * 100 |
| 173 | + gui_sessions = list_gui_sessions() |
| 174 | + |
| 175 | + # Force a collection so we don't count obviously-dead objects. |
| 176 | + gc.collect() |
| 177 | + gc.collect() |
| 178 | + |
| 179 | + # 1) Object counts by type (top 30 by count) |
| 180 | + type_counts = Counter() |
| 181 | + for obj in gc.get_objects(): |
| 182 | + type_counts[type(obj).__name__] += 1 |
| 183 | + top_types = type_counts.most_common(30) |
| 184 | + |
| 185 | + # 2) numpy/zarr/pandas big buffers — sum bytes by type |
| 186 | + big_buffers = [] |
| 187 | + try: |
| 188 | + import numpy as _np |
| 189 | + |
| 190 | + np_bytes = 0 |
| 191 | + np_count = 0 |
| 192 | + for obj in gc.get_objects(): |
| 193 | + if isinstance(obj, _np.ndarray): |
| 194 | + np_bytes += obj.nbytes |
| 195 | + np_count += 1 |
| 196 | + big_buffers.append(("numpy.ndarray", np_count, np_bytes)) |
| 197 | + except Exception as e: |
| 198 | + big_buffers.append(("numpy.ndarray", -1, -1)) |
| 199 | + |
| 200 | + # 3) fsspec/s3fs cache state |
| 201 | + fsspec_info = [] |
| 202 | + try: |
| 203 | + from fsspec import AbstractFileSystem |
| 204 | + |
| 205 | + fsspec_info.append( |
| 206 | + f"fsspec AbstractFileSystem._cache size: {len(AbstractFileSystem._cache)}" |
| 207 | + ) |
| 208 | + for key, fs in list(AbstractFileSystem._cache.items())[:10]: |
| 209 | + fsspec_info.append(f" - {type(fs).__name__}: protocol={getattr(fs, 'protocol', '?')}") |
| 210 | + except Exception as e: |
| 211 | + fsspec_info.append(f"fsspec inspection failed: {e}") |
| 212 | + |
| 213 | + # 4) tracemalloc top allocators since boot baseline |
| 214 | + tm_lines = [] |
| 215 | + try: |
| 216 | + current = tracemalloc.take_snapshot() |
| 217 | + stats = current.compare_to(_tracemalloc_baseline, "lineno")[:25] |
| 218 | + for stat in stats: |
| 219 | + tm_lines.append( |
| 220 | + f" +{stat.size_diff/1024/1024:7.2f} MiB ({stat.count_diff:+d} blocks) {stat.traceback[0]}" |
| 221 | + ) |
| 222 | + except Exception as e: |
| 223 | + tm_lines.append(f"tracemalloc unavailable: {e}") |
| 224 | + |
| 225 | + self.set_header("Content-Type", "text/plain; charset=utf-8") |
| 226 | + out = [ |
| 227 | + f"=== Task {task_id} ===", |
| 228 | + f"Memory: {mem_percent:.1f}% ({used_memory/1024**3:.2f} / {total_memory/1024**3:.2f} GB)", |
| 229 | + f"GUI sessions: {len(gui_sessions)}", |
| 230 | + "", |
| 231 | + "--- Top object types by count ---", |
| 232 | + ] |
| 233 | + for name, count in top_types: |
| 234 | + out.append(f" {count:>10d} {name}") |
| 235 | + out.extend(["", "--- Buffer bytes ---"]) |
| 236 | + for name, count, nbytes in big_buffers: |
| 237 | + mib = nbytes / 1024 / 1024 if nbytes >= 0 else -1 |
| 238 | + out.append(f" {name}: {count} objects, {mib:.1f} MiB") |
| 239 | + out.extend(["", "--- fsspec ---"]) |
| 240 | + out.extend(fsspec_info) |
| 241 | + out.extend(["", "--- tracemalloc (top 25 since boot) ---"]) |
| 242 | + out.extend(tm_lines) |
| 243 | + self.write("\n".join(out)) |
| 244 | + |
| 245 | + |
127 | 246 | # 3. App file paths — Panel will exec these per-session with a proper context |
128 | 247 | APP_DIR = "src/aind_ephys_portal" |
129 | 248 | apps = { |
@@ -182,7 +301,11 @@ def get(self): |
182 | 301 | port=port, |
183 | 302 | allow_websocket_origin=allow_ws, |
184 | 303 | static_dirs={"images": os.path.join(APP_DIR, "images")}, |
185 | | - extra_patterns=[(r"/health", HealthHandler), (r"/", IndexRedirectHandler)], |
| 304 | + extra_patterns=[ |
| 305 | + (r"/health", HealthHandler), |
| 306 | + (r"/debug/memory", DebugMemoryHandler), |
| 307 | + (r"/", IndexRedirectHandler), |
| 308 | + ], |
186 | 309 | check_unused_sessions=2000, |
187 | 310 | unused_session_lifetime=5000, |
188 | 311 | show=False, |
|
0 commit comments