Skip to content

Commit 9254f46

Browse files
authored
Add ECS task scale-in protection for active GUI sessions (#43)
1 parent 0fc11bb commit 9254f46

8 files changed

Lines changed: 168 additions & 40 deletions

File tree

entrypoint.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
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,
3636
can_admit_new_session,
@@ -40,6 +40,7 @@
4040
get_ecs_task_id,
4141
LOG_DIR,
4242
) # noqa: F401
43+
from aind_ephys_portal.ecs_protection import is_protected, get_protection_status # noqa: E402
4344

4445
TARGET_MEMORY_TRIGGER_PERCENT = 70
4546
TARGET_CLEAR_TMP_ARR_SECONDS = 180
@@ -111,6 +112,9 @@ def get(self):
111112
# Idle-but-bloated → ask ALB to deregister so ECS replaces us.
112113
# GUI-level enforcement protects against admitting too many sessions,
113114
# but only the load balancer can take a leaky task out of rotation.
115+
protected = is_protected()
116+
prot_tag = " (protected)" if protected else ""
117+
114118
if num_sessions == 0 and mem_percent > RECYCLE_RAM_PERCENT_WHEN_IDLE:
115119
self.set_status(503)
116120
self.write(
@@ -150,7 +154,7 @@ def get(self):
150154
elif _tmp_array_triggered:
151155
busy_msg += " (inflated memory released)"
152156
self.write(
153-
f"Busy {busy_msg}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
157+
f"Busy {busy_msg}{prot_tag}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
154158
f"(hard cap {hard_cap}) Task ID: {task_id}"
155159
)
156160
else:
@@ -160,11 +164,22 @@ def get(self):
160164
_inflate_delay_timer = None
161165
self.set_status(200)
162166
self.write(
163-
f"Healthy:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
167+
f"Healthy{prot_tag}:\nMemory at {mem_percent:.1f}% - Num sessions: {num_sessions} "
164168
f"(hard cap {hard_cap}) Task ID: {task_id}"
165169
)
166170

167171

172+
class ProtectionStatusHandler(RequestHandler):
173+
"""GET /debug/protection — show current ECS task scale-in protection status."""
174+
175+
def get(self):
176+
import json as _json
177+
178+
status = get_protection_status()
179+
self.set_header("Content-Type", "application/json; charset=utf-8")
180+
self.write(_json.dumps(status, indent=2))
181+
182+
168183
class IndexRedirectHandler(RequestHandler):
169184
def get(self):
170185
self.redirect("/ephys_portal_app")
@@ -441,6 +456,7 @@ def post(self):
441456
(r"/debug/memory", DebugMemoryHandler),
442457
(r"/debug/tracemalloc/start", TracemallocStartHandler),
443458
(r"/debug/tracemalloc/stop", TracemallocStopHandler),
459+
(r"/debug/protection", ProtectionStatusHandler),
444460
(r"/", IndexRedirectHandler),
445461
],
446462
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 & 33 deletions
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,
@@ -227,12 +227,6 @@ def refresh_log_tabs():
227227
# --- Process table (htop-like) ---
228228
def get_process_table():
229229
"""Collect per-process info into a DataFrame."""
230-
<<<<<<< HEAD
231-
rows = []
232-
for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", "status"]):
233-
try:
234-
info = proc.info
235-
=======
236230
container_total = get_container_total_memory()
237231
rows = []
238232
for proc in psutil.process_iter(["pid", "name", "cpu_percent", "status"]):
@@ -241,28 +235,19 @@ def get_process_table():
241235
rss = proc.memory_info().rss
242236
rss_gb = rss / (1024**3)
243237
mem_pct = rss / container_total * 100 if container_total else 0.0
244-
>>>>>>> origin
245238
rows.append(
246239
{
247240
"PID": info["pid"],
248241
"Name": info["name"] or "",
249242
"CPU %": round(info["cpu_percent"] or 0.0, 1),
250-
<<<<<<< HEAD
251-
"Memory %": round(info["memory_percent"] or 0.0, 1),
252-
=======
253243
"RAM (GB)": round(rss_gb, 2),
254244
"Memory %": round(mem_pct, 1),
255-
>>>>>>> origin
256245
"Status": info["status"] or "",
257246
}
258247
)
259248
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
260249
continue
261-
<<<<<<< HEAD
262-
df = pd.DataFrame(rows, columns=["PID", "Name", "CPU %", "Memory %", "Status"])
263-
=======
264250
df = pd.DataFrame(rows, columns=["PID", "Name", "CPU %", "RAM (GB)", "Memory %", "Status"])
265-
>>>>>>> origin
266251
return df.sort_values("CPU %", ascending=False).reset_index(drop=True)
267252

