From 58ae41fe0c8431bb2c0336f3f16fe6c382525960 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Wed, 5 Nov 2025 21:41:10 -0500 Subject: [PATCH 01/11] Delay itemsource initialization until first request is served --- cellxgene_gateway/gateway.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index f99d09e..a5a22ae 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -40,6 +40,12 @@ app = Flask(__name__) item_sources = [] default_item_source = None +# Guard for lazy initialization so tests can import this module without +# triggering environment-dependent side effects. initialize_data_sources() +# will set this to True when it has run. +data_sources_initialized = False +data_sources_init_lock = Lock() + def _force_https(app): def wrapper(environ, start_response): @@ -75,6 +81,30 @@ if ( x_prefix=env.proxy_fix_prefix, ) + +# WSGI middleware to ensure data sources are initialized before the first +# WSGI request is handled. This guarantees initialization works under +# Gunicorn/uWSGI (which import the module but don't call main()). The +# initialize_data_sources() function is idempotent-protected by +# data_sources_initialized and data_sources_init_lock. +def _init_on_first_wsgi_request(wsgi_app): + def middleware(environ, start_response): + global data_sources_initialized + if not data_sources_initialized: + with data_sources_init_lock: + if not data_sources_initialized: + initialize_data_sources() + data_sources_initialized = True + return wsgi_app(environ, start_response) + + return middleware + + +# Wrap the WSGI app so Gunicorn/uWSGI will trigger initialization when the +# first request comes in. Tests that need initialization can call +# initialize_data_sources() directly. +app.wsgi_app = _init_on_first_wsgi_request(app.wsgi_app) + cache = BackendCache() @@ -333,12 +363,6 @@ def launch(): app.run(host="0.0.0.0", port=env.gateway_port, debug=False) -# When using servers like Gunicorn or uWSGI, this file is imported rather than run directly. -# As a result, the main() function is never called automatically. -# Therefore, we must initialize the data sources at import time to ensure they are available. -initialize_data_sources() - - def main(): """CLI entry point for Flask development server.""" launch() From f260180a76167042ef53ea9a6f99f50e3b591b92 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Wed, 5 Nov 2025 21:54:18 -0500 Subject: [PATCH 02/11] set default_item_source and start pruner thread --- cellxgene_gateway/gateway.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index a5a22ae..d2c787c 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -92,8 +92,19 @@ def _init_on_first_wsgi_request(wsgi_app): global data_sources_initialized if not data_sources_initialized: with data_sources_init_lock: + if not app.launchtime: + app.launchtime = current_time_stamp() if not data_sources_initialized: initialize_data_sources() + + env.validate() + if not item_sources or not len(item_sources): + raise Exception("No data sources specified for Cellxgene Gateway") + + global default_item_source + if default_item_source is None: + default_item_source = item_sources[0] + data_sources_initialized = True return wsgi_app(environ, start_response) @@ -344,24 +355,20 @@ def ip_address(): resp = make_response(env.ip) return set_no_cache(resp) - -def launch(): - env.validate() - if not item_sources or not len(item_sources): - raise Exception("No data sources specified for Cellxgene Gateway") - - global default_item_source - if default_item_source is None: - default_item_source = item_sources[0] - +def start_pruner_thread(): pruner = PruneProcessCache(cache) background_thread = Thread(target=pruner) background_thread.start() + +def launch(): + start_pruner_thread() + app.launchtime = current_time_stamp() app.run(host="0.0.0.0", port=env.gateway_port, debug=False) +app.launchtime = None def main(): """CLI entry point for Flask development server.""" From a35c6b9b1e42831c1c9f5bfc7d44904f9033e50f Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Wed, 5 Nov 2025 22:02:56 -0500 Subject: [PATCH 03/11] added start scripts for flask, gunicorn and uwsgi --- env_example | 3 ++ run.sh.example | 6 ---- start_flask.sh | 41 +++++++++++++++++++++ start_gunicorn.sh | 91 +++++++++++++++++++++++++++++++++++++++++++++++ start_uwsgi.sh | 88 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 env_example delete mode 100644 run.sh.example create mode 100644 start_flask.sh create mode 100644 start_gunicorn.sh create mode 100644 start_uwsgi.sh diff --git a/env_example b/env_example new file mode 100644 index 0000000..71a6b3b --- /dev/null +++ b/env_example @@ -0,0 +1,3 @@ +export CELLXGENE_LOCATION=$(pwd)/.venv/bin/cellxgene +export CELLXGENE_DATA=../cellxgene_data +export GATEWAY_IP=127.0.0.1 diff --git a/run.sh.example b/run.sh.example deleted file mode 100644 index cd0629c..0000000 --- a/run.sh.example +++ /dev/null @@ -1,6 +0,0 @@ -export CELLXGENE_LOCATION=$(pwd)/.cellxgene-gateway/bin/cellxgene -export CELLXGENE_DATA=../cellxgene_data -export GATEWAY_IP=127.0.0.1 - -#Once these are set, you run like a normal Flask app -cellxgene-gateway diff --git a/start_flask.sh b/start_flask.sh new file mode 100644 index 0000000..5f7d2b4 --- /dev/null +++ b/start_flask.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# start_gunicorn.sh - Start Cellxgene Gateway with Gunicorn +# +# PREREQUISITES: +# - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) +# - Virtual environment activated or .venv present +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_gunicorn.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +cellxgene-gateway \ No newline at end of file diff --git a/start_gunicorn.sh b/start_gunicorn.sh new file mode 100644 index 0000000..994b899 --- /dev/null +++ b/start_gunicorn.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# start_gunicorn.sh - Start Cellxgene Gateway with Gunicorn +# +# PREREQUISITES: +# - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) +# - Virtual environment activated or .venv present +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_gunicorn.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +# Gunicorn configuration +# WARNING: Multi-worker mode has cache synchronization issues (see plans/002-shared-cache-implementation.md) +# Each worker maintains its own in-memory cache, causing 404s for static assets when different +# workers handle requests for the same dataset. Use GUNICORN_WORKERS=1 until shared cache is implemented. +WORKERS=${GUNICORN_WORKERS:-1} +BIND=${GATEWAY_IP:-0.0.0.0}:${GATEWAY_PORT:-5005} +TIMEOUT=${GUNICORN_TIMEOUT:-120} +WORKER_CLASS=${GUNICORN_WORKER_CLASS:-sync} +KEEPALIVE=${GUNICORN_KEEPALIVE:-5} +LOG_LEVEL=${GUNICORN_LOG_LEVEL:-info} + +# Production optimization: enable backed mode to reduce memory usage +export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} + +# Check if gunicorn is installed +if ! command -v gunicorn &> /dev/null; then + echo "Error: gunicorn not found. Install with: pip install gunicorn" + exit 1 +fi + +# Display configuration +echo "Starting Cellxgene Gateway with Gunicorn..." +echo "Configuration:" +echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" +echo " Binding to: $BIND" +echo " Workers: $WORKERS" +echo " Worker class: $WORKER_CLASS" +echo " Timeout: ${TIMEOUT}s" +echo " Keepalive: ${KEEPALIVE}s" +echo " Log level: $LOG_LEVEL" +echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" +echo "" + +cd "$SCRIPT_DIR" + +# Start Gunicorn with optimized settings +# Additional options you can add via environment variables: +# - GUNICORN_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) +# - GUNICORN_MAX_REQUESTS_JITTER: Add randomness to max-requests +gunicorn cellxgene_gateway.gateway:app \ + --workers "$WORKERS" \ + --worker-class "$WORKER_CLASS" \ + --bind "$BIND" \ + --timeout "$TIMEOUT" \ + --keep-alive "$KEEPALIVE" \ + --access-logfile - \ + --error-logfile - \ + --log-level "$LOG_LEVEL" \ + --preload \ + ${GUNICORN_MAX_REQUESTS:+--max-requests "$GUNICORN_MAX_REQUESTS"} \ + ${GUNICORN_MAX_REQUESTS_JITTER:+--max-requests-jitter "$GUNICORN_MAX_REQUESTS_JITTER"} \ + "$@" diff --git a/start_uwsgi.sh b/start_uwsgi.sh new file mode 100644 index 0000000..03d019a --- /dev/null +++ b/start_uwsgi.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# start_uwsgi.sh - Start Cellxgene Gateway with uWSGI +# +# PREREQUISITES: +# - uWSGI installed (pip install uwsgi) +# - Virtual environment activated or .venv present +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_uwsgi.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +# uWSGI configuration +# WARNING: Multi-worker mode has cache synchronization issues (see plans/002-shared-cache-implementation.md) +# Each worker maintains its own in-memory cache, causing 404s for static assets when different +# workers handle requests for the same dataset. Use UWSGI_WORKERS=1 until shared cache is implemented. +WORKERS=${UWSGI_WORKERS:-1} +HOST=${GATEWAY_IP:-0.0.0.0} +PORT=${GATEWAY_PORT:-5005} +TIMEOUT=${UWSGI_TIMEOUT:-120} +THREADS=${UWSGI_THREADS:-1} + +# Production optimization: enable backed mode to reduce memory usage +export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} + +# Check if uwsgi is installed +if ! command -v uwsgi &> /dev/null; then + echo "Error: uwsgi not found. Install with: pip install uwsgi" + exit 1 +fi + +# Display configuration +echo "Starting Cellxgene Gateway with uWSGI..." +echo "Configuration:" +echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" +echo " Binding to: $HOST:$PORT" +echo " Workers: $WORKERS" +echo " Threads: $THREADS" +echo " Timeout: ${TIMEOUT}s" +echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" +echo "" + +cd "$SCRIPT_DIR" + +# Start uWSGI with optimized settings +# Additional options you can add via environment variables: +# - UWSGI_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) +exec uwsgi \ + --http "$HOST:$PORT" \ + --module cellxgene_gateway.gateway:app \ + --workers "$WORKERS" \ + --threads "$THREADS" \ + --harakiri "$TIMEOUT" \ + --master \ + --enable-threads \ + --single-interpreter \ + --need-app \ + --die-on-term \ + --log-x-forwarded-for \ + ${UWSGI_MAX_REQUESTS:+--max-requests "$UWSGI_MAX_REQUESTS"} \ + "$@" From 1d1d8b4e59a5d20839ad0deeb0120e16fc5ba9e5 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 10:57:50 -0500 Subject: [PATCH 04/11] Address linter warnings --- cellxgene_gateway/gateway.py | 37 ++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index d2c787c..4bec20f 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -92,8 +92,9 @@ def _init_on_first_wsgi_request(wsgi_app): global data_sources_initialized if not data_sources_initialized: with data_sources_init_lock: - if not app.launchtime: - app.launchtime = current_time_stamp() + if not app.extensions.get("cellxgene_gateway", {}).get("launchtime"): + app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = current_time_stamp() + if not data_sources_initialized: initialize_data_sources() @@ -248,7 +249,7 @@ entry_lock = Lock() def matching_source(source_name): - if source_name is None: + if source_name is None and default_item_source is not None: source_name = default_item_source.name matching = [i for i in item_sources if i.name == source_name] if len(matching) != 1: @@ -295,6 +296,11 @@ def do_view(path, source_name=None): raise CellxgeneException("User not authorized to access this data", 403) elif match.status == CacheEntryStatus.error: raise ProcessException.from_cache_entry(match) + else: + raise CellxgeneException( + f"Unexpected cache entry status {match.status} for key {match.key.descriptor}", + 500, + ) @app.route("/cache_status", methods=["GET"]) @@ -310,7 +316,7 @@ def do_GET_status(): def do_GET_status_json(): return json.dumps( { - "launchtime": app.launchtime, + "launchtime": app.extensions.get("cellxgene_gateway", {}).get("launchtime"), "entry_list": [ { "dataset": entry.key.dataset, @@ -324,12 +330,21 @@ def do_GET_status_json(): } ) +def get_cache_key(path): + if request.args.get("source_name"): + source_name = request.args.get("source_name") + elif default_item_source: + source_name = default_item_source.name + else: + source_name = None + source = matching_source(source_name) + key = CacheKey.for_lookup(source, source.lookup(path)) + return key + @app.route("/relaunch/", methods=["GET"]) def do_relaunch(path): - source_name = request.args.get("source_name") or default_item_source.name - source = matching_source(source_name) - key = CacheKey.for_lookup(source, source.lookup(path)) + key = get_cache_key(path) match = cache.check_entry(key) if not match is None: match.terminate() @@ -341,9 +356,7 @@ def do_relaunch(path): @app.route("/terminate/", methods=["GET"]) def do_terminate(path): - source_name = request.args.get("source_name") or default_item_source.name - source = matching_source(source_name) - key = CacheKey.for_lookup(source, source.lookup(path)) + key = get_cache_key(path) match = cache.check_entry(key) if not match is None: match.terminate() @@ -365,10 +378,10 @@ def start_pruner_thread(): def launch(): start_pruner_thread() - app.launchtime = current_time_stamp() + app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = current_time_stamp() app.run(host="0.0.0.0", port=env.gateway_port, debug=False) -app.launchtime = None +app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = None def main(): """CLI entry point for Flask development server.""" From c8056991b0a896422fd986daec6babf457ad61fd Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 10:58:03 -0500 Subject: [PATCH 05/11] simplify tests --- tests/items/s3/test_s3item_source.py | 12 ++-------- tests/test_cache_entry.py | 14 ++---------- tests/test_filecrawl.py | 34 +--------------------------- 3 files changed, 5 insertions(+), 55 deletions(-) diff --git a/tests/items/s3/test_s3item_source.py b/tests/items/s3/test_s3item_source.py index 1ff675d..99f615d 100644 --- a/tests/items/s3/test_s3item_source.py +++ b/tests/items/s3/test_s3item_source.py @@ -8,24 +8,16 @@ from unittest.mock import MagicMock, Mock, patch from cellxgene_gateway.items.item import ItemType from cellxgene_gateway.items.s3.s3item import S3Item from cellxgene_gateway.items.s3.s3item_source import S3ItemSource +from cellxgene_gateway.gateway import app class TestScanDirectory(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app self.app = app def tearDown(self): - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) + pass @patch("s3fs.S3FileSystem") def test_GIVEN_invalid_bucket_THEN_throws_error(self, s3func): diff --git a/tests/test_cache_entry.py b/tests/test_cache_entry.py index 3c1054f..f98b1dd 100644 --- a/tests/test_cache_entry.py +++ b/tests/test_cache_entry.py @@ -10,6 +10,7 @@ from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.items.item import ItemType from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem_source import FileItemSource +from cellxgene_gateway.gateway import app key = CacheKey( FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad), @@ -19,12 +20,6 @@ key = CacheKey( class TestRenderEntry(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() @@ -32,12 +27,7 @@ class TestRenderEntry(unittest.TestCase): def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) - + def test_GIVEN_key_and_port_THEN_returns_loading_CacheEntry(self): entry = CacheEntry.for_key("some-key", 1) self.assertEqual(entry.status, CacheEntryStatus.loading) diff --git a/tests/test_filecrawl.py b/tests/test_filecrawl.py index d42d783..b77e9bd 100644 --- a/tests/test_filecrawl.py +++ b/tests/test_filecrawl.py @@ -13,6 +13,7 @@ from cellxgene_gateway.filecrawl import ( from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem_source import FileItemSource from cellxgene_gateway.items.item import ItemTree, ItemType +from cellxgene_gateway.gateway import app source = FileItemSource("/tmp") @@ -29,23 +30,12 @@ def make_entry(subpath="somepath", annotations=None): class TestRenderEntry(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self): entry = make_entry(subpath="/somepath/") @@ -71,23 +61,12 @@ class TestRenderEntry(unittest.TestCase): class TestRenderAnnotation(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) @patch("cellxgene_gateway.filecrawl.enable_annotations", new=True) def test_GIVEN_no_annotation_THEN_new_alone(self): @@ -145,23 +124,12 @@ class TestRenderItemSource(unittest.TestCase): class TestRenderItemTree(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) @patch("cellxgene_gateway.items.file.fileitem_source.FileItemSource") def test_GIVEN_deep_nested_dirs_THEN_includes_dirs_in_output(self, item_source): From 8b4565e7457c5e160a255ed0942266c570c6fb72 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 16:00:57 -0500 Subject: [PATCH 06/11] added status.json test --- tests/test_gateway_status_json.py | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/test_gateway_status_json.py diff --git a/tests/test_gateway_status_json.py b/tests/test_gateway_status_json.py new file mode 100644 index 0000000..066cdc1 --- /dev/null +++ b/tests/test_gateway_status_json.py @@ -0,0 +1,51 @@ +import json +import unittest +from types import SimpleNamespace + +from cellxgene_gateway.gateway import do_GET_status_json, app, cache +from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus + + +class TestGatewayStatusJson(unittest.TestCase): + def test_do_GET_status_json_returns_expected_structure(self): + # Create a minimal fake key with required attributes + h5ad_item = SimpleNamespace(descriptor="somedir/dataset.h5ad") + key = SimpleNamespace(h5ad_item=h5ad_item, annotation_descriptor="somedir/dataset_annotations/foo.csv") + + # Create a CacheEntry with known launchtime/timestamp/status + entry = CacheEntry( + None, + key, + 8000, + 111, + 222, + CacheEntryStatus.loaded, + None, + None, + None, + None, + ) + + # Install into the gateway cache and set app launchtime + cache.entry_list = [entry] + app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = "LAUNCH_TIME" + + rv = do_GET_status_json() + + data = json.loads(rv) + # top-level launchtime comes from app.extensions + self.assertEqual("LAUNCH_TIME", data["launchtime"]) + + self.assertIn("entry_list", data) + self.assertEqual(1, len(data["entry_list"])) + + e = data["entry_list"][0] + self.assertEqual("somedir/dataset.h5ad", e["dataset"]) + self.assertEqual("somedir/dataset_annotations/foo.csv", e["annotation_file"]) + self.assertEqual("loaded", e["status"]) + self.assertEqual(111, e["launchtime"]) + self.assertEqual(222, e["last_access"]) + + +if __name__ == "__main__": + unittest.main() From 4df58f9ceb02eaaab2cbbfeb6725161c56286344 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 11:16:56 -0500 Subject: [PATCH 07/11] fixe bug in status.json --- cellxgene_gateway/gateway.py | 20 ++++++++---- start_uwsgi.sh | 62 ++++++++++++++++++------------------ 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index 4bec20f..1c634ad 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -314,17 +314,23 @@ def do_GET_status(): @app.route("/cache_status.json", methods=["GET"]) def do_GET_status_json(): + def map_entry(entry): + dataset = entry.key.h5ad_item.descriptor + annotation_file = entry.key.annotation_descriptor + return { + "dataset": dataset, + "annotation_file": annotation_file, + "launchtime": entry.launchtime, + "last_access": entry.timestamp, + "status": entry.status.name, + } + + return json.dumps( { "launchtime": app.extensions.get("cellxgene_gateway", {}).get("launchtime"), "entry_list": [ - { - "dataset": entry.key.dataset, - "annotation_file": entry.key.annotation_file, - "launchtime": entry.launchtime, - "last_access": entry.timestamp, - "status": entry.status, - } + map_entry(entry) for entry in cache.entry_list ], } diff --git a/start_uwsgi.sh b/start_uwsgi.sh index 03d019a..54966f4 100644 --- a/start_uwsgi.sh +++ b/start_uwsgi.sh @@ -53,36 +53,36 @@ export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} # Check if uwsgi is installed if ! command -v uwsgi &> /dev/null; then echo "Error: uwsgi not found. Install with: pip install uwsgi" - exit 1 +else + # Display configuration + echo "Starting Cellxgene Gateway with uWSGI..." + echo "Configuration:" + echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" + echo " Binding to: $HOST:$PORT" + echo " Workers: $WORKERS" + echo " Threads: $THREADS" + echo " Timeout: ${TIMEOUT}s" + echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" + echo "" + + cd "$SCRIPT_DIR" + + # Start uWSGI with optimized settings + # Additional options you can add via environment variables: + # - UWSGI_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) + uwsgi \ + --http "$HOST:$PORT" \ + --module cellxgene_gateway.gateway:app \ + --workers "$WORKERS" \ + --threads "$THREADS" \ + --harakiri "$TIMEOUT" \ + --master \ + --enable-threads \ + --single-interpreter \ + --need-app \ + --die-on-term \ + --log-x-forwarded-for \ + ${UWSGI_MAX_REQUESTS:+--max-requests "$UWSGI_MAX_REQUESTS"} \ + "$@" fi -# Display configuration -echo "Starting Cellxgene Gateway with uWSGI..." -echo "Configuration:" -echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" -echo " Binding to: $HOST:$PORT" -echo " Workers: $WORKERS" -echo " Threads: $THREADS" -echo " Timeout: ${TIMEOUT}s" -echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" -echo "" - -cd "$SCRIPT_DIR" - -# Start uWSGI with optimized settings -# Additional options you can add via environment variables: -# - UWSGI_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) -exec uwsgi \ - --http "$HOST:$PORT" \ - --module cellxgene_gateway.gateway:app \ - --workers "$WORKERS" \ - --threads "$THREADS" \ - --harakiri "$TIMEOUT" \ - --master \ - --enable-threads \ - --single-interpreter \ - --need-app \ - --die-on-term \ - --log-x-forwarded-for \ - ${UWSGI_MAX_REQUESTS:+--max-requests "$UWSGI_MAX_REQUESTS"} \ - "$@" From 55b268125ff58c7a959b9dce25f23cdbf511af64 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 17:23:47 -0500 Subject: [PATCH 08/11] blacken --- cellxgene_gateway/gateway.py | 34 ++++++++++++++++++------------- tests/test_cache_entry.py | 2 +- tests/test_gateway_status_json.py | 5 ++++- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index 1c634ad..fd7bf45 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -93,14 +93,18 @@ def _init_on_first_wsgi_request(wsgi_app): if not data_sources_initialized: with data_sources_init_lock: if not app.extensions.get("cellxgene_gateway", {}).get("launchtime"): - app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = current_time_stamp() + app.extensions.setdefault("cellxgene_gateway", {})[ + "launchtime" + ] = current_time_stamp() if not data_sources_initialized: initialize_data_sources() env.validate() if not item_sources or not len(item_sources): - raise Exception("No data sources specified for Cellxgene Gateway") + raise Exception( + "No data sources specified for Cellxgene Gateway" + ) global default_item_source if default_item_source is None: @@ -318,24 +322,21 @@ def do_GET_status_json(): dataset = entry.key.h5ad_item.descriptor annotation_file = entry.key.annotation_descriptor return { - "dataset": dataset, - "annotation_file": annotation_file, - "launchtime": entry.launchtime, - "last_access": entry.timestamp, - "status": entry.status.name, - } - + "dataset": dataset, + "annotation_file": annotation_file, + "launchtime": entry.launchtime, + "last_access": entry.timestamp, + "status": entry.status.name, + } return json.dumps( { "launchtime": app.extensions.get("cellxgene_gateway", {}).get("launchtime"), - "entry_list": [ - map_entry(entry) - for entry in cache.entry_list - ], + "entry_list": [map_entry(entry) for entry in cache.entry_list], } ) + def get_cache_key(path): if request.args.get("source_name"): source_name = request.args.get("source_name") @@ -374,6 +375,7 @@ def ip_address(): resp = make_response(env.ip) return set_no_cache(resp) + def start_pruner_thread(): pruner = PruneProcessCache(cache) @@ -384,11 +386,15 @@ def start_pruner_thread(): def launch(): start_pruner_thread() - app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = current_time_stamp() + app.extensions.setdefault("cellxgene_gateway", {})[ + "launchtime" + ] = current_time_stamp() app.run(host="0.0.0.0", port=env.gateway_port, debug=False) + app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = None + def main(): """CLI entry point for Flask development server.""" launch() diff --git a/tests/test_cache_entry.py b/tests/test_cache_entry.py index f98b1dd..30463e7 100644 --- a/tests/test_cache_entry.py +++ b/tests/test_cache_entry.py @@ -27,7 +27,7 @@ class TestRenderEntry(unittest.TestCase): def tearDown(self): self.app_context.pop() - + def test_GIVEN_key_and_port_THEN_returns_loading_CacheEntry(self): entry = CacheEntry.for_key("some-key", 1) self.assertEqual(entry.status, CacheEntryStatus.loading) diff --git a/tests/test_gateway_status_json.py b/tests/test_gateway_status_json.py index 066cdc1..fba4888 100644 --- a/tests/test_gateway_status_json.py +++ b/tests/test_gateway_status_json.py @@ -10,7 +10,10 @@ class TestGatewayStatusJson(unittest.TestCase): def test_do_GET_status_json_returns_expected_structure(self): # Create a minimal fake key with required attributes h5ad_item = SimpleNamespace(descriptor="somedir/dataset.h5ad") - key = SimpleNamespace(h5ad_item=h5ad_item, annotation_descriptor="somedir/dataset_annotations/foo.csv") + key = SimpleNamespace( + h5ad_item=h5ad_item, + annotation_descriptor="somedir/dataset_annotations/foo.csv", + ) # Create a CacheEntry with known launchtime/timestamp/status entry = CacheEntry( From cd9c0a3671d04f78b550a38a98df4932a72481f9 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 17:38:14 -0500 Subject: [PATCH 09/11] upload coverage reports as artifacts --- .github/workflows/pr-checks.yaml | 44 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 8a7c92b..f9b07f3 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -52,17 +52,39 @@ jobs: eval "$(conda shell.bash hook)" conda activate cellxgene-gateway coverage report --fail-under 41 + coverage report > coverage.txt + coverage html -i coverage xml -i - - name: "Upload coverage to Codecov" - if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@v1 + - name: Upload coverage HTML report + uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage.xml - flags: unittests - env_vars: OS,PYTHON - name: codecov-umbrella - fail_ci_if_error: true - path_to_write_report: ./codecov_report.txt - verbose: true + name: coverage-html + path: htmlcov/ + retention-days: 30 + + - name: Upload coverage xml + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + retention-days: 30 + + - name: Upload coverage summary + uses: actions/upload-artifact@v4 + with: + name: coverage-summary + path: coverage.txt + retention-days: 30 + # - name: "Upload coverage to Codecov" + # if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} + # uses: codecov/codecov-action@v1 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # files: ./coverage.xml + # flags: unittests + # env_vars: OS,PYTHON + # name: codecov-umbrella + # fail_ci_if_error: true + # path_to_write_report: ./codecov_report.txt + # verbose: true From 79fef570102b8fd522fe927b8a32a40207949f91 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 17:51:06 -0500 Subject: [PATCH 10/11] updated start scripts to run in subshells --- start_flask.sh | 2 +- start_gunicorn.sh | 2 +- start_uwsgi.sh | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) mode change 100644 => 100755 start_flask.sh mode change 100644 => 100755 start_gunicorn.sh mode change 100644 => 100755 start_uwsgi.sh diff --git a/start_flask.sh b/start_flask.sh old mode 100644 new mode 100755 index 5f7d2b4..d28c1df --- a/start_flask.sh +++ b/start_flask.sh @@ -38,4 +38,4 @@ if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then exit 1 fi -cellxgene-gateway \ No newline at end of file +exec cellxgene-gateway \ No newline at end of file diff --git a/start_gunicorn.sh b/start_gunicorn.sh old mode 100644 new mode 100755 index 994b899..4e6da8f --- a/start_gunicorn.sh +++ b/start_gunicorn.sh @@ -76,7 +76,7 @@ cd "$SCRIPT_DIR" # Additional options you can add via environment variables: # - GUNICORN_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) # - GUNICORN_MAX_REQUESTS_JITTER: Add randomness to max-requests -gunicorn cellxgene_gateway.gateway:app \ +exec gunicorn cellxgene_gateway.gateway:app \ --workers "$WORKERS" \ --worker-class "$WORKER_CLASS" \ --bind "$BIND" \ diff --git a/start_uwsgi.sh b/start_uwsgi.sh old mode 100644 new mode 100755 index 54966f4..7cfaa58 --- a/start_uwsgi.sh +++ b/start_uwsgi.sh @@ -53,6 +53,7 @@ export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} # Check if uwsgi is installed if ! command -v uwsgi &> /dev/null; then echo "Error: uwsgi not found. Install with: pip install uwsgi" + exit 1 else # Display configuration echo "Starting Cellxgene Gateway with uWSGI..." @@ -70,7 +71,7 @@ else # Start uWSGI with optimized settings # Additional options you can add via environment variables: # - UWSGI_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) - uwsgi \ + exec uwsgi \ --http "$HOST:$PORT" \ --module cellxgene_gateway.gateway:app \ --workers "$WORKERS" \ From d7478601183ac308491b560d24a7e6098801f0b7 Mon Sep 17 00:00:00 2001 From: Alok Saldanha Date: Sat, 8 Nov 2025 18:40:33 -0500 Subject: [PATCH 11/11] made pruner a daemon thread --- README.md | 15 +++++++++++++++ cellxgene_gateway/gateway.py | 6 ++++-- start_flask.sh | 2 +- start_gunicorn.sh | 2 +- start_uwsgi.sh | 2 +- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index acb1528..48774ae 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,21 @@ docker run -it --rm \ -p 8080:8080 \ cellxgene-gateway ``` +## Running cellxgene gateway with start scripts + +For your convenience, we provide start scripts for flask, gunicorn and uwsgi. + +First, set up a .env +```bash +cp env_example .env +# edit .env +open .env +``` + +Then run the scripts in a subshell +```bash +( ./start_flask.sh ) +``` # Customization diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index fd7bf45..1e6d00a 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -378,8 +378,10 @@ def ip_address(): def start_pruner_thread(): pruner = PruneProcessCache(cache) - - background_thread = Thread(target=pruner) + # Run the pruner as a daemon thread so it won't block interpreter + # shutdown (for example when Ctrl-C is used in the main thread). + # This avoids "Exception ignored in: " at exit. + background_thread = Thread(target=pruner, daemon=True) background_thread.start() diff --git a/start_flask.sh b/start_flask.sh index d28c1df..14387dd 100755 --- a/start_flask.sh +++ b/start_flask.sh @@ -4,7 +4,7 @@ # # PREREQUISITES: # - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) -# - Virtual environment activated or .venv present +# - Virtual environment activated # - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) # # USAGE: diff --git a/start_gunicorn.sh b/start_gunicorn.sh index 4e6da8f..eeef9bb 100755 --- a/start_gunicorn.sh +++ b/start_gunicorn.sh @@ -4,7 +4,7 @@ # # PREREQUISITES: # - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) -# - Virtual environment activated or .venv present +# - Virtual environment activated # - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) # # USAGE: diff --git a/start_uwsgi.sh b/start_uwsgi.sh index 7cfaa58..3482085 100755 --- a/start_uwsgi.sh +++ b/start_uwsgi.sh @@ -4,7 +4,7 @@ # # PREREQUISITES: # - uWSGI installed (pip install uwsgi) -# - Virtual environment activated or .venv present +# - Virtual environment activated # - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) # # USAGE: