Skip to content

Commit 4aea423

Browse files
authored
feat(etl-uvicorn): ignore SIGTERM in plugin uvicorn Server (#68)
## Summary uvicorn's default SIGTERM behavior load-sheds new requests with 504 while draining in-flight work. Plugin webservers in our platform are expected to **outlive their controller container** during pod shutdown so the controller can finish dispatching in-flight work before the pod is SIGKILLed — the default behavior races with that drain and orphans records. This PR monkey-patches `uvicorn.Server.install_signal_handlers` at module import time so plugin webservers using `etl-uvicorn` install only a SIGINT handler. The process now ignores SIGTERM and exits only on SIGKILL (or SIGINT for local-dev Ctrl-C). ## Why a monkey-patch instead of a Server subclass? Subclassing requires bypassing `uvicorn.run()` and manually constructing `Config + Server`, which loses uvicorn's built-in handling of workers / reload / multiproc. Plugin webservers don't use those today but the surface area isn't worth changing. The patch is 8 lines, well-scoped to module import, and works for any uvicorn.Server instance in this process. ## Test plan - [x] `make tidy` — clean - [x] `make check-ruff` — passes - [x] `pytest` — all tests pass (existing suite + 2 new in `test/test_signal_handlers.py` covering the patch is applied + the SIGINT-only handler set) - [ ] Smoke test: send SIGTERM to a plugin pod, confirm the process keeps serving until SIGKILL at end of pod grace - [ ] Verify plugin logs / traces show no 504s from `/invoke` during a rolling deploy after this version is rolled out ## Version Bumps `unstructured_platform_plugins` to `0.0.44`. Downstream consumers will need a separate change to pin the new version.
1 parent 094fc3d commit 4aea423

4 files changed

Lines changed: 64 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 0.0.44
2+
3+
* **Ignore SIGTERM in plugin uvicorn Servers**: plugin webservers now keep
4+
serving on SIGTERM so their controller container can finish dispatching
5+
in-flight work before the pod is SIGKILLed. SIGINT still terminates for
6+
local-dev Ctrl-C.
7+
18
## 0.0.43
29

310
* **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.

test/test_signal_handlers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Verify the etl_uvicorn module installs a SIGTERM-ignoring handler on uvicorn.Server."""
2+
3+
import asyncio
4+
import signal
5+
6+
import uvicorn
7+
8+
# Importing the module applies the monkey-patch as a side effect.
9+
from unstructured_platform_plugins.etl_uvicorn import main # noqa: F401
10+
11+
12+
def test_uvicorn_server_install_signal_handlers_is_patched():
13+
assert (
14+
uvicorn.Server.install_signal_handlers.__name__
15+
== "_install_signal_handlers_ignoring_sigterm"
16+
)
17+
18+
19+
def test_install_signal_handlers_registers_sigint_only():
20+
async def _run() -> None:
21+
config = uvicorn.Config(app="fake:app", lifespan="off")
22+
server = uvicorn.Server(config=config)
23+
server.install_signal_handlers()
24+
loop = asyncio.get_running_loop()
25+
try:
26+
assert loop.remove_signal_handler(signal.SIGINT) is True
27+
assert loop.remove_signal_handler(signal.SIGTERM) is False
28+
finally:
29+
try:
30+
loop.remove_signal_handler(signal.SIGINT)
31+
loop.remove_signal_handler(signal.SIGTERM)
32+
except Exception:
33+
pass
34+
35+
asyncio.run(_run())
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.43" # pragma: no cover
1+
__version__ = "0.0.44" # pragma: no cover

unstructured_platform_plugins/etl_uvicorn/main.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
1+
import asyncio
2+
import signal
3+
import threading
14
from dataclasses import dataclass, field
25
from typing import IO, Any, Optional
36

47
import click
8+
import uvicorn
59
from uvicorn.config import LOGGING_CONFIG, Config, RawConfigParser
610
from uvicorn.main import main, run
711

812
from unstructured_platform_plugins.etl_uvicorn.api_generator import generate_fast_api
913

1014

15+
def _install_signal_handlers_ignoring_sigterm(self: uvicorn.Server) -> None:
16+
# uvicorn's default load-sheds 504 on SIGTERM, which races with controllers
17+
# trying to drain in-flight work during pod shutdown. Plugin webservers are
18+
# expected to outlive their controller container so SIGKILL — not SIGTERM —
19+
# ends the process. SIGINT is preserved so local Ctrl-C still works.
20+
if threading.current_thread() is not threading.main_thread():
21+
return
22+
try:
23+
loop = asyncio.get_event_loop()
24+
loop.add_signal_handler(signal.SIGINT, self.handle_exit, signal.SIGINT, None)
25+
except NotImplementedError:
26+
signal.signal(signal.SIGINT, self.handle_exit)
27+
28+
29+
uvicorn.Server.install_signal_handlers = _install_signal_handlers_ignoring_sigterm
30+
31+
1132
@dataclass
1233
class CustomConfig:
1334
log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = field(

0 commit comments

Comments
 (0)