Skip to content

Commit 0425420

Browse files
authored
Merge pull request #138 from PythonNest/agent/background-workers
feat: add supervised background workers
2 parents 371f554 + 353c25a commit 0425420

14 files changed

Lines changed: 1746 additions & 44 deletions

docs/background_workers.md

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Background workers
2+
3+
PyNest can run dependency-injected work alongside an HTTP application or in a
4+
dedicated process without an HTTP server. The framework starts worker tasks on
5+
the runtime event loop, supervises failures, and stops them before application
6+
shutdown hooks dispose their dependencies.
7+
8+
## Long-running workers
9+
10+
Create an injectable provider that extends `BackgroundWorker`:
11+
12+
```python
13+
import asyncio
14+
15+
from nest.core import BackgroundWorker, Injectable, Module
16+
17+
18+
@Injectable
19+
class EmailWorker(BackgroundWorker):
20+
name = "email"
21+
22+
def __init__(self, queue: EmailQueue):
23+
self.queue = queue
24+
25+
async def run(self) -> None:
26+
while not self.stopping.is_set():
27+
try:
28+
message = await asyncio.wait_for(
29+
self.queue.receive(),
30+
timeout=1,
31+
)
32+
except asyncio.TimeoutError:
33+
continue
34+
35+
await self.queue.deliver(message)
36+
37+
38+
@Module(providers=[EmailQueue, EmailWorker])
39+
class AppModule:
40+
pass
41+
```
42+
43+
Register the worker in `providers` like any other service. Constructor
44+
dependencies are resolved by the PyNest container, and the host retains the
45+
resolved instance for the application lifespan. Singleton scope is recommended
46+
for worker providers.
47+
48+
Do not start tasks from a worker constructor or lifecycle hook. PyNest calls
49+
`run()` from FastAPI's lifespan event loop, which keeps asyncio resources on the
50+
same loop that owns the application.
51+
52+
### Cooperative shutdown
53+
54+
`self.stopping` is set when shutdown begins. Long-running loops should inspect
55+
it, and idle delays should use `self.sleep()`:
56+
57+
```python
58+
async def run(self) -> None:
59+
while not self.stopping.is_set():
60+
await self.flush_batch()
61+
if not await self.sleep(5):
62+
return
63+
```
64+
65+
`sleep(seconds)` returns `True` when the delay elapsed and `False` when shutdown
66+
interrupted it. PyNest first requests cooperative shutdown and calls
67+
`on_stop()`. If the worker is still running after the grace timeout, its task is
68+
cancelled and drained.
69+
70+
`on_start()` and `on_stop()` are optional. They may be synchronous or
71+
asynchronous, but `run()` must be asynchronous.
72+
73+
## Recurring interval jobs
74+
75+
`IntervalWorker` is a fixed-delay scheduler built on the same supervisor:
76+
77+
```python
78+
from nest.core import Injectable, IntervalWorker
79+
80+
81+
@Injectable
82+
class CleanupWorker(IntervalWorker):
83+
name = "expired-session-cleanup"
84+
interval = 300
85+
run_immediately = True
86+
87+
def __init__(self, sessions: SessionRepository):
88+
self.sessions = sessions
89+
90+
async def execute(self) -> None:
91+
await self.sessions.delete_expired()
92+
```
93+
94+
The delay starts after `execute()` finishes, so occurrences never overlap
95+
within one process. `interval` must be greater than zero. By default, the first
96+
occurrence waits for one interval; set `run_immediately = True` to run once at
97+
startup.
98+
99+
Calendar and cron scheduling are intentionally not implemented in core.
100+
Production cron scheduling needs explicit policies for time zones, daylight
101+
saving changes, missed executions, persistence, and distributed locking. Use a
102+
dedicated scheduler in a worker provider when those semantics are required.
103+
104+
## Restart policies
105+
106+
Workers default to restarting after an exception:
107+
108+
```python
109+
from nest.core import RestartPolicy
110+
111+
112+
class QueueWorker(BackgroundWorker):
113+
restart = RestartPolicy.ON_FAILURE
114+
restart_backoff = 1
115+
restart_backoff_max = 30
116+
```
117+
118+
The available policies are:
119+
120+
| Policy | Exception | Normal return |
121+
| --- | --- | --- |
122+
| `RestartPolicy.NONE` | Stop as failed | Stop as completed |
123+
| `RestartPolicy.ON_FAILURE` | Restart | Stop as completed |
124+
| `RestartPolicy.ALWAYS` | Restart | Restart |
125+
126+
Restarts use capped exponential backoff. Cancellation and application shutdown
127+
never trigger a restart.
128+
129+
An unhandled `IntervalWorker.execute()` exception follows the same policy as
130+
any other worker failure.
131+
132+
## HTTP applications
133+
134+
No extra startup code is needed:
135+
136+
```python
137+
from nest.core import PyNestFactory
138+
139+
app = PyNestFactory.create(
140+
AppModule,
141+
worker_grace_timeout=15,
142+
title="API and workers",
143+
)
144+
```
145+
146+
Workers start when the ASGI lifespan starts, not when
147+
`PyNestFactory.create()` returns. They stop before PyNest runs provider and
148+
module shutdown hooks.
149+
150+
Inspect worker state for a health or administration endpoint:
151+
152+
```python
153+
worker_status = app.get_worker_host().status()
154+
```
155+
156+
The result is JSON-ready:
157+
158+
```json
159+
[
160+
{
161+
"name": "email",
162+
"state": "running",
163+
"restarts": 1,
164+
"last_error": "ConnectionError: broker unavailable"
165+
}
166+
]
167+
```
168+
169+
States are `starting`, `running`, `backing_off`, `stopping`, `stopped`,
170+
`completed`, and `failed`.
171+
172+
## Standalone worker processes
173+
174+
Use `run_workers()` for a process that does not serve HTTP:
175+
176+
```python
177+
from nest.core import run_workers
178+
179+
from src.app_module import AppModule
180+
181+
182+
if __name__ == "__main__":
183+
run_workers(AppModule, grace_timeout=15)
184+
```
185+
186+
The standalone runner:
187+
188+
1. Builds the dependency container.
189+
2. Runs bootstrap lifecycle hooks on its event loop.
190+
3. Starts all registered workers on that same loop.
191+
4. Handles `SIGTERM` and `SIGINT`.
192+
5. Stops workers before running shutdown hooks.
193+
194+
For an existing async entrypoint, use the application object instead:
195+
196+
```python
197+
from nest.core import WorkerAppFactory
198+
199+
200+
async def main() -> None:
201+
app = WorkerAppFactory.create(AppModule)
202+
await app.run()
203+
```
204+
205+
Do not call `run_workers()` from a running event loop; it raises a clear error
206+
instead of nesting `asyncio.run()`.
207+
208+
## Deployment behavior
209+
210+
Every application process runs its own instance of every registered worker. If
211+
Uvicorn starts four processes, an HTTP-integrated interval worker runs four
212+
times.
213+
214+
Use one of these patterns when work must run only once:
215+
216+
- Deploy `run_workers()` as a separate single-replica service.
217+
- Partition queue consumption so the broker coordinates consumers.
218+
- Protect scheduled work with a distributed lock.
219+
- Use a scheduler that persists and coordinates jobs across processes.
220+
221+
Worker status is local to one process and resets when that process restarts.