268253

@@ -274,11 +259,7 @@ def get_process_table():
274259
theme="simple",
275260
frozen_columns=["PID"],
276261
sorters=[{"field": "CPU %", "dir": "desc"}],
277-
<<<<<<< HEAD
278-
height=600,
279-
=======
280262
min_height=600,
281-
>>>>>>> origin
282263
)
283264

284265

@@ -289,21 +270,12 @@ def refresh_process_table():
289270
pn.state.add_periodic_callback(refresh_process_table, period=2000)
290271

291272

292-
<<<<<<< HEAD
293-
# --- Accordion: Session Logs + Tasks ---
294-
accordion = pn.Accordion(
295-
("Session Logs", log_container),
296-
("Tasks", task_tabulator),
297-
active=[0],
298-
sizing_mode="stretch_both",
299-
=======
300273
# --- Tabs: Session Logs + Tasks ---
301274
monitor_tabs = pn.Tabs(
302275
("Session Logs", log_container),
303276
("Tasks", task_tabulator),
304277
sizing_mode="stretch_both",
305278
dynamic=True,
306-
>>>>>>> origin
307279
)
308280

309281

@@ -315,11 +287,7 @@ def refresh_process_table():
315287
pn.Row(cpu_usage_label, cpu_monitor),
316288
pn.pane.Markdown("## Active Sessions"),
317289
sessions_summary,
318-
<<<<<<< HEAD
319-
accordion,
320-
=======
321290
monitor_tabs,
322-
>>>>>>> origin
323291
sizing_mode="stretch_both",
324292
)
325293

src/aind_ephys_portal/panel/ephys_gui.py

Lines changed: 1 addition & 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,

src/aind_ephys_portal/panel/ephys_portal.py

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

1111
from aind_ephys_portal.docdb.database import get_raw_asset_by_name, get_all_ecephys_derived
1212
from aind_ephys_portal.panel.utils import format_link, OUTER_STYLE, EPHYSGUI_LINK_PREFIX
13-
from aind_ephys_portal.panel.logging import setup_logging, get_container_total_memory, get_container_used_memory
13+
from aind_ephys_portal.session_logging import setup_logging, get_container_total_memory, get_container_used_memory
1414

1515
s3_client = boto3.client("s3")
1616

src/aind_ephys_portal/setup.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import panel as pn
22

3-
from aind_ephys_portal.panel.logging import add_session, remove_session, setup_logging
3+
from aind_ephys_portal.session_logging import add_session, remove_session, setup_logging
4+
from aind_ephys_portal.session_logging import list_gui_sessions
5+
from aind_ephys_portal.ecs_protection import protect_task, unprotect_task, is_protected
46

57
setup_logging()
68

@@ -31,6 +33,10 @@ def on_session_created(session_context):
3133
add_session(route=route, session_id=session_id)
3234
print(f"[setup] Session created: {route}/{session_id}")
3335

36+
# Protect task from scale-in while GUI sessions are active
37+
if route == "ephys_gui_app" and not is_protected():
38+
protect_task()
39+
3440

3541
def on_session_destroyed(session_context):
3642
doc = session_context._document
@@ -43,6 +49,10 @@ def on_session_destroyed(session_context):
4349
remove_session(route=route, session_id=session_id)
4450
print(f"[setup] Session destroyed: {route}/{session_id}")
4551

52+
# Clear protection when the last GUI session ends
53+
if route == "ephys_gui_app" and len(list_gui_sessions()) == 0:
54+
unprotect_task()
55+
4656

4757
pn.state.on_session_created(on_session_created)
4858
pn.state.on_session_destroyed(on_session_destroyed)

0 commit comments

Comments
 (0)