diff --git a/pyproject.toml b/pyproject.toml index 14bc7d0aa5..7ea8fe0642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,8 @@ remote = [ ] server = [ "fastapi", - "safir >= 3.4.0" + "safir >= 3.4.0", + "pydantic-settings" ] test = [ "pytest >= 3.2", diff --git a/python/lsst/daf/butler/remote_butler/server/_config.py b/python/lsst/daf/butler/remote_butler/server/_config.py index 16537eff09..d15364edbb 100644 --- a/python/lsst/daf/butler/remote_butler/server/_config.py +++ b/python/lsst/daf/butler/remote_butler/server/_config.py @@ -27,12 +27,10 @@ from __future__ import annotations -import dataclasses -import os +from pydantic_settings import BaseSettings, SettingsConfigDict -@dataclasses.dataclass(frozen=True) -class ButlerServerConfig: +class ButlerServerConfig(BaseSettings): """Butler server configuration loaded from environment variables. Notes @@ -43,12 +41,20 @@ class ButlerServerConfig: `ButlerRepoIndex`. """ - static_files_path: str | None + model_config = SettingsConfigDict(env_prefix="daf_butler_server_") + + static_files_path: str | None = None """Absolute path to a directory of files that will be served to end-users as static files from the `configs/` HTTP route. """ + thread_pool_size: int = 40 + """ + Maximum number of concurrent threads that may be spawned by FastAPI for + synchronous handlers. + """ + def load_config() -> ButlerServerConfig: """Read the Butler server configuration from the environment.""" - return ButlerServerConfig(static_files_path=os.environ.get("DAF_BUTLER_SERVER_STATIC_FILES_PATH")) + return ButlerServerConfig() diff --git a/python/lsst/daf/butler/remote_butler/server/_server.py b/python/lsst/daf/butler/remote_butler/server/_server.py index b2e69488d2..8dd1b3a452 100644 --- a/python/lsst/daf/butler/remote_butler/server/_server.py +++ b/python/lsst/daf/butler/remote_butler/server/_server.py @@ -29,8 +29,10 @@ __all__ = ("create_app",) -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +import anyio import safir.dependencies.logger from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles @@ -54,7 +56,17 @@ def create_app() -> FastAPI: """Create a Butler server FastAPI application.""" config = load_config() - app = FastAPI() + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Set the size of the threadpool used internally by FastAPI for + # synchronous handlers. Because most Butler server endpoints are sync, + # this effectively sets the maximum number of concurrent requests that + # can be handled. + # See https://github.com/encode/starlette/issues/1724#issuecomment-1179063924 + anyio.to_thread.current_default_thread_limiter().total_tokens = config.thread_pool_size + yield + + app = FastAPI(lifespan=lifespan) # A single instance of the server can serve data from multiple Butler # repositories. This 'repository' path placeholder is consumed by diff --git a/requirements/main.in b/requirements/main.in index 05bbd51395..6fb1d0b1f8 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -19,3 +19,4 @@ httpx # Butler server dependencies sufficient for unit tests fastapi safir >= 3.4.0 +pydantic-settings