Skip to content

Commit d86873b

Browse files
authored
Dev changes to main (#47)
1 parent 7297bfc commit d86873b

9 files changed

Lines changed: 411 additions & 28 deletions

File tree

Dockerfile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ RUN wget "https://www.wavpack.com/wavpack-${WAVPACK_VERSION}.tar.bz2" && \
2323
# Install
2424
RUN pip install wavpack-numcodecs
2525

26-
# Install spikeinterface from source
26+
# Install spikeinterface
2727
RUN pip install spikeinterface==0.104.1
2828

29-
# Install spikeinterface-gui from source
30-
RUN pip install spikeinterface-gui==0.13.1
29+
# Install spikeinterface-gui form branch with AIND fixes
30+
RUN git clone https://github.com/alejoe91/spikeinterface-gui.git && \
31+
cd spikeinterface-gui && git checkout e98d515bb25c10a357c7eee3afb85e55d7afab67 && \
32+
pip install . && cd ..
33+
# RUN pip install spikeinterface-gui==0.13.1
3134

3235
# Pin scikit-learn AFTER spikeinterface installs so we override whatever the
3336
# transitive resolver picked. Match the version that analyzers in our pipeline
@@ -43,4 +46,4 @@ ENV PYTHONUNBUFFERED=1
4346
ENV MALLOC_ARENA_MAX=2
4447

4548
EXPOSE 8000
46-
ENTRYPOINT ["python", "entrypoint.py", "--address", "0.0.0.0", "--port", "8000"]
49+
ENTRYPOINT ["python", "entrypoint.py", "--address", "0.0.0.0", "--port", "8000"]

entrypoint.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,18 @@
3030

3131
# 1. Run setup (replaces --setup flag)
3232
from aind_ephys_portal.setup import * # noqa: F401,F403,E402
33-
from aind_ephys_portal.panel.logging import ( # noqa: E402
33+
from aind_ephys_portal.session_logging import ( # noqa: E402
3434
list_gui_sessions,
3535
get_max_number_of_gui_sessions,
36-
can_admit_new_session,
3736
get_hard_cap_sessions,
37+
get_health_estimate_pct,
3838
get_container_total_memory,
3939
get_container_used_memory,
4040
get_ecs_task_id,
41+
recent_session_rejection,
4142
LOG_DIR,
4243
) # noqa: F401
44+
from aind_ephys_portal.ecs_protection import is_protected, get_protection_status # noqa: E402
4345

4446
TARGET_MEMORY_TRIGGER_PERCENT = 70
4547
TARGET_CLEAR_TMP_ARR_SECONDS = 180
@@ -111,19 +113,25 @@ def get(self):
111113
# Idle-but-bloated → ask ALB to deregister so ECS replaces us.
112114
# GUI-level enforcement protects against admitting too many sessions,
113115
# but only the load balancer can take a leaky task out of rotation.
116+
protected = is_protected()
117+
prot_tag = " (protected)" if protected else ""
118+
114119
if num_sessions == 0 and mem_percent > RECYCLE_RAM_PERCENT_WHEN_IDLE:
115120
self.set_status(503)
116121
self.write(
117122
f"Unhealthy (recycle): Memory at {mem_percent:.1f}% with 0 sessions - Task ID: {task_id}"
118123
)
119124
return
120125

121-
# "Operationally full" = at least one session AND can't fit another
122-
# without crossing the safe RAM ceiling or hitting the hard count cap.
123-
# This unifies the old (count-based) and RAM-based busy paths into one
124-
# predicate that's accurate for both light and heavy sessions.
125-
full = num_sessions > 0 and not can_admit_new_session(
126-
current_count=num_sessions, used_pct=mem_percent
126+
# "Operationally full" = either at the hard session cap, or the GUI
127+
# just rejected an incoming session (RAM headroom exceeded). We do
128+
# NOT predict-reject here based on a hypothetical incoming session —
129+
# that would over-trigger ECS scale-up whenever a mid/heavy session
130+
# is happily running. Concrete rejections are the only signal that
131+
# real scaling pressure exists.
132+
full = num_sessions > 0 and (
133+
num_sessions >= get_hard_cap_sessions()
134+
or recent_session_rejection()
127135
)
128136

129137
if full:
@@ -150,7 +158,7 @@ def get(self):
150158
elif _tmp_array_triggered:
151159
busy_msg += " (inflated memory released)"
152160
self.write(
153-
f"Busy {busy_msg}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
161+
f"Busy {busy_msg}{prot_tag}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
154162
f"(hard cap {hard_cap}) Task ID: {task_id}"
155163
)
156164
else:
@@ -160,11 +168,22 @@ def get(self):
160168
_inflate_delay_timer = None
161169
self.set_status(200)
162170
self.write(
163-
f"Healthy:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
171+
f"Healthy{prot_tag}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
164172
f"(hard cap {hard_cap}) Task ID: {task_id}"
165173
)
166174

167175

176+
class ProtectionStatusHandler(RequestHandler):
177+
"""GET /debug/protection — show current ECS task scale-in protection status."""
178+
179+
def get(self):
180+
import json as _json
181+
182+
status = get_protection_status()
183+
self.set_header("Content-Type", "application/json; charset=utf-8")
184+
self.write(_json.dumps(status, indent=2))
185+
186+
168187
class IndexRedirectHandler(RequestHandler):
169188
def get(self):
170189
self.redirect("/ephys_portal_app")
@@ -425,6 +444,8 @@ def post(self):
425444
if test_mode:
426445
print("Running in TEST MODE: connecting to test API gateway")
427446
os.environ["TEST_ENV"] = "1"
447+
# Lower threshold on test for easier testing
448+
os.environ["RECYCLE_RAM_PERCENT_WHEN_IDLE"] = "15"
428449

429450
print(f"Ephys Portal is running on http://{address}:{port}")
430451
for app in apps:
@@ -441,6 +462,7 @@ def post(self):
441462
(r"/debug/memory", DebugMemoryHandler),
442463
(r"/debug/tracemalloc/start", TracemallocStartHandler),
443464
(r"/debug/tracemalloc/stop", TracemallocStopHandler),
465+
(r"/debug/protection", ProtectionStatusHandler),
444466
(r"/", IndexRedirectHandler),
445467
],
446468
check_unused_sessions=2000,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""ECS task scale-in protection via the container agent endpoint.
2+
3+
When at least one GUI session is active the task is marked as protected so
4+
that ECS auto-scaling and deployment scale-in events cannot terminate it.
5+
Protection is renewed periodically (before the expiry window closes) and
6+
cleared when the last GUI session ends.
7+
8+
The module is a no-op when ``ECS_AGENT_URI`` is not set (local development).
9+
"""
10+
11+
import json
12+
import os
13+
import threading
14+
15+
import requests
16+
17+
from aind_ephys_portal.session_logging import list_gui_sessions
18+
19+
# --- Configuration -----------------------------------------------------------
20+
21+
PROTECTION_DURATION_MINUTES = 60
22+
"""Each protection window lasts this long. Renewed before it expires."""
23+
24+
_REFRESH_INTERVAL_SECONDS = 50 * 60 # 50 min — well before 60-min expiry
25+
26+
# --- Module state (guarded by _lock) -----------------------------------------
27+
28+
_lock = threading.Lock()
29+
_is_protected = False
30+
_refresh_timer: threading.Timer | None = None
31+
32+
33+
# --- Low-level ECS agent call ------------------------------------------------
34+
35+
def _agent_uri() -> str | None:
36+
return os.environ.get("ECS_AGENT_URI")
37+
38+
39+
def _set_task_protection(enabled: bool, expires_minutes: int = PROTECTION_DURATION_MINUTES) -> bool:
40+
"""PUT to the ECS agent endpoint. Returns True on success."""
41+
uri = _agent_uri()
42+
if not uri:
43+
action = "protect" if enabled else "unprotect"
44+
print(f"[ecs_protection] No ECS_AGENT_URI — skipping {action} (local dev)")
45+
return True # treat as success in local dev
46+
47+
url = f"{uri}/task-protection/v1/state"
48+
body: dict = {"ProtectionEnabled": enabled}
49+
if enabled:
50+
body["ExpiresInMinutes"] = expires_minutes
51+
52+
try:
53+
resp = requests.put(url, json=body, timeout=5)
54+
data = resp.json()
55+
if "error" in data:
56+
print(f"[ecs_protection] Agent error: {json.dumps(data['error'])}")
57+
return False
58+
print(f"[ecs_protection] Task protection set: enabled={enabled}, response={json.dumps(data)}")
59+
return True
60+
except Exception as exc:
61+
print(f"[ecs_protection] Failed to set protection (enabled={enabled}): {exc}")
62+
return False
63+
64+
65+
# --- High-level API ----------------------------------------------------------
66+
67+
def _cancel_refresh_timer():
68+
global _refresh_timer
69+
if _refresh_timer is not None:
70+
_refresh_timer.cancel()
71+
_refresh_timer = None
72+
73+
74+
def _schedule_refresh():
75+
global _refresh_timer
76+
_cancel_refresh_timer()
77+
_refresh_timer = threading.Timer(_REFRESH_INTERVAL_SECONDS, _refresh_protection)
78+
_refresh_timer.daemon = True
79+
_refresh_timer.start()
80+
81+
82+
def _refresh_protection():
83+
"""Called by the timer — renew protection if sessions still exist, else clear."""
84+
with _lock:
85+
if len(list_gui_sessions()) > 0:
86+
print("[ecs_protection] Renewing task protection (sessions still active)")
87+
_set_task_protection(True)
88+
_schedule_refresh()
89+
else:
90+
print("[ecs_protection] No sessions at refresh time — clearing protection")
91+
_unprotect_task_locked()
92+
93+
94+
def _unprotect_task_locked():
95+
"""Must be called while holding _lock."""
96+
global _is_protected
97+
_cancel_refresh_timer()
98+
_set_task_protection(False)
99+
_is_protected = False
100+
101+
102+
def protect_task():
103+
"""Enable scale-in protection for this task (idempotent while protected)."""
104+
global _is_protected
105+
with _lock:
106+
if _is_protected:
107+
return
108+
if _set_task_protection(True):
109+
_is_protected = True
110+
_schedule_refresh()
111+
112+
113+
def unprotect_task():
114+
"""Disable scale-in protection for this task."""
115+
with _lock:
116+
if not _is_protected:
117+
return
118+
_unprotect_task_locked()
119+
120+
121+
def is_protected() -> bool:
122+
return _is_protected
123+
124+
125+
def get_protection_status() -> dict | None:
126+
"""GET the current protection status from the ECS agent. Returns None in local dev."""
127+
uri = _agent_uri()
128+
if not uri:
129+
return {"local_dev": True, "is_protected": _is_protected}
130+
try:
131+
resp = requests.get(f"{uri}/task-protection/v1/state", timeout=5)
132+
return resp.json()
133+
except Exception as exc:
134+
return {"error": str(exc)}

src/aind_ephys_portal/ephys_launcher_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import panel as pn
66

7-
from aind_ephys_portal.panel.logging import setup_logging
7+
from aind_ephys_portal.session_logging import setup_logging
88
from aind_ephys_portal.panel.utils import EPHYSGUI_LINK_PREFIX
99

1010
pn.extension()

src/aind_ephys_portal/ephys_monitor_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import pandas as pd
33
import panel as pn
44

5-
from aind_ephys_portal.panel.logging import (
5+
from aind_ephys_portal.session_logging import (
66
list_sessions,
77
remove_session,
88
get_container_total_memory,

src/aind_ephys_portal/panel/ephys_gui.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from spikeinterface.core.zarrextractors import super_zarr_open
3636
from spikeinterface.curation import validate_curation_dict
3737

38-
from aind_ephys_portal.panel.logging import (
38+
from aind_ephys_portal.session_logging import (
3939
setup_logging,
4040
local_log_context,
4141
can_admit_new_session,
@@ -47,6 +47,11 @@
4747
get_ecs_task_id,
4848
get_container_total_memory,
4949
get_container_used_memory,
50+
record_session_rejection,
51+
record_pending_load,
52+
clear_pending_load,
53+
record_admission,
54+
admission_cooldown_remaining,
5055
remove_session,
5156
)
5257
from aind_ephys_portal.panel.utils import PostMessageListener, FullscreenResizeHandler
@@ -211,6 +216,34 @@ def __init__(
211216
ram_percent = get_container_used_memory() / get_container_total_memory() * 100
212217
total_ram_bytes = get_container_total_memory()
213218

219+
# Admission cooldown: if another session was admitted within the
220+
# last few seconds, soft-reject this one. The previous admission's
221+
# pending-load entry needs a moment to register so concurrent
222+
# admissions don't all read the same stale `used + pending` sum.
223+
# NB: this is sequencing, not real capacity pressure — we do NOT
224+
# call record_session_rejection() so /health doesn't inflate.
225+
cooldown_remaining = admission_cooldown_remaining()
226+
if cooldown_remaining > 0:
227+
print(
228+
f"Soft-rejecting (admission cooldown): {cooldown_remaining:.1f}s remaining. "
229+
f"Task ID: {task_id}"
230+
)
231+
doc = pn.state.curdoc
232+
route = getattr(doc, "_log_route", None)
233+
session_id = getattr(doc, "_log_session_id", None)
234+
if route and session_id:
235+
remove_session(route, session_id)
236+
self.layout = pn.Column(
237+
header,
238+
pn.pane.Markdown(
239+
f"⏱️ Another session is being admitted — please retry in "
240+
f"a few seconds. (Task: `{task_id}`)",
241+
sizing_mode="stretch_both",
242+
),
243+
sizing_mode="stretch_both",
244+
)
245+
return
246+
214247
# Try to derive a *dataset-specific* RAM estimate by reading the unit counts
215248
# unit count. This costs one S3 round-trip (~1-3s) but lets us accurately predict heavy
216249
# sessions instead of relying on a fixed percent.
@@ -227,6 +260,11 @@ def __init__(
227260
f"{est_bytes / (1024**3):.2f} GB ({estimate_pct:.1f}%) "
228261
f"for {num_units} units, fast_mode={self.fast_mode}"
229262
)
263+
# Teach /health what the largest recent session looked like
264+
# — its admission predicate uses this so it can fire the
265+
# inflate signal when a *heavy* session arrives instead of
266+
# relying on the static fallback percent.
267+
record_session_estimate(estimate_pct)
230268
except Exception as e:
231269
# Pre-load failed (S3 transient, bad path, etc.) — fall back
232270
# to the static estimate. Better to occasionally reject a
@@ -240,6 +278,11 @@ def __init__(
240278
used_pct=ram_percent,
241279
estimate_pct=estimate_pct,
242280
):
281+
# Tell /health a rejection just happened so it can fire the
282+
# inflate / scale-up signal on its next tick. Without this,
283+
# /health would see the task's current RAM and think there's
284+
# still room — even though admission just turned a session away.
285+
record_session_rejection()
243286
# Remove this session from the count — it is being rejected
244287
doc = pn.state.curdoc
245288
route = getattr(doc, "_log_route", None)
@@ -269,6 +312,19 @@ def __init__(
269312
sizing_mode="stretch_both",
270313
)
271314
elif self.analyzer_path != "":
315+
# Admission succeeded — register this session's estimated RAM so
316+
# later admission attempts (within the same ~10s loading window)
317+
# see it as "pending" instead of reading a stale-low used_pct from
318+
# cgroup. Falls back to the static estimate if the dynamic one
319+
# could not be computed. The pending entry is cleared in cleanup()
320+
# or expires after PENDING_LOAD_TTL (60s).
321+
self._pending_session_id = getattr(pn.state.curdoc, "_log_session_id", None)
322+
pending_pct = estimate_pct if estimate_pct is not None else get_per_session_estimate_pct()
323+
record_pending_load(self._pending_session_id, pending_pct)
324+
# Start the admission-cooldown clock so the next arrival waits
325+
# for THIS pending-load entry to be visible.
326+
record_admission()
327+
272328
self.layout = pn.Column(
273329
header,
274330
self._create_main_window(),
@@ -517,6 +573,13 @@ def cleanup(self):
517573
current_ram_usage = initial_mem.used / (1024**3)
518574
print(f"\nRAM Usage before cleanup: {current_ram_usage:.2f} / {total_ram:.2f} GB\n")
519575

576+
# 0) Release this session's pending-load reservation so the next
577+
# admission attempt sees accurate RAM headroom. (The actual cgroup
578+
# `memory.current` won't drop until the deferred GC further down
579+
# runs malloc_trim — but the pending entry was overestimating, so
580+
# dropping it now lets other admissions proceed sooner.)
581+
clear_pending_load(getattr(self, "_pending_session_id", None))
582+
520583
# 1) Clear postMessage listeners and submit trigger (they hold bound-method back-refs to self)
521584
self.curation_listener = None
522585
self.fullscreen_listener = None

0 commit comments

Comments
 (0)