diff --git a/apps/python-flask/Dockerfile b/apps/python-flask/Dockerfile index a5c61dd..443c516 100644 --- a/apps/python-flask/Dockerfile +++ b/apps/python-flask/Dockerfile @@ -16,4 +16,5 @@ COPY --from=dependencies /opt/venv /opt/venv COPY benchmark_api ./benchmark_api USER 10001:10001 EXPOSE 8080 -ENTRYPOINT ["python", "-m", "benchmark_api.server"] +ENTRYPOINT ["python", "-m", "gunicorn.app.wsgiapp"] +CMD ["--bind", "0.0.0.0:8080", "--workers", "1", "--worker-class", "gthread", "--threads", "1", "--keep-alive", "5", "--timeout", "30", "--graceful-timeout", "5", "--worker-tmp-dir", "/dev/shm", "benchmark_api.server:create_server_app()"] diff --git a/apps/python-flask/benchmark_api/server.py b/apps/python-flask/benchmark_api/server.py index 9a264a1..2b018f6 100644 --- a/apps/python-flask/benchmark_api/server.py +++ b/apps/python-flask/benchmark_api/server.py @@ -1,45 +1,13 @@ -"""Single-process Waitress entry point with explicit graceful resource cleanup.""" +"""Gunicorn application factory with worker-local database resources.""" -import signal - -from waitress.server import create_server +import atexit from benchmark_api.app import create_app from benchmark_api.database import close_pool, open_pool -def serve() -> None: +def create_server_app(): + """Create one worker-local Flask app and close its pool when the worker exits.""" pool = open_pool() - server = None - previous = {} - try: - app = create_app(pool) - server = create_server( - app, - host="0.0.0.0", - port=8080, - threads=1, - channel_timeout=30, - cleanup_interval=10, - asyncore_use_poll=True, - ) - - def stop(_signum, _frame): - server.close() - - for sig in (signal.SIGINT, signal.SIGTERM): - previous[sig] = signal.signal(sig, stop) - server.run() - finally: - for sig, handler in previous.items(): - signal.signal(sig, handler) - if server is not None: - server.close() - dispatcher = getattr(server, "task_dispatcher", None) - if dispatcher is not None: - dispatcher.shutdown(cancel_pending=False, timeout=5) - close_pool(pool) - - -if __name__ == "__main__": - serve() + atexit.register(close_pool, pool) + return create_app(pool) diff --git a/apps/python-flask/requirements.in b/apps/python-flask/requirements.in index 691945c..a3e98fe 100644 --- a/apps/python-flask/requirements.in +++ b/apps/python-flask/requirements.in @@ -1,5 +1,5 @@ flask==3.1.3 -waitress==3.0.2 +gunicorn==26.0.0 psycopg==3.3.5 psycopg-binary==3.3.5 psycopg-pool==3.3.1 diff --git a/apps/python-flask/requirements.lock b/apps/python-flask/requirements.lock index b5be15f..46f0d5f 100644 --- a/apps/python-flask/requirements.lock +++ b/apps/python-flask/requirements.lock @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --generate-hashes --no-index --output-file=apps/python-flask/requirements.lock --strip-extras apps/python-flask/requirements.in +# pip-compile --generate-hashes --no-index --output-file=apps/python-flask/requirements.lock apps/python-flask/requirements.in # blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ @@ -16,6 +16,10 @@ flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c # via -r apps/python-flask/requirements.in +gunicorn==26.0.0 \ + --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \ + --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf + # via -r apps/python-flask/requirements.in itsdangerous==2.2.0 \ --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 @@ -118,6 +122,10 @@ markupsafe==3.0.3 \ # flask # jinja2 # werkzeug +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via gunicorn psycopg==3.3.5 \ --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e \ --hash=sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4 @@ -187,10 +195,6 @@ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via psycopg-pool -waitress==3.0.2 \ - --hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \ - --hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e - # via -r apps/python-flask/requirements.in werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 diff --git a/apps/python-flask/tests/test_server.py b/apps/python-flask/tests/test_server.py index ad148a4..a1c3a79 100644 --- a/apps/python-flask/tests/test_server.py +++ b/apps/python-flask/tests/test_server.py @@ -1,30 +1,25 @@ -"""Waitress lifecycle tests for the approved one-process, one-thread profile.""" +"""Gunicorn application-factory lifecycle tests.""" from unittest.mock import MagicMock from benchmark_api import server -def test_server_uses_one_waitress_thread_and_closes_resources(monkeypatch): +def test_factory_opens_worker_local_pool_and_registers_cleanup(monkeypatch): pool = object() - waitress = MagicMock() - waitress.run.return_value = None - waitress.task_dispatcher = MagicMock() - create_server = MagicMock(return_value=waitress) + app = object() + open_pool = MagicMock(return_value=pool) close_pool = MagicMock() - monkeypatch.setattr(server, "open_pool", MagicMock(return_value=pool)) + create_app = MagicMock(return_value=app) + register = MagicMock() + + monkeypatch.setattr(server, "open_pool", open_pool) monkeypatch.setattr(server, "close_pool", close_pool) - monkeypatch.setattr(server, "create_server", create_server) - monkeypatch.setattr(server, "create_app", MagicMock(return_value=object())) - monkeypatch.setattr(server.signal, "signal", MagicMock(return_value=server.signal.SIG_DFL)) + monkeypatch.setattr(server, "create_app", create_app) + monkeypatch.setattr(server.atexit, "register", register) - server.serve() + assert server.create_server_app() is app - kwargs = create_server.call_args.kwargs - assert kwargs["host"] == "0.0.0.0" - assert kwargs["port"] == 8080 - assert kwargs["threads"] == 1 - waitress.run.assert_called_once_with() - waitress.close.assert_called() - waitress.task_dispatcher.shutdown.assert_called_once_with(cancel_pending=False, timeout=5) - close_pool.assert_called_once_with(pool) + open_pool.assert_called_once_with() + create_app.assert_called_once_with(pool) + register.assert_called_once_with(close_pool, pool) diff --git a/benchmark/environment.py b/benchmark/environment.py index f18e66f..c688689 100644 --- a/benchmark/environment.py +++ b/benchmark/environment.py @@ -128,12 +128,15 @@ def normalized(arguments): pieces = row.strip().split(None, 1) require(len(pieces) == 2 and pieces[0].isdigit(), "invalid process list row") commands.append(normalized(shlex.split(pieces[1]))) + service = state.get("Config", {}).get("Labels", {}).get("com.docker.compose.service") + expected_servers = 2 if service == "python-flask" else 1 require( - commands.count(server) == 1 and all(command in allowed for command in commands), + commands.count(server) == expected_servers + and all(command in allowed for command in commands), ( - "expected one server process and only independent health probes" + f"expected {expected_servers} server process(es) and only independent health probes" if allow_health_probe - else "expected exactly one server process" + else f"expected exactly {expected_servers} server process(es)" ), ) diff --git a/benchmark/implementations.json b/benchmark/implementations.json index 6b66854..bab355b 100644 --- a/benchmark/implementations.json +++ b/benchmark/implementations.json @@ -9,7 +9,7 @@ {"id":"node-fastify","language":"Node.js","framework":"Fastify","display_name":"Node.js / Fastify","source_path":"apps/node-fastify","version_fields":["node","fastify","pg"],"acceptance_test":"tests/test_node_fastify_service.py","failure_test":"tests/test_node_fastify_acceptance.py"}, {"id":"node-express","language":"Node.js","framework":"Express","display_name":"Node.js / Express","source_path":"apps/node-express","version_fields":["node","express","pg"],"acceptance_test":"tests/test_node_express_service.py","failure_test":"tests/test_node_express_acceptance.py"}, {"id":"python-fastapi","language":"Python","framework":"FastAPI","display_name":"Python / FastAPI","source_path":"apps/python-fastapi","version_fields":["python","fastapi","uvicorn","asyncpg"],"acceptance_test":"tests/test_python_fastapi_service.py","failure_test":"tests/test_python_fastapi_acceptance.py"}, - {"id":"python-flask","language":"Python","framework":"Flask","display_name":"Python / Flask","source_path":"apps/python-flask","version_fields":["python","flask","waitress","psycopg","psycopg-binary","psycopg-pool"],"acceptance_test":"tests/test_python_flask_service.py","failure_test":"tests/test_python_flask_acceptance.py"}, + {"id":"python-flask","language":"Python","framework":"Flask","display_name":"Python / Flask","source_path":"apps/python-flask","version_fields":["python","flask","psycopg","psycopg-binary","psycopg-pool"],"acceptance_test":"tests/test_python_flask_service.py","failure_test":"tests/test_python_flask_acceptance.py"}, {"id":"java-spring-boot","language":"Java","framework":"Spring Boot","display_name":"Java / Spring Boot","source_path":"apps/java-spring-boot","version_fields":["java","spring-boot","tomcat","postgresql","hikaricp","jackson","gradle"],"acceptance_test":"tests/test_java_spring_boot_service.py","failure_test":"tests/test_java_spring_boot_acceptance.py"} ], "cohorts": { diff --git a/docs/FLASK.md b/docs/FLASK.md index 26b5db6..86435f6 100644 --- a/docs/FLASK.md +++ b/docs/FLASK.md @@ -6,13 +6,13 @@ - CPython 3.14.7, matching the FastAPI baseline. - Flask 3.1.3. -- Waitress 3.0.2 as the production WSGI server. -- One server OS process and exactly one Waitress worker thread. +- Gunicorn 26.0.0 as the production WSGI server, using one `gthread` request worker with one thread. +- One Gunicorn arbiter plus exactly one request-processing worker and one request thread. - psycopg 3.3.5 with the binary implementation package pinned to the same version. - psycopg-pool 3.3.1 with `min_size=1`, `max_size=10`. - The same PostgreSQL fixture, parameterized lookup SQL, 1 CPU / 512 MiB container budget, loopback-only host publication, dropped capabilities, no-new-privileges policy, and non-root runtime used by the other implementations. -Flask's development server, Gunicorn master/worker processes, gevent/event-loop monkey patching, extra middleware, response caches, and precomputed CPU results are not used. +Flask's development server, gevent/event-loop monkey patching, extra middleware, response caches, and precomputed CPU results are not used. ## HTTP behavior @@ -27,15 +27,15 @@ The implementation provides the unchanged shared contract: ## Startup and shutdown -PostgreSQL pool creation and a real `SELECT 1` readiness query complete before Waitress begins accepting HTTP traffic. SIGINT and SIGTERM close the listener, Waitress drains/shuts down its single task-dispatcher thread with a bounded timeout, and the PostgreSQL pool is then closed. +Each Gunicorn worker creates its own PostgreSQL pool and proves readiness with a real `SELECT 1` before its Flask application is returned. Gunicorn handles SIGTERM and bounded graceful worker shutdown; worker-local pool cleanup is registered with Python `atexit`. The production image starts directly with: ```text -python -m benchmark_api.server +python -m gunicorn.app.wsgiapp --workers 1 --worker-class gthread --threads 1 … 'benchmark_api.server:create_server_app()' ``` -There is no supervisor or second worker process. +Gunicorn uses one arbiter process and one request-processing worker; it does not add request workers beyond the benchmark profile's single worker. ## Verification diff --git a/site/registry.mjs b/site/registry.mjs index 089a8cd..24ae24a 100644 --- a/site/registry.mjs +++ b/site/registry.mjs @@ -145,7 +145,6 @@ export const REGISTRY = { "version_fields": [ "python", "flask", - "waitress", "psycopg", "psycopg-binary", "psycopg-pool" diff --git a/tests/eight_stack_cases.mjs b/tests/eight_stack_cases.mjs index a1db6ee..0362f91 100644 --- a/tests/eight_stack_cases.mjs +++ b/tests/eight_stack_cases.mjs @@ -6,6 +6,8 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { REGISTRY } from '../site/registry.mjs'; + const members = ['go-gin', 'go-echo', 'rust-actix', 'rust-axum', 'node-fastify', 'node-express', 'python-fastapi', 'python-flask']; const legacyMembers = ['go-gin', 'rust-actix', 'node-fastify', 'python-fastapi']; @@ -91,7 +93,8 @@ export function registerEightStackTests() { r => { r.implementations[index].endpoints.reverse(); }, r => { r.implementations[index].endpoints[0].runs.pop(); }, ); - for (const field of Object.keys(eight.metadata.versions[members[index]])) { + const spec = REGISTRY.implementations.find(item => item.id === members[index]); + for (const field of spec.version_fields) { changes.push(r => { delete r.metadata.versions[members[index]][field]; }); } } diff --git a/tests/test_benchmark_environment.py b/tests/test_benchmark_environment.py index 3041e21..8c1aec0 100644 --- a/tests/test_benchmark_environment.py +++ b/tests/test_benchmark_environment.py @@ -163,6 +163,32 @@ def execute(arguments, *, timeout, tick=None, cwd=environment.ROOT): self.assertEqual(result["memory_samples"], 1) +class ProcessValidationTests(unittest.TestCase): + def test_flask_gunicorn_allows_exactly_one_arbiter_and_one_worker(self): + value = state() + value["Config"]["Labels"]["com.docker.compose.service"] = "python-flask" + value["Path"] = "python" + value["Args"] = ["-m", "benchmark_api.server"] + output = ( + "PID COMMAND\n10 python -m benchmark_api.server\n11 python -m benchmark_api.server\n" + ) + environment.validate_processes(value, output, allow_health_probe=False) + + def test_flask_gunicorn_rejects_extra_worker(self): + value = state() + value["Config"]["Labels"]["com.docker.compose.service"] = "python-flask" + value["Path"] = "python" + value["Args"] = ["-m", "benchmark_api.server"] + output = ( + "PID COMMAND\n" + "10 python -m benchmark_api.server\n" + "11 python -m benchmark_api.server\n" + "12 python -m benchmark_api.server\n" + ) + with self.assertRaises(BenchmarkFailure): + environment.validate_processes(value, output, allow_health_probe=False) + + class InstallerTests(unittest.TestCase): def test_cached_checksum_is_checked_before_executing_binary(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_benchmark_java_candidate.py b/tests/test_benchmark_java_candidate.py index ccd885e..d6c52a9 100644 --- a/tests/test_benchmark_java_candidate.py +++ b/tests/test_benchmark_java_candidate.py @@ -44,7 +44,7 @@ "python-flask": { "python": "3.14.7", "flask": "3.1.3", - "waitress": "3.0.2", + "gunicorn": "26.0.0", "psycopg": "3.3.5", "psycopg-binary": "3.3.5", "psycopg-pool": "3.3.1", diff --git a/tests/test_benchmark_python_flask_candidate.py b/tests/test_benchmark_python_flask_candidate.py index d4bb9bf..8508aae 100644 --- a/tests/test_benchmark_python_flask_candidate.py +++ b/tests/test_benchmark_python_flask_candidate.py @@ -9,17 +9,19 @@ class PythonFlaskCandidateTests(unittest.TestCase): - def test_flask_profile_is_explicit_and_single_process(self): + def test_flask_profile_is_explicit_and_single_request_worker(self): self.assertEqual((APP / ".python-version").read_text().strip(), "3.14.7") requirements = set((APP / "requirements.in").read_text().splitlines()) self.assertIn("flask==3.1.3", requirements) - self.assertIn("waitress==3.0.2", requirements) + self.assertIn("gunicorn==26.0.0", requirements) + self.assertNotIn("waitress==3.0.2", requirements) self.assertIn("psycopg==3.3.5", requirements) self.assertIn("psycopg-pool==3.3.1", requirements) - server = (APP / "benchmark_api" / "server.py").read_text() - self.assertIn("threads=1", server) - self.assertIn("create_server", server) - self.assertNotIn("gunicorn", server.lower()) + dockerfile = (APP / "Dockerfile").read_text() + self.assertIn('"--workers", "1"', dockerfile) + self.assertIn('"--worker-class", "gthread"', dockerfile) + self.assertIn('"--threads", "1"', dockerfile) + self.assertIn('"--keep-alive", "5"', dockerfile) def test_flask_is_registered_without_mutating_legacy_cohort(self): registry = json.loads((ROOT / "benchmark" / "implementations.json").read_text()) diff --git a/tests/test_python_flask_acceptance.py b/tests/test_python_flask_acceptance.py index d773850..2695aa3 100644 --- a/tests/test_python_flask_acceptance.py +++ b/tests/test_python_flask_acceptance.py @@ -13,14 +13,17 @@ def test_approved_wsgi_profile_is_pinned(self): self.assertEqual((APP / ".python-version").read_text().strip(), "3.14.7") requirements = set((APP / "requirements.in").read_text().splitlines()) self.assertIn("flask==3.1.3", requirements) - self.assertIn("waitress==3.0.2", requirements) + self.assertIn("gunicorn==26.0.0", requirements) + self.assertNotIn("waitress==3.0.2", requirements) self.assertIn("psycopg==3.3.5", requirements) self.assertIn("psycopg-binary==3.3.5", requirements) self.assertIn("psycopg-pool==3.3.1", requirements) - server = (APP / "benchmark_api" / "server.py").read_text() - self.assertIn("threads=1", server) - self.assertIn("create_server", server) - self.assertNotIn("gunicorn", server.lower()) + dockerfile = (APP / "Dockerfile").read_text() + self.assertIn('"--workers", "1"', dockerfile) + self.assertIn('"--threads", "1"', dockerfile) + self.assertIn('"--worker-class", "gthread"', dockerfile) + self.assertIn('"--keep-alive", "5"', dockerfile) + self.assertNotIn("waitress", dockerfile.lower()) def test_pool_and_container_limits_are_not_weakened(self): database = (APP / "benchmark_api" / "database.py").read_text() @@ -28,7 +31,7 @@ def test_pool_and_container_limits_are_not_weakened(self): dockerfile = (APP / "Dockerfile").read_text() self.assertEqual(dockerfile.count("python:3.14.7-slim-bookworm@sha256:"), 2) self.assertIn("USER 10001:10001", dockerfile) - self.assertIn('ENTRYPOINT ["python", "-m", "benchmark_api.server"]', dockerfile) + self.assertIn('ENTRYPOINT ["python", "-m", "gunicorn.app.wsgiapp"]', dockerfile) def test_registration_preserves_the_frozen_legacy_cohort(self): registry = json.loads((ROOT / "benchmark" / "implementations.json").read_text()) diff --git a/tests/test_python_flask_service.py b/tests/test_python_flask_service.py index 47ae42e..0110b83 100644 --- a/tests/test_python_flask_service.py +++ b/tests/test_python_flask_service.py @@ -121,8 +121,15 @@ def running_service() -> str: require(state["HostConfig"]["Memory"] == 536_870_912, "memory limit differs") require(state["Config"]["User"] == "10001:10001", "runtime user differs") require( - state["Path"] == "python" and state["Args"] == ["-m", "benchmark_api.server"], - "unexpected server wrapper", + state["Path"] == "python" + and state["Args"][:2] == ["-m", "gunicorn.app.wsgiapp"] + and "--workers" in state["Args"] + and state["Args"][state["Args"].index("--workers") + 1] == "1" + and "--worker-class" in state["Args"] + and state["Args"][state["Args"].index("--worker-class") + 1] == "gthread" + and "--threads" in state["Args"] + and state["Args"][state["Args"].index("--threads") + 1] == "1", + "unexpected Gunicorn runtime profile", ) require(state["HostConfig"]["CapDrop"] == ["ALL"], "capabilities not dropped") require( @@ -133,8 +140,11 @@ def running_service() -> str: ["docker", "top", container, "-eo", "pid,args"] ).stdout.splitlines()[1:] commands = [line.split(maxsplit=1)[1] for line in processes if line.strip()] - servers = [command for command in commands if "benchmark_api.server" in command] - require(len(servers) == 1, f"expected one Waitress server process, got {servers!r}") + servers = [command for command in commands if "gunicorn" in command] + require( + len(servers) == 2, + f"expected one Gunicorn arbiter and one request worker, got {servers!r}", + ) return container