Skip to content

Commit 7297bfc

Browse files
authored
fix: load DB and streams in separate threads (#42)
1 parent db0a262 commit 7297bfc

2 files changed

Lines changed: 127 additions & 61 deletions

File tree

src/aind_ephys_portal/ephys_monitor_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def get_process_table():
259259
theme="simple",
260260
frozen_columns=["PID"],
261261
sorters=[{"field": "CPU %", "dir": "desc"}],
262-
height=600,
262+
min_height=600,
263263
)
264264

265265

src/aind_ephys_portal/panel/ephys_portal.py

Lines changed: 126 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Main Panel application for the AIND SIGUI Portal."""
22

33
import os
4+
import threading
45
import param
56
import panel as pn
67
import pandas as pd
@@ -9,7 +10,7 @@
910

1011
from aind_ephys_portal.docdb.database import get_raw_asset_by_name, get_all_ecephys_derived
1112
from aind_ephys_portal.panel.utils import format_link, OUTER_STYLE, EPHYSGUI_LINK_PREFIX
12-
from aind_ephys_portal.panel.logging import setup_logging
13+
from aind_ephys_portal.panel.logging import setup_logging, get_container_total_memory, get_container_used_memory
1314

1415
s3_client = boto3.client("s3")
1516

@@ -30,8 +31,9 @@ def __init__(self):
3031
setup_logging() # Ensure logging is set up for this panel
3132
# for test deployment, use v1 by default
3233
default_db_version = "v2" if os.environ.get("TEST_ENV", "0") == "0" else "v1"
33-
# Initialize search options with default database version
34-
self.search_options = SearchOptions(database_version=default_db_version)
34+
# Initialize search options without blocking on database load
35+
self.search_options = SearchOptions(database_version=default_db_version, defer_load=True)
36+
self._db_loading = False
3537
# Get the search input widget
3638
self.search_bar = pn.widgets.TextInput(
3739
name="Search",
@@ -46,18 +48,23 @@ def __init__(self):
4648
"""
4749
self.results_panel = pn.widgets.Tabulator(
4850
pd.DataFrame(columns=["name", "subject_id", "date", "id"]), # Empty DataFrame initially
49-
height=400,
51+
min_height=400,
5052
selectable=True,
5153
disabled=True,
5254
show_index=False,
5355
stylesheets=[stylesheet],
5456
styles={"background-color": "#f5f5f5", "padding": "20px", "border-radius": "5px"},
5557
)
58+
self._results_loading_pane = pn.pane.Markdown(
59+
"*Loading Database...*",
60+
styles={"background-color": "#f5f5f5", "padding": "20px", "border-radius": "5px", "min-height": "200px"},
61+
)
62+
self.results_container = pn.Column(self._results_loading_pane)
5663

5764
# Create a streams panel to display postprocessed streams for the selected entry
5865
self.streams_panel = pn.widgets.Tabulator(
5966
pd.DataFrame(columns=["Stream name", "Ephys GUI View"]), # Empty DataFrame initially
60-
height=200,
67+
min_height=200,
6168
sizing_mode="stretch_width",
6269
show_index=False,
6370
disabled=True,
@@ -66,6 +73,11 @@ def __init__(self):
6673
stylesheets=[stylesheet],
6774
styles={"background-color": "#f5f5f5", "padding": "20px", "border-radius": "5px"},
6875
)
76+
self._streams_loading_pane = pn.pane.Markdown(
77+
"*Loading postprocessed streams...*",
78+
styles={"background-color": "#f5f5f5", "padding": "20px", "border-radius": "5px", "min-height": "100px"},
79+
)
80+
self.streams_container = pn.Column(self.streams_panel)
6981

