Skip to content

Commit 03e8f17

Browse files
authored
Add /health check based on RAM usage (#22)
1 parent 45b4b37 commit 03e8f17

8 files changed

Lines changed: 200 additions & 55 deletions

File tree

Dockerfile

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ WORKDIR /app
55
ADD src ./src
66
ADD pyproject.toml .
77
ADD setup.py .
8+
ADD entrypoint.py .
89

910

1011
RUN apt-get update
@@ -31,10 +32,7 @@ RUN pip install scikit-learn==1.6.1
3132
# Install spikeinterface-gui from source
3233
RUN pip install spikeinterface-gui==0.13.1
3334

34-
35-
ENV PYTHONUNBUFFERED=1
36-
3735
ENV PYTHONUNBUFFERED=1
3836

3937
EXPOSE 8000
40-
ENTRYPOINT ["sh", "-c", "panel serve src/aind_ephys_portal/ephys_gui_app.py src/aind_ephys_portal/ephys_portal_app.py src/aind_ephys_portal/ephys_launcher_app.py src/aind_ephys_portal/ephys_monitor_app.py --setup src/aind_ephys_portal/setup.py --static-dirs images=src/aind_ephys_portal/images --address 0.0.0.0 --port 8000 --allow-websocket-origin ${ALLOW_WEBSOCKET_ORIGIN} --index ephys_portal_app.py --check-unused-sessions 2000 --unused-session-lifetime 5000 --num-threads 8"]
38+
ENTRYPOINT ["python", "entrypoint.py", "--address", "0.0.0.0", "--port", "8000", "--test"]

README.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,15 @@ pip install -e .
2727
To run the Ephys Portal you can either use tha `launch.sh` script:
2828

2929
```bash
30-
bash launch.sh
31-
```
30+
AWS_PROFILE=your-profile
3231

33-
or serve panel apps directly:
32+
# Make sure your SSO session is active
33+
aws sso login --profile $AWS_PROFILE
3434

35-
```bash
36-
panel serve \
37-
src/aind_ephys_portal/ephys_gui_app.py \
38-
src/aind_ephys_portal/ephys_launcher_app.py \
39-
src/aind_ephys_portal/ephys_monitor_app.py \
40-
src/aind_ephys_portal/ephys_log_app.py \
41-
--setup src/aind_ephys_portal/setup.py \
42-
--static-dirs images=src/aind_ephys_portal/images \
43-
--index ephys_portal_app.py \
44-
--num-procs 4
35+
python entrypoint.py --port 8000 (default) --address 0.0.0.0 (default)
4536
```
4637

38+
4739
This will start a Panel server and make the application available in your web browser.
4840

4941
## Development
@@ -58,7 +50,15 @@ pip install -e ".[dev]"
5850
1. Build the Docker image locally and run a Docker container:
5951
```sh
6052
docker build -t aind-ephys-portal .
61-
docker run --rm -e ALLOW_WEBSOCKET_ORIGIN=0.0.0.0:8000 -v ~/.aws:/root/.aws:ro -p 8000:8000 aind-ephys-portal
53+
54+
AWS_PROFILE=your-profile
55+
56+
# Make sure your SSO session is active
57+
aws sso login --profile $AWS_PROFILE
58+
59+
# Export temporary credentials and run Docker
60+
eval "$(aws configure export-credentials --profile $AWS_PROFILE --format env)"
61+
docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN -e ALLOW_WEBSOCKET_ORIGIN=0.0.0.0:8000 -p 8000:8000 aind-ephys-portal
6262
```
6363
2. Navigate to '0.0.0.0:8000` to view the app.
6464

entrypoint.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import os
2+
import shutil
3+
from argparse import ArgumentParser
4+
5+
import requests
6+
import psutil
7+
import panel as pn
8+
from tornado.web import RequestHandler
9+
10+
# 1. Run setup (replaces --setup flag)
11+
from aind_ephys_portal.setup import * # noqa: F401,F403
12+
from aind_ephys_portal.panel.logging import list_gui_sessions, get_max_number_of_gui_sessions, LOG_DIR # noqa: F401
13+
14+
15+
if LOG_DIR.is_dir():
16+
print(f"Cleaning up old log files in {LOG_DIR}...")
17+
shutil.rmtree(LOG_DIR)
18+
19+
20+
def get_ecs_task_id():
21+
metadata_uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
22+
if metadata_uri:
23+
# Request the metadata from the local ECS agent
24+
response = requests.get(f"{metadata_uri}/task")
25+
task_arn = response.json().get("TaskARN")
26+
# The Task ID is the last part of the ARN string
27+
return task_arn.split('/')[-1]
28+
return "local-dev"
29+
30+
31+
MAX_GUI_SESSIONS_PER_TASK = get_max_number_of_gui_sessions()
32+
print(f"Max GUI sessions per task: {MAX_GUI_SESSIONS_PER_TASK}")
33+
34+
# 2. Health Check & Index Redirect
35+
class HealthHandler(RequestHandler):
36+
def get(self):
37+
mem = psutil.virtual_memory()
38+
# count number of GUI app sessions
39+
gui_sessions = list_gui_sessions()
40+
task_id = get_ecs_task_id()
41+
if mem.percent > 70 or len(gui_sessions) > MAX_GUI_SESSIONS_PER_TASK:
42+
self.set_status(503)
43+
self.write(
44+
f"Busy:\nMemory at {mem.percent}% - Num sessions: {len(gui_sessions)} "
45+
f"(max {MAX_GUI_SESSIONS_PER_TASK}) Task ID: {task_id}"
46+
)
47+
else:
48+
self.set_status(200)
49+
self.write(
50+
f"Healthy:\nMemory at {mem.percent}% - Num sessions: {len(gui_sessions)} "
51+
f"(max {MAX_GUI_SESSIONS_PER_TASK}) Task ID: {task_id}"
52+
)
53+
54+
class IndexRedirectHandler(RequestHandler):
55+
def get(self):
56+
self.redirect("/ephys_portal_app")
57+
58+
# 3. App file paths — Panel will exec these per-session with a proper context
59+
APP_DIR = "src/aind_ephys_portal"
60+
apps = {
61+
"ephys_portal_app": os.path.join(APP_DIR, "ephys_portal_app.py"),
62+
"ephys_gui_app": os.path.join(APP_DIR, "ephys_gui_app.py"),
63+
"ephys_launcher_app": os.path.join(APP_DIR, "ephys_launcher_app.py"),
64+
"ephys_monitor_app": os.path.join(APP_DIR, "ephys_monitor_app.py"),
65+
}
66+
67+
# 4. Parse ALLOW_WEBSOCKET_ORIGIN from env
68+
allow_ws = os.environ.get("ALLOW_WEBSOCKET_ORIGIN", "*").split(",")
69+
70+
parser = ArgumentParser(description="Ephys Portal Server")
71+
parser.add_argument("--port", type=int, default=8000, help="Port to run the server on")
72+
parser.add_argument("--address", type=str, default="localhost", help="Address to run the server on")
73+
parser.add_argument("--test", action="store_true", help="Run in test mode (connects to test API gateway)")
74+
75+
if __name__ == "__main__":
76+
args = parser.parse_args()
77+
port = args.port
78+
address = args.address
79+
test_mode = args.test
80+
81+
# Set number of threads for Panel's thread pool to handle multiple sessions in parallel
82+
pn.config.nthreads = 8
83+
84+
if test_mode:
85+
print("Running in TEST MODE: connecting to test API gateway")
86+
os.environ["TEST_ENV"] = "1"
87+
88+
print(f"Ephys Portal is running on http://{address}:{port}")
89+
for app in apps:
90+
print(f" - {app}: http://{address}:{port}/{app}")
91+
print(f" - Health check: http://{address}:{port}/health")
92+
pn.serve(
93+
apps,
94+
address=address,
95+
port=port,
96+
allow_websocket_origin=allow_ws,
97+
static_dirs={"images": os.path.join(APP_DIR, "images")},
98+
extra_patterns=[(r"/health", HealthHandler), (r"/", IndexRedirectHandler)],
99+
check_unused_sessions=2000,
100+
unused_session_lifetime=5000,
101+
show=False,
102+
)

launch.sh

Lines changed: 0 additions & 18 deletions
This file was deleted.

src/aind_ephys_portal/docdb/database.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,36 @@
66
import panel as pn
77
from aind_data_access_api.document_db import MetadataDbClient
88

9+
10+
TEST_ENV = os.environ.get("TEST_ENV", "0") == "1"
11+
912
# Constants for database connection
10-
API_GATEWAY_HOST = os.environ.get("API_GATEWAY_HOST", "api.allenneuraldynamics-test.org")
13+
default_api_gateway_host = "api.allenneuraldynamics-test.org" if TEST_ENV else "api.allenneuraldynamics.org"
14+
API_GATEWAY_HOST = os.environ.get("API_GATEWAY_HOST", default_api_gateway_host)
1115
DATABASE = os.environ.get("DATABASE", "metadata_index")
1216
COLLECTION = os.environ.get("COLLECTION", "data_assets")
1317

1418
# Timeouts
15-
TIMEOUT_1M = 60
1619
TIMEOUT_1H = 60 * 60
17-
TIMEOUT_24H = 60 * 60 * 24
1820

1921
# Initialize the client
20-
client = MetadataDbClient(
22+
client_v1 = MetadataDbClient(
23+
host=API_GATEWAY_HOST,
24+
database=DATABASE,
25+
collection=COLLECTION,
26+
version="v1",
27+
)
28+
29+
client_v2 = MetadataDbClient(
2130
host=API_GATEWAY_HOST,
2231
database=DATABASE,
2332
collection=COLLECTION,
33+
version="v2",
2434
)
2535

2636

2737
@pn.cache()
28-
def get_name_from_id(id: str):
38+
def get_name_from_id(id: str, version: str = "v2") -> str:
2939
"""Get the name field from a record with the given ID.
3040
3141
Parameters
@@ -38,6 +48,7 @@ def get_name_from_id(id: str):
3848
str
3949
The name field from the record.
4050
"""
51+
client = client_v1 if version == "v1" else client_v2
4152
response = client.aggregate_docdb_records(pipeline=[{"$match": {"_id": id}}, {"$project": {"name": 1, "_id": 0}}])
4253
return response[0]["name"]
4354

@@ -81,7 +92,7 @@ def get_asset_by_name(asset_name: str):
8192

8293

8394
@pn.cache(ttl=TIMEOUT_1H)
84-
def get_raw_asset_by_name(asset_name: str):
95+
def get_raw_asset_by_name(asset_name: str, version: str = "v2"):
8596
"""Get all assets that match a given asset name pattern.
8697
8798
Parameters
@@ -95,14 +106,15 @@ def get_raw_asset_by_name(asset_name: str):
95106
List of matching asset records.
96107
"""
97108
raw_name = _raw_name_from_derived(asset_name)
109+
client = client_v1 if version == "v1" else client_v2
98110
response = client.retrieve_docdb_records(
99111
filter_query={"name": {"$regex": raw_name, "$options": "i"}, "data_description.data_level": "raw"}, limit=0
100112
)
101113
return response
102114

103115

104116
@pn.cache(ttl=TIMEOUT_1H)
105-
def get_all_ecephys_derived(additional_includes_in_name: str | None = None) -> List[Dict[str, Any]]:
117+
def get_all_ecephys_derived(additional_includes_in_name: str | None = None, version: str = "v2") -> List[Dict[str, Any]]:
106118
"""Get a limited set of all records from the database.
107119
108120
Returns
@@ -113,6 +125,7 @@ def get_all_ecephys_derived(additional_includes_in_name: str | None = None) -> L
113125
Comma-separated list of additional fields to include in the results, by default None
114126
"""
115127
filter_query = {"data_description.modality.abbreviation": "ecephys", "data_description.data_level": "derived"}
128+
client = client_v1 if version == "v1" else client_v2
116129
responses = client.retrieve_docdb_records(
117130
filter_query=filter_query,
118131
)

src/aind_ephys_portal/panel/ephys_gui.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from spikeinterface.core.core_tools import extractor_dict_iterator, set_value_in_extractor_dict
1818
from spikeinterface.curation import validate_curation_dict
1919

20-
from aind_ephys_portal.panel.logging import setup_logging, local_log_context
20+
from aind_ephys_portal.panel.logging import (
21+
setup_logging, local_log_context, get_max_number_of_gui_sessions, list_gui_sessions
22+
)
2123
from aind_ephys_portal.panel.utils import PostMessageListener, FullscreenResizeHandler
2224

2325

@@ -94,7 +96,20 @@ def __init__(self, analyzer_path, recording_path, identifier=None, fast_mode=Fal
9496
self.loading_banner = pn.Row(self.spinner, self.log_output, sizing_mode="stretch_both")
9597

9698
self.win = None
97-
if self.analyzer_path != "":
99+
100+
num_gui_sessions = len(list_gui_sessions())
101+
max_sessions = get_max_number_of_gui_sessions()
102+
if num_gui_sessions > max_sessions:
103+
print(f"Current number of GUI sessions: {num_gui_sessions}. Max allowed per worker: {max_sessions}.")
104+
self.layout = pn.Column(
105+
pn.pane.Markdown(
106+
f"⚠️ Too many active GUI sessions ({num_gui_sessions}). Max allowed per worker is {max_sessions}. "
107+
f"Please try again in a few minutes.",
108+
sizing_mode="stretch_both",
109+
),
110+
sizing_mode="stretch_both",
111+
)
112+
elif self.analyzer_path != "":
98113
self.layout = pn.Column(
99114
self._create_main_window(),
100115
sizing_mode="stretch_both",

0 commit comments

Comments
 (0)