diff --git a/CHANGELOG.md b/CHANGELOG.md index aa60819..79da33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.0.44 + +* **Ignore SIGTERM in plugin uvicorn Servers**: plugin webservers now keep + serving on SIGTERM so their controller container can finish dispatching + in-flight work before the pod is SIGKILLed. SIGINT still terminates for + local-dev Ctrl-C. + ## 0.0.43 * **Deprecate `wrap_in_fastapi`** - Mark `wrap_in_fastapi` (and the `etl-uvicorn` CLI it backs) as deprecated via PEP 702 `@deprecated`. New plugins should build a FastAPI app directly with explicit handlers for the plugin contract routes. diff --git a/test/test_signal_handlers.py b/test/test_signal_handlers.py new file mode 100644 index 0000000..2832e93 --- /dev/null +++ b/test/test_signal_handlers.py @@ -0,0 +1,35 @@ +"""Verify the etl_uvicorn module installs a SIGTERM-ignoring handler on uvicorn.Server.""" + +import asyncio +import signal + +import uvicorn + +# Importing the module applies the monkey-patch as a side effect. +from unstructured_platform_plugins.etl_uvicorn import main # noqa: F401 + + +def test_uvicorn_server_install_signal_handlers_is_patched(): + assert ( + uvicorn.Server.install_signal_handlers.__name__ + == "_install_signal_handlers_ignoring_sigterm" + ) + + +def test_install_signal_handlers_registers_sigint_only(): + async def _run() -> None: + config = uvicorn.Config(app="fake:app", lifespan="off") + server = uvicorn.Server(config=config) + server.install_signal_handlers() + loop = asyncio.get_running_loop() + try: + assert loop.remove_signal_handler(signal.SIGINT) is True + assert loop.remove_signal_handler(signal.SIGTERM) is False + finally: + try: + loop.remove_signal_handler(signal.SIGINT) + loop.remove_signal_handler(signal.SIGTERM) + except Exception: + pass + + asyncio.run(_run()) diff --git a/unstructured_platform_plugins/__version__.py b/unstructured_platform_plugins/__version__.py index 9c0e950..3fbbe28 100644 --- a/unstructured_platform_plugins/__version__.py +++ b/unstructured_platform_plugins/__version__.py @@ -1 +1 @@ -__version__ = "0.0.43" # pragma: no cover +__version__ = "0.0.44" # pragma: no cover diff --git a/unstructured_platform_plugins/etl_uvicorn/main.py b/unstructured_platform_plugins/etl_uvicorn/main.py index 39f4cb3..600e0c1 100644 --- a/unstructured_platform_plugins/etl_uvicorn/main.py +++ b/unstructured_platform_plugins/etl_uvicorn/main.py @@ -1,13 +1,34 @@ +import asyncio +import signal +import threading from dataclasses import dataclass, field from typing import IO, Any, Optional import click +import uvicorn from uvicorn.config import LOGGING_CONFIG, Config, RawConfigParser from uvicorn.main import main, run from unstructured_platform_plugins.etl_uvicorn.api_generator import generate_fast_api +def _install_signal_handlers_ignoring_sigterm(self: uvicorn.Server) -> None: + # uvicorn's default load-sheds 504 on SIGTERM, which races with controllers + # trying to drain in-flight work during pod shutdown. Plugin webservers are + # expected to outlive their controller container so SIGKILL — not SIGTERM — + # ends the process. SIGINT is preserved so local Ctrl-C still works. + if threading.current_thread() is not threading.main_thread(): + return + try: + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal.SIGINT, self.handle_exit, signal.SIGINT, None) + except NotImplementedError: + signal.signal(signal.SIGINT, self.handle_exit) + + +uvicorn.Server.install_signal_handlers = _install_signal_handlers_ignoring_sigterm + + @dataclass class CustomConfig: log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = field(