7082
# Update the results panel when the search input changes
7183
self.search_bar.param.watch(self.update_results, "value")
@@ -79,32 +91,76 @@ def __init__(self):
7991
self.database_version_dropdown.param.watch(self.update_db_version, "value")
8092
self.refresh_button = pn.widgets.Button(name="Refresh Datasets", button_type="primary", height=30, width=150)
8193
self.refresh_button.on_click(self.update_results)
82-
# Initialize with current results
83-
self.update_results(None)
94+
# Load database in a background thread so the UI renders immediately
95+
self._load_database()
96+
97+
def _run_in_background(self, target, on_complete):
98+
"""Run *target()* in a daemon thread; schedule *on_complete(result)* on the UI thread."""
99+
def _worker():
100+
result = target()
101+
pn.state.execute(lambda: on_complete(result))
102+
103+
t = threading.Thread(target=_worker, daemon=True)
104+
t.start()
105+
106+
def _set_db_loading(self, loading):
107+
"""Toggle the loading state and enable/disable controls accordingly."""
108+
self._db_loading = loading
109+
self.refresh_button.disabled = loading
110+
self.database_version_dropdown.disabled = loading
111+
112+
def _load_database(self):
113+
"""Kick off a background thread to load/reload the database."""
114+
self._set_db_loading(True)
115+
self.results_container[:] = [self._results_loading_pane]
116+
self.streams_container[:] = [self.streams_panel]
117+
self.streams_panel.value = pd.DataFrame(columns=["Stream name", "Ephys GUI View"])
118+
119+
def _do_load():
120+
used = get_container_used_memory()
121+
total = get_container_total_memory()
122+
print(f"[Portal] RAM before DB load: {used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB ({used / total * 100:.1f}%)")
123+
self.search_options.update_options()
124+
used = get_container_used_memory()
125+
print(f"[Portal] RAM after DB load: {used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB ({used / total * 100:.1f}%)")
126+
return self.search_options.df
127+
128+
def _on_loaded(df):
129+
self._set_db_loading(False)
130+
self.results_panel.value = df
131+
self.results_container[:] = [self.results_panel]
132+
print("Database loaded.")
133+
134+
self._run_in_background(_do_load, _on_loaded)
84135

85136
def update_db_version(self, event):
86137
"""Update the database version used for searching."""
87138
if event.new != event.old:
88139
new_version = event.new
89140
print(f"Switching to database version: {new_version}")
90141
self.search_options.database_version = new_version
91-
self.search_options.update_options()
92-
self.update_results(None)
142+
self._load_database()
93143

94144
def update_results(self, event):
95145
"""Update the results panel with the current search results."""
96146
print("Updating search results...")
97147
if event is None:
98148
df = self.search_options.df
99149
elif event.name == "clicks":
100-
self.search_options.update_options()
101-
df = self.search_options.df
150+
# Refresh button: reload from database in a background thread
151+
self._load_database()
152+
return
102153
else:
103154
# Filter the DataFrame based on the search input
155+
if self._db_loading:
156+
# Database is still loading; nothing to filter yet
157+
return
104158
df = self.search_options.df_filtered(event.new)
105159
self.results_panel.value = df
160+
self.results_container[:] = [self.results_panel]
106161
# Clear the streams panel when results are updated
107162
self.streams_panel.value = pd.DataFrame(columns=["Stream name", "Ephys GUI View"])
163+
self.streams_container[:] = [self.streams_panel]
108164

109165
def update_streams(self, event):
110166
"""Update the streams panel with the postprocessed streams for the selected entry."""
@@ -122,48 +178,57 @@ def update_streams(self, event):
122178
asset_name = record.get("name", "")
123179
location = record.get("location", "")
124180

125-
loading_text = f"Loading postprocessed streams..."
126-
streams_df = pd.DataFrame({"Stream name": [loading_text], "Ephys GUI View": [""]})
127-
self.streams_panel.value = streams_df
128-
129-
# Get the postprocessed streams for this location
130-
stream_names = self.search_options.get_postprocessed_streams(location)
131-
print(f"Found {len(stream_names)} postprocessed streams from {location}")
132-
analyzer_base_location = record["location"]
133-
raw_asset = get_raw_asset_by_name(asset_name, version=db_version)[0]
134-
links_url = []
135-
for stream_name in stream_names:
136-
raw_stream_name = stream_name[: stream_name.find("_recording")]
137-
raw_asset_prefix = self.get_raw_asset_location(raw_asset["location"])
138-
print(f"Raw asset prefix: {raw_asset_prefix}")
139-
if raw_asset_prefix is None:
140-
recording_path = ""
141-
else:
142-
recording_path = f"{raw_asset_prefix}/{raw_stream_name}.zarr"
143-
analyzer_path = f"{analyzer_base_location}/postprocessed/{stream_name}"
144-
if not analyzer_path.endswith(".zarr"):
145-
link_url = "Only Zarr files are supported."
146-
else:
147-
link_url = EPHYSGUI_LINK_PREFIX.format(analyzer_path, recording_path, asset_name).replace(
148-
"#", "%23"
149-
)
150-
links_url.append(link_url)
151-
links = []
152-
for link in links_url:
153-
if "ephys_gui_app" in link:
154-
links.append(format_link(link, text="SpikeInterface-GUI"))
155-
else:
156-
links.append(link)
157-
158-
# Update the streams panel
159-
streams_df = pd.DataFrame({"Stream name": stream_names, "Ephys GUI View": links})
160-
self.streams_panel.value = streams_df
181+
# Show loading pane while fetching streams in background
182+
self.streams_container[:] = [self._streams_loading_pane]
183+
184+
# Fetch streams data in a background thread to avoid blocking the UI
185+
def _on_streams_loaded(result_df):
186+
self.streams_panel.value = result_df
187+
self.streams_container[:] = [self.streams_panel]
188+
189+
self._run_in_background(
190+
lambda: self._fetch_streams_data(record, asset_name, location, db_version),
191+
_on_streams_loaded,
192+
)
161193
return
162194

