Skip to content

Commit

Permalink
WIP: Add and test Gunicorn worker
Browse files Browse the repository at this point in the history
  • Loading branch information
br3ndonland committed Nov 11, 2024
1 parent 351f19c commit 6853b0c
Show file tree
Hide file tree
Showing 5 changed files with 406 additions and 2 deletions.
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,14 @@ jobs:
- name: Run Hatch script for code quality checks
run: hatch run ${{ env.HATCH_ENV }}:check
- name: Run tests
run: hatch run ${{ env.HATCH_ENV }}:coverage run
run: |
export COVERAGE_PROCESS_START="$PWD/pyproject.toml"
hatch run ${{ env.HATCH_ENV }}:coverage run
timeout-minutes: 5
- name: Enforce test coverage
run: hatch run ${{ env.HATCH_ENV }}:coverage report
run: |
hatch run ${{ env.HATCH_ENV }}:coverage combine -q
hatch run ${{ env.HATCH_ENV }}:coverage report
- name: Build Python package
run: hatch build
- name: Upload Python package artifacts
Expand Down
1 change: 1 addition & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ As explained in the [VSCode docs](https://code.visualstudio.com/docs/containers/
- [pytest configuration](https://docs.pytest.org/en/latest/reference/customize.html) is in _[pyproject.toml](https://github.com/br3ndonland/inboard/blob/develop/pyproject.toml)_.
- [FastAPI testing](https://fastapi.tiangolo.com/tutorial/testing/) and [Starlette testing](https://www.starlette.io/testclient/) rely on the [Starlette `TestClient`](https://www.starlette.io/testclient/).
- Test coverage reports are generated by [coverage.py](https://github.com/nedbat/coveragepy). To generate test coverage reports, first run tests with `coverage run`, then generate a report with `coverage report`. To see interactive HTML coverage reports, run `coverage html` instead of `coverage report`.
- Some of the tests start separate subprocesses. These tests are more complex in some ways, and can take longer, than the standard single-process tests. A [pytest mark](https://docs.pytest.org/en/latest/example/markers.html) is included to help control the behavior of subprocess tests. To run the test suite without subprocess tests, [select tests](https://docs.pytest.org/en/stable/example/markers.html) with `coverage run -m pytest -m "not subprocess"`. Note that test coverage will be lower without the subprocess tests.

## Docker

Expand Down
134 changes: 134 additions & 0 deletions inboard/gunicorn_workers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/).
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""

import asyncio
import logging
import signal
import sys
from typing import Any

from gunicorn.arbiter import Arbiter
from gunicorn.workers.base import Worker
from uvicorn.config import Config
from uvicorn.main import Server


class UvicornWorker(Worker):
"""
A worker class for Gunicorn that interfaces with an ASGI consumer callable,
rather than a WSGI callable.
"""

CONFIG_KWARGS: dict[str, Any] = {"loop": "auto", "http": "auto"}

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

logger = logging.getLogger("uvicorn.error")
logger.handlers = self.log.error_log.handlers
logger.setLevel(self.log.error_log.level)
logger.propagate = False

logger = logging.getLogger("uvicorn.access")
logger.handlers = self.log.access_log.handlers
logger.setLevel(self.log.access_log.level)
logger.propagate = False

config_kwargs: dict = {
"app": None,
"log_config": None,
"timeout_keep_alive": self.cfg.keepalive,
"timeout_notify": self.timeout,
"callback_notify": self.callback_notify,
"limit_max_requests": self.max_requests,
"forwarded_allow_ips": self.cfg.forwarded_allow_ips,
}

if self.cfg.is_ssl:
ssl_kwargs = {
"ssl_keyfile": self.cfg.ssl_options.get("keyfile"),
"ssl_certfile": self.cfg.ssl_options.get("certfile"),
"ssl_keyfile_password": self.cfg.ssl_options.get("password"),
"ssl_version": self.cfg.ssl_options.get("ssl_version"),
"ssl_cert_reqs": self.cfg.ssl_options.get("cert_reqs"),
"ssl_ca_certs": self.cfg.ssl_options.get("ca_certs"),
"ssl_ciphers": self.cfg.ssl_options.get("ciphers"),
}
config_kwargs.update(ssl_kwargs)

if self.cfg.settings["backlog"].value:
config_kwargs["backlog"] = self.cfg.settings["backlog"].value

config_kwargs.update(self.CONFIG_KWARGS)

self.config = Config(**config_kwargs)

def init_process(self) -> None:
self.config.setup_event_loop()
super().init_process()

def init_signals(self) -> None:
# Reset signals so Gunicorn doesn't swallow subprocess return codes
# other signals are set up by Server.install_signal_handlers()
# See: https://github.com/encode/uvicorn/issues/894
for s in self.SIGNALS:
signal.signal(s, signal.SIG_DFL)

signal.signal(signal.SIGUSR1, self.handle_usr1)
# Don't let SIGUSR1 disturb active requests by interrupting system calls
signal.siginterrupt(signal.SIGUSR1, False)

def _install_sigquit_handler(self) -> None:
"""Install a SIGQUIT handler on workers.
- https://github.com/encode/uvicorn/issues/1116
- https://github.com/benoitc/gunicorn/issues/2604
"""

loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGQUIT, self.handle_exit, signal.SIGQUIT, None)

async def _serve(self) -> None:
self.config.app = self.wsgi
server = Server(config=self.config)
self._install_sigquit_handler()
await server.serve(sockets=self.sockets)
if not server.started:
sys.exit(Arbiter.WORKER_BOOT_ERROR)

def run(self) -> None:
return asyncio.run(self._serve())

async def callback_notify(self) -> None:
self.notify()


class UvicornH11Worker(UvicornWorker):
CONFIG_KWARGS = {"loop": "asyncio", "http": "h11"}
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ starlette = [
]
tests = [
"coverage[toml]>=7,<8",
"coverage_enable_subprocess==1.0",
"httpx>=0.23,<1",
"pytest>=8.1.1,<9",
"pytest-mock>=3,<4",
Expand Down Expand Up @@ -75,6 +76,7 @@ show_missing = true

[tool.coverage.run]
command_line = "-m pytest"
parallel = true
source = ["inboard", "tests"]

[tool.hatch.build.targets.sdist]
Expand Down
Loading

0 comments on commit 6853b0c

Please sign in to comment.