docs/lifespan_tasks.md

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,37 @@
1-
# Lifaspan tasks in PyNest
1+
# Lifespan tasks in PyNest
22

3-
## Introduction
3+
Long-running coroutines should use PyNest's
4+
[background worker](background_workers.md) support. Awaiting an infinite
5+
coroutine directly from a FastAPI startup handler prevents startup from
6+
completing and does not provide supervised failure or bounded shutdown.
47

5-
Lifespan tasks - coroutines, which run while app is working.
6-
7-
## Defining a lifespan task
8-
As example of lifespan task will use coroutine, which print time every hour. In real user cases can be everything else.
8+
For a recurring task, extend `IntervalWorker`:
99

1010
```python
11-
import asyncio
1211
from datetime import datetime
1312

14-
async def print_current_time():
15-
while True:
13+
from nest.core import Injectable, IntervalWorker, Module, PyNestFactory
14+
15+
16+
@Injectable
17+
class ClockWorker(IntervalWorker):
18+
interval = 3600
19+
run_immediately = True
20+
21+
async def execute(self) -> None:
1622
current_time = datetime.now().strftime("%H:%M:%S")
1723
print(f"Current time: {current_time}")
18-
await asyncio.sleep(3600)
19-
```
20-
21-
## Implement a lifespan task
22-
In `app_module.py` we can define a startup handler, and run lifespan inside it
2324

24-
```python
25-
from nest.core import PyNestFactory
2625

27-
app = PyNestFactory.create(
28-
AppModule,
29-
description="This is my PyNest app with lifespan task",
30-
title="My App",
31-
version="1.0.0",
32-
debug=True,
33-
)
26+
@Module(providers=[ClockWorker])
27+
class AppModule:
28+
pass
3429

35-
http_server = app.get_server()
3630

37-
@http_server.on_event("startup")
38-
async def startup():
39-
await print_current_time()
31+
app = PyNestFactory.create(AppModule)
4032
```
4133

42-
Now `print_current_time` will work in lifespan after startup.
34+
PyNest starts the worker inside the ASGI lifespan and stops it when the
35+
application shuts down. See [Background workers](background_workers.md) for
36+
long-running consumers, restart policies, status inspection, and standalone
37+
worker processes.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ nav:
6060
- Guards: guards.md
6161
- Exception Filters: exception_filters.md
6262
- WebSockets: websockets.md
63+
- Background Workers: background_workers.md
6364
- Dependency Injection: dependency_injection.md
6465
- Deployment:
6566
- Docker: docker.md

nest/common/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@
3131
OnModuleDestroy,
3232
OnModuleInit,
3333
)
34+
from nest.common.background_worker import (
35+
BackgroundWorker,
36+
IntervalWorker,
37+
RestartPolicy,
38+
WorkerState,
39+
WorkerStatus,
40+
)

0 commit comments

Comments
 (0)