163-
# If no matching record is found, clear the streams panel
164-
no_streams_text = f"No postprocessed streams..."
165-
streams_df = pd.DataFrame({"Stream name": [no_streams_text], "Ephys GUI View": [""]})
166-
self.streams_panel.value = streams_df
195+
# If no matching record is found, show message
196+
self.streams_container[:] = [pn.pane.Markdown(
197+
"*No postprocessed streams...*",
198+
styles={"background-color": "#f5f5f5", "padding": "20px", "border-radius": "5px"},
199+
)]
200+
201+
def _fetch_streams_data(self, record, asset_name, location, db_version):
202+
"""Blocking helper that fetches postprocessed stream data (runs in a background thread)."""
203+
stream_names = self.search_options.get_postprocessed_streams(location)
204+
print(f"Found {len(stream_names)} postprocessed streams from {location}")
205+
analyzer_base_location = record["location"]
206+
raw_asset = get_raw_asset_by_name(asset_name, version=db_version)[0]
207+
links_url = []
208+
for stream_name in stream_names:
209+
raw_stream_name = stream_name[: stream_name.find("_recording")]
210+
raw_asset_prefix = self.get_raw_asset_location(raw_asset["location"])
211+
print(f"Raw asset prefix: {raw_asset_prefix}")
212+
if raw_asset_prefix is None:
213+
recording_path = ""
214+
else:
215+
recording_path = f"{raw_asset_prefix}/{raw_stream_name}.zarr"
216+
analyzer_path = f"{analyzer_base_location}/postprocessed/{stream_name}"
217+
if not analyzer_path.endswith(".zarr"):
218+
link_url = "Only Zarr files are supported."
219+
else:
220+
link_url = EPHYSGUI_LINK_PREFIX.format(analyzer_path, recording_path, asset_name).replace(
221+
"#", "%23"
222+
)
223+
links_url.append(link_url)
224+
links = []
225+
for link in links_url:
226+
if "ephys_gui_app" in link:
227+
links.append(format_link(link, text="SpikeInterface-GUI"))
228+
else:
229+
links.append(link)
230+
231+
return pd.DataFrame({"Stream name": stream_names, "Ephys GUI View": links})
167232

168233
def get_raw_asset_location(self, asset_location):
169234
asset_without_s3 = asset_location[asset_location.find("s3://") + 5 :]
@@ -196,10 +261,10 @@ def panel(self):
196261
self.refresh_button,
197262
pn.layout.Divider(),
198263
pn.pane.Markdown("## Search Results", styles={"text-align": "left"}),
199-
self.results_panel,
264+
self.results_container,
200265
pn.layout.Divider(),
201266
pn.pane.Markdown("## Postprocessed Streams", styles={"text-align": "left"}),
202-
self.streams_panel,
267+
self.streams_container,
203268
min_width=1500,
204269
styles=OUTER_STYLE,
205270
align="center",
@@ -212,16 +277,15 @@ def panel(self):
212277
class SearchOptions(param.Parameterized):
213278
"""Search options for the Ephys Portal."""
214279

215-
def __init__(self, database_version="v2"):
280+
def __init__(self, database_version="v2", defer_load=False):
216281
"""Initialize a search options object."""
217282
super().__init__()
218283
self.database_version = database_version
284+
self.all_records = []
285+
self.df = pd.DataFrame(columns=["name", "subject_id", "date", "id"])
219286

220-
self.update_options()
221-
222-
# Sort by date if available
223-
if not self.df.empty and "date" in self.df.columns:
224-
self.df = self.df.sort_values(by="date", ascending=False)
287+
if not defer_load:
288+
self.update_options()
225289

226290
def update_options(self):
227291
# Get initial data
@@ -249,8 +313,10 @@ def update_options(self):
249313
except Exception as e:
250314
print(f"Error loading initial data: {e}.")
251315

252-
# Create DataFrame
316+
# Create DataFrame and sort by date if available
253317
self.df = pd.DataFrame(data, columns=["name", "subject_id", "date", "id"])
318+
if not self.df.empty and "date" in self.df.columns:
319+
self.df = self.df.sort_values(by="date", ascending=False)
254320

255321
def get_postprocessed_streams(self, location):
256322
"""Get the postprocessed folders for a given location."""

0 commit comments

Comments
 (0)