diff --git a/AGENTS.md b/AGENTS.md index a6edbc3c..7a152f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -444,5 +444,6 @@ with admin"). Runtime runbook: `docs/identity-management.md`. - **New API endpoint**: Add a route to `main.py` or a new router under `routers/`. - **Configuration**: Add fields to `ServerConfig` in `config.py`. Keep defaults conservative. - **Tests**: Every handler should have a unit test in `tests/handlers/`. Integration tests live in `tests/integration/`. +- **Server process**: Gunicorn runs one `uvicorn_worker.UvicornWorker` (`worker_class` in `main.py` `run()`), from the `uvicorn-worker` package. Do not use the deprecated `uvicorn.workers` module. Run `uv run pytest tests/ -q` to verify before committing. diff --git a/context_intelligence_server/logging_config.py b/context_intelligence_server/logging_config.py index 588c93e6..9fab99b8 100644 --- a/context_intelligence_server/logging_config.py +++ b/context_intelligence_server/logging_config.py @@ -11,7 +11,7 @@ _MAX_BYTES = 10 * 1024 * 1024 _BACKUP_COUNT = 5 -# Third-party loggers (gunicorn + uvicorn.workers.UvicornWorker) that install +# Third-party loggers (gunicorn + uvicorn_worker.UvicornWorker) that install # their OWN handlers and do NOT propagate by default. Left alone, their lines # (startup, access, errors) reach stdout as PLAIN TEXT, which Azure Log Analytics # cannot parse. We strip those handlers and force propagation so every record diff --git a/context_intelligence_server/main.py b/context_intelligence_server/main.py index 0bf0709f..6c2e233f 100644 --- a/context_intelligence_server/main.py +++ b/context_intelligence_server/main.py @@ -1058,7 +1058,7 @@ def load_config(self) -> None: for key, value in { "bind": f"{_settings.server_host}:{_settings.server_port}", "workers": workers, - "worker_class": "uvicorn.workers.UvicornWorker", + "worker_class": "uvicorn_worker.UvicornWorker", "timeout": _settings.gunicorn_worker_timeout, "graceful_timeout": _settings.gunicorn_graceful_timeout, "loglevel": _settings.log_level.lower(), diff --git a/pyproject.toml b/pyproject.toml index 2aaa7caf..b2c4962b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", + "uvicorn-worker>=0.4.0", "gunicorn>=23.0.0", "pydantic-settings>=2.14.2", "neo4j>=5.0", diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py index c5d566dc..62155a46 100644 --- a/tests/test_logging_config.py +++ b/tests/test_logging_config.py @@ -339,7 +339,7 @@ def test_configured_stdout_handler_emits_one_line_json(self) -> None: def test_uvicorn_gunicorn_loggers_route_through_json_formatter(self) -> None: """uvicorn/gunicorn loggers must emit through the root JsonFormatter as one-line JSON. - Production runs gunicorn + uvicorn.workers.UvicornWorker. Those frameworks + Production runs gunicorn + uvicorn_worker.UvicornWorker. Those frameworks install their OWN handlers on the ``uvicorn*`` / ``gunicorn*`` loggers with ``propagate=False`` so their lines (startup, access, errors) reach stdout as PLAIN TEXT, which Azure Log Analytics cannot parse as JSON. diff --git a/tests/test_run_entrypoint.py b/tests/test_run_entrypoint.py index 07042b0b..520f4af3 100644 --- a/tests/test_run_entrypoint.py +++ b/tests/test_run_entrypoint.py @@ -7,7 +7,7 @@ from context_intelligence_server.config import get_settings from context_intelligence_server.main import run from gunicorn.app.base import BaseApplication -from uvicorn.workers import UvicornWorker +from uvicorn_worker import UvicornWorker def test_run_uses_gunicorn_with_settings() -> None: diff --git a/uv.lock b/uv.lock index 06f8fbaf..484b90f6 100644 --- a/uv.lock +++ b/uv.lock @@ -244,6 +244,7 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "pyyaml" }, { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, ] [package.dev-dependencies] @@ -268,6 +269,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, + { name = "uvicorn-worker", specifier = ">=0.4.0" }, ] [package.metadata.requires-dev] @@ -874,6 +876,19 @@ standard = [ { name = "websockets" }, ] +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + [[package]] name = "uvloop" version = "0.22.1"