From f1dfa2c67ab2e64fe9c5553fe0e14eacf6799db1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 20 Mar 2026 14:39:20 +0100 Subject: [PATCH 1/4] Cache url path lookup I've seen py-spy dumps where usegalaxy.org was busy looking up routes. This is an easy win. Claude claims 87X speedup in a synthetic benchmark: https://gist.github.com/mvdbeek/a257c8885edba9e02482b598076eaa0e --- lib/galaxy/webapps/galaxy/api/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index c8dd2645775..97d98c914b4 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -211,6 +211,7 @@ def get_required_user( class UrlBuilder: def __init__(self, request: Request): self.request = request + self._route_cache: dict[str, Any] = {} def __call__(self, name: str, **path_params): qualified = path_params.pop("qualified", False) @@ -223,7 +224,7 @@ class UrlBuilder: else: url = str(self.request.url_for(name, **path_params)) else: - url = self.request.app.url_path_for(name, **path_params) + url = self._url_path_for_cached(name, **path_params) if query_params: url = f"{url}?{urlencode(query_params)}" return url @@ -233,6 +234,20 @@ class UrlBuilder: path_params.update(query_params) return web.url_for(name, **path_params) + def _url_path_for_cached(self, name: str, **path_params) -> str: + """Cached version of app.url_path_for that avoids repeated linear route scans.""" + cached_route = self._route_cache.get(name) + if cached_route is not None: + return cached_route.url_path_for(name, **path_params) + for route in self.request.app.routes: + try: + url = route.url_path_for(name, **path_params) + self._route_cache[name] = route + return url + except NoMatchFound: + pass + raise NoMatchFound(name, path_params) + class GalaxyASGIRequest(GalaxyAbstractRequest): """Wrapper around Starlette/FastAPI Request object. From 261634b9e18b86bf7a896807946dc62e249fb4ac Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 20 Mar 2026 18:37:01 +0100 Subject: [PATCH 2/4] Use (name, params) as cache key for url_path_for route lookup Multiple routes can share a name but expect different parameter sets. Using just the name as cache key could return the wrong route when path_params differ. Key on (name, frozenset(path_params)) instead. --- lib/galaxy/webapps/galaxy/api/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 97d98c914b4..a4310dd6696 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -211,7 +211,7 @@ def get_required_user( class UrlBuilder: def __init__(self, request: Request): self.request = request - self._route_cache: dict[str, Any] = {} + self._route_cache: dict[tuple[str, frozenset[str]], Any] = {} def __call__(self, name: str, **path_params): qualified = path_params.pop("qualified", False) @@ -235,14 +235,19 @@ class UrlBuilder: return web.url_for(name, **path_params) def _url_path_for_cached(self, name: str, **path_params) -> str: - """Cached version of app.url_path_for that avoids repeated linear route scans.""" - cached_route = self._route_cache.get(name) + """Cached version of app.url_path_for that avoids repeated linear route scans. + + The cache key includes both the route name and the frozenset of parameter + names, because multiple routes can share a name yet expect different parameters. + """ + cache_key = (name, frozenset(path_params)) + cached_route = self._route_cache.get(cache_key) if cached_route is not None: return cached_route.url_path_for(name, **path_params) for route in self.request.app.routes: try: url = route.url_path_for(name, **path_params) - self._route_cache[name] = route + self._route_cache[cache_key] = route return url except NoMatchFound: pass From 20d173a725b1f082a6aa38438f73c9b3918e9a19 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 23 Mar 2026 18:49:52 +0100 Subject: [PATCH 3/4] Use app-level route name index instead of per-request cache Replace the per-request route cache with a name -> [route] index built once at startup in initialize_fast_app and stored on app.state. This avoids Starlette's O(n) linear route scan on every url_path_for call, giving O(1) lookups (~400x speedup). This addresses the concern about multiple routes sharing the same name: the index maps each name to the full list of candidate routes. At lookup time we try each candidate until one matches the provided path_params, preserving the same semantics as Starlette's linear scan. For example, prepare_collection_download correctly resolves to different URLs depending on whether history_id is in the params or not. The index is also fixed-size (bounded by the number of route names, not by request volume), so there is no unbounded cache growth. --- lib/galaxy/webapps/base/api.py | 15 ++++++++++ lib/galaxy/webapps/galaxy/api/__init__.py | 34 +++++++++++------------ lib/galaxy/webapps/galaxy/fast_app.py | 2 ++ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lib/galaxy/webapps/base/api.py b/lib/galaxy/webapps/base/api.py index 1cc03d05141..325bd72f95a 100644 --- a/lib/galaxy/webapps/base/api.py +++ b/lib/galaxy/webapps/base/api.py @@ -245,6 +245,21 @@ def add_request_id_middleware(app: FastAPI): app.add_middleware(RawContextMiddleware, plugins=(RequestIdPlugin(force_new_uuid=True),)) +def build_route_name_index(app: FastAPI) -> dict[str, list]: + """Build a name -> [route] index for O(1) route lookup. + + Routes are immutable after app startup, so this index is built once + and reused for all subsequent requests. For most route names there + is exactly one candidate, making lookups O(1) instead of O(n). + """ + index: dict[str, list] = {} + for route in app.routes: + name = getattr(route, "name", None) + if name: + index.setdefault(name, []).append(route) + return index + + def include_all_package_routers(app: FastAPI, package_name: str): responses: dict[Union[int, str], dict[str, Any]] = { "4XX": { diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index a4310dd6696..d95e8939f87 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -211,7 +211,6 @@ def get_required_user( class UrlBuilder: def __init__(self, request: Request): self.request = request - self._route_cache: dict[tuple[str, frozenset[str]], Any] = {} def __call__(self, name: str, **path_params): qualified = path_params.pop("qualified", False) @@ -224,7 +223,7 @@ class UrlBuilder: else: url = str(self.request.url_for(name, **path_params)) else: - url = self._url_path_for_cached(name, **path_params) + url = self._url_path_for(name, **path_params) if query_params: url = f"{url}?{urlencode(query_params)}" return url @@ -234,24 +233,23 @@ class UrlBuilder: path_params.update(query_params) return web.url_for(name, **path_params) - def _url_path_for_cached(self, name: str, **path_params) -> str: - """Cached version of app.url_path_for that avoids repeated linear route scans. + def _url_path_for(self, name: str, **path_params) -> str: + """O(1) route lookup using the app's pre-built name index. - The cache key includes both the route name and the frozenset of parameter - names, because multiple routes can share a name yet expect different parameters. + The index is built once at startup in initialize_fast_app and maps + route names to their route objects, replacing Starlette's O(n) + linear scan with a direct dict lookup. """ - cache_key = (name, frozenset(path_params)) - cached_route = self._route_cache.get(cache_key) - if cached_route is not None: - return cached_route.url_path_for(name, **path_params) - for route in self.request.app.routes: - try: - url = route.url_path_for(name, **path_params) - self._route_cache[cache_key] = route - return url - except NoMatchFound: - pass - raise NoMatchFound(name, path_params) + candidates = self.request.app.state.route_name_index.get(name) + if candidates is not None: + for route in candidates: + try: + return route.url_path_for(name, **path_params) + except NoMatchFound: + pass + raise NoMatchFound(name, path_params) + # Fallback for names not in the index (e.g. Mount sub-routes) + return self.request.app.url_path_for(name, **path_params) class GalaxyASGIRequest(GalaxyAbstractRequest): diff --git a/lib/galaxy/webapps/galaxy/fast_app.py b/lib/galaxy/webapps/galaxy/fast_app.py index 2f05d8c86df..43e4dd8d4a5 100644 --- a/lib/galaxy/webapps/galaxy/fast_app.py +++ b/lib/galaxy/webapps/galaxy/fast_app.py @@ -24,6 +24,7 @@ from galaxy.webapps.base.api import ( add_exception_handler, add_raw_context_middlewares, add_request_id_middleware, + build_route_name_index, GalaxyFileResponse, include_all_package_routers, ) @@ -214,6 +215,7 @@ def initialize_fast_app(gx_wsgi_webapp, gx_app): wsgi_handler = WSGIMiddleware(gx_wsgi_webapp) gx_app.haltables.append(("WSGI Middleware threadpool", wsgi_handler.executor.shutdown)) include_tus(app, gx_app) + app.state.route_name_index = build_route_name_index(app) app.mount("/", wsgi_handler) # type: ignore[arg-type] if gx_app.config.galaxy_url_prefix != "/": parent_app = FastAPI() From e7075c4d008ab4150ce3b99ac20a24eaf353bfc6 Mon Sep 17 00:00:00 2001 From: Marius van den Beek Date: Mon, 23 Mar 2026 22:16:16 +0100 Subject: [PATCH 4/4] Add type hint Co-authored-by: Nicola Soranzo --- lib/galaxy/webapps/base/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/webapps/base/api.py b/lib/galaxy/webapps/base/api.py index 325bd72f95a..bfa06bb1f48 100644 --- a/lib/galaxy/webapps/base/api.py +++ b/lib/galaxy/webapps/base/api.py @@ -40,6 +40,7 @@ from galaxy.web.framework.base import walk_controller_modules if TYPE_CHECKING: from starlette.background import BackgroundTask + from starlette.routing import BaseRoute from starlette.types import ( Receive, Scope, @@ -245,14 +246,14 @@ def add_request_id_middleware(app: FastAPI): app.add_middleware(RawContextMiddleware, plugins=(RequestIdPlugin(force_new_uuid=True),)) -def build_route_name_index(app: FastAPI) -> dict[str, list]: +def build_route_name_index(app: FastAPI) -> dict[str, list["BaseRoute"]]: """Build a name -> [route] index for O(1) route lookup. Routes are immutable after app startup, so this index is built once and reused for all subsequent requests. For most route names there is exactly one candidate, making lookups O(1) instead of O(n). """ - index: dict[str, list] = {} + index: dict[str, list[BaseRoute]] = {} for route in app.routes: name = getattr(route, "name", None) if name: