flask-async-celery 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flask_async_celery/__init__.py +9 -0
- flask_async_celery/bootstep.py +84 -0
- flask_async_celery/executor.py +283 -0
- flask_async_celery/extension.py +111 -0
- flask_async_celery/pool.py +235 -0
- flask_async_celery/task.py +63 -0
- flask_async_celery-0.1.0.dist-info/METADATA +597 -0
- flask_async_celery-0.1.0.dist-info/RECORD +10 -0
- flask_async_celery-0.1.0.dist-info/WHEEL +5 -0
- flask_async_celery-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from celery import bootsteps
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncIOBootStep(bootsteps.StartStopStep):
|
|
11
|
+
"""
|
|
12
|
+
Worker bootstep for the Flask Async Celery integration.
|
|
13
|
+
|
|
14
|
+
The Celery Pool bootstep is required so this component starts
|
|
15
|
+
after the worker pool has been created.
|
|
16
|
+
|
|
17
|
+
The actual asyncio event loop is owned by AsyncIOPool/
|
|
18
|
+
AsyncExecutor. This bootstep provides the worker-level hook
|
|
19
|
+
that we will use for lifecycle and backpressure handling.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
requires = {"celery.worker.components:Pool"}
|
|
23
|
+
|
|
24
|
+
def __init__(self, worker, **kwargs):
|
|
25
|
+
self.worker = worker
|
|
26
|
+
super().__init__(worker, **kwargs)
|
|
27
|
+
|
|
28
|
+
def start(self, worker) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Called when the Celery worker starts.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
pool = getattr(worker, "pool", None)
|
|
34
|
+
|
|
35
|
+
logger.info(
|
|
36
|
+
"AsyncIO worker bootstep started: pool=%s",
|
|
37
|
+
type(pool).__name__ if pool else None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# The custom pool owns the AsyncExecutor.
|
|
41
|
+
#
|
|
42
|
+
# Keep a reference on the worker so other bootsteps/components
|
|
43
|
+
# can access it without importing the pool implementation.
|
|
44
|
+
if pool is not None and hasattr(pool, "executor"):
|
|
45
|
+
worker.async_executor = pool.async_executor
|
|
46
|
+
|
|
47
|
+
worker.asyncio_enabled = True
|
|
48
|
+
|
|
49
|
+
def stop(self, worker) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Called during normal worker shutdown.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
logger.info("AsyncIO worker bootstep stopping")
|
|
55
|
+
|
|
56
|
+
worker.asyncio_enabled = False
|
|
57
|
+
|
|
58
|
+
def terminate(self, worker) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Called during forced worker termination.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
logger.info("AsyncIO worker bootstep terminating")
|
|
64
|
+
|
|
65
|
+
worker.asyncio_enabled = False
|
|
66
|
+
|
|
67
|
+
def info(self, worker):
|
|
68
|
+
"""
|
|
69
|
+
Expose basic information through worker inspection.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
executor = getattr(worker, "async_executor", None)
|
|
73
|
+
|
|
74
|
+
if executor is None:
|
|
75
|
+
return {
|
|
76
|
+
"asyncio-enabled": False,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"asyncio-enabled": True,
|
|
81
|
+
"asyncio-running": executor.running,
|
|
82
|
+
"asyncio-available": executor.available,
|
|
83
|
+
"asyncio-max-tasks": executor.max_tasks,
|
|
84
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from concurrent.futures import Future
|
|
7
|
+
from typing import Any
|
|
8
|
+
from collections.abc import Coroutine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncExecutor:
|
|
15
|
+
"""
|
|
16
|
+
Persistent asyncio executor.
|
|
17
|
+
|
|
18
|
+
One instance runs one asyncio event loop inside one
|
|
19
|
+
dedicated thread.
|
|
20
|
+
|
|
21
|
+
The executor itself is NOT a Celery worker pool.
|
|
22
|
+
It only knows how to execute coroutines concurrently.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
max_tasks: int = 20,
|
|
28
|
+
thread_name: str = "celery-asyncio",
|
|
29
|
+
) -> None:
|
|
30
|
+
|
|
31
|
+
if max_tasks < 1:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"max_tasks must be greater than zero"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self.max_tasks = max_tasks
|
|
37
|
+
self.thread_name = thread_name
|
|
38
|
+
|
|
39
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
40
|
+
|
|
41
|
+
self.thread: threading.Thread | None = None
|
|
42
|
+
|
|
43
|
+
self._started = threading.Event()
|
|
44
|
+
|
|
45
|
+
self._stopping = threading.Event()
|
|
46
|
+
|
|
47
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
48
|
+
|
|
49
|
+
self._running = 0
|
|
50
|
+
|
|
51
|
+
self._running_lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Properties
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def running(self) -> int:
|
|
59
|
+
with self._running_lock:
|
|
60
|
+
return self._running
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def available(self) -> int:
|
|
64
|
+
return max(
|
|
65
|
+
0,
|
|
66
|
+
self.max_tasks - self.running,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_running(self) -> bool:
|
|
71
|
+
return (
|
|
72
|
+
self.loop is not None
|
|
73
|
+
and self.loop.is_running()
|
|
74
|
+
and not self._stopping.is_set()
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Lifecycle
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def start(self) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Start the dedicated asyncio thread.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
if self.thread and self.thread.is_alive():
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self._started.clear()
|
|
90
|
+
self._stopping.clear()
|
|
91
|
+
|
|
92
|
+
self.thread = threading.Thread(
|
|
93
|
+
target=self._run_loop,
|
|
94
|
+
name=self.thread_name,
|
|
95
|
+
daemon=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.thread.start()
|
|
99
|
+
|
|
100
|
+
if not self._started.wait(timeout=10):
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"AsyncIO event loop failed to start"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info(
|
|
106
|
+
"AsyncIO executor started: "
|
|
107
|
+
"max_tasks=%s thread=%s",
|
|
108
|
+
self.max_tasks,
|
|
109
|
+
self.thread.name,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _run_loop(self) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Runs inside the dedicated thread.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
loop = asyncio.new_event_loop()
|
|
118
|
+
|
|
119
|
+
self.loop = loop
|
|
120
|
+
|
|
121
|
+
asyncio.set_event_loop(loop)
|
|
122
|
+
|
|
123
|
+
self._semaphore = asyncio.Semaphore(
|
|
124
|
+
self.max_tasks
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._started.set()
|
|
128
|
+
|
|
129
|
+
logger.info(
|
|
130
|
+
"AsyncIO event loop started"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
loop.run_forever()
|
|
135
|
+
|
|
136
|
+
finally:
|
|
137
|
+
self._shutdown_loop(loop)
|
|
138
|
+
|
|
139
|
+
def _shutdown_loop(
|
|
140
|
+
self,
|
|
141
|
+
loop: asyncio.AbstractEventLoop,
|
|
142
|
+
) -> None:
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
"Shutting down AsyncIO event loop"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
pending = asyncio.all_tasks(loop)
|
|
150
|
+
|
|
151
|
+
for task in pending:
|
|
152
|
+
task.cancel()
|
|
153
|
+
|
|
154
|
+
if pending:
|
|
155
|
+
loop.run_until_complete(
|
|
156
|
+
asyncio.gather(
|
|
157
|
+
*pending,
|
|
158
|
+
return_exceptions=True,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
except Exception:
|
|
163
|
+
logger.exception(
|
|
164
|
+
"Error while shutting down "
|
|
165
|
+
"AsyncIO tasks"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
finally:
|
|
169
|
+
loop.close()
|
|
170
|
+
|
|
171
|
+
logger.info(
|
|
172
|
+
"AsyncIO event loop stopped"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Submission
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def submit(
|
|
180
|
+
self,
|
|
181
|
+
coroutine: Coroutine[Any, Any, Any],
|
|
182
|
+
) -> Future:
|
|
183
|
+
"""
|
|
184
|
+
Submit a coroutine to the persistent event loop.
|
|
185
|
+
|
|
186
|
+
Returns a concurrent.futures.Future.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
if not self.is_running:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"AsyncIO executor is not running"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return asyncio.run_coroutine_threadsafe(
|
|
195
|
+
self._execute(coroutine),
|
|
196
|
+
self.loop,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def _execute(
|
|
200
|
+
self,
|
|
201
|
+
coroutine: Coroutine[Any, Any, Any],
|
|
202
|
+
) -> Any:
|
|
203
|
+
|
|
204
|
+
if self._semaphore is None:
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
"AsyncIO semaphore is not initialized"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async with self._semaphore:
|
|
210
|
+
|
|
211
|
+
self._increment_running()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
return await coroutine
|
|
215
|
+
|
|
216
|
+
finally:
|
|
217
|
+
self._decrement_running()
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Counters
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _increment_running(self) -> None:
|
|
224
|
+
|
|
225
|
+
with self._running_lock:
|
|
226
|
+
self._running += 1
|
|
227
|
+
|
|
228
|
+
logger.debug(
|
|
229
|
+
"AsyncIO tasks: %s/%s",
|
|
230
|
+
self._running,
|
|
231
|
+
self.max_tasks,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _decrement_running(self) -> None:
|
|
235
|
+
|
|
236
|
+
with self._running_lock:
|
|
237
|
+
self._running -= 1
|
|
238
|
+
|
|
239
|
+
logger.debug(
|
|
240
|
+
"AsyncIO tasks: %s/%s",
|
|
241
|
+
self._running,
|
|
242
|
+
self.max_tasks,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Shutdown
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def shutdown(
|
|
250
|
+
self,
|
|
251
|
+
wait: bool = True,
|
|
252
|
+
timeout: float = 10,
|
|
253
|
+
) -> None:
|
|
254
|
+
|
|
255
|
+
if self.loop is None:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
if not self.loop.is_running():
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
self._stopping.set()
|
|
262
|
+
|
|
263
|
+
logger.info(
|
|
264
|
+
"Stopping AsyncIO executor"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
self.loop.call_soon_threadsafe(
|
|
268
|
+
self.loop.stop
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if wait and self.thread:
|
|
272
|
+
|
|
273
|
+
self.thread.join(
|
|
274
|
+
timeout=timeout
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if self.thread.is_alive():
|
|
278
|
+
|
|
279
|
+
logger.warning(
|
|
280
|
+
"AsyncIO thread did not stop "
|
|
281
|
+
"within %.1f seconds",
|
|
282
|
+
timeout,
|
|
283
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from celery import Celery
|
|
6
|
+
from flask import Flask
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncCelery:
|
|
10
|
+
"""
|
|
11
|
+
Flask integration for flask-async-celery.
|
|
12
|
+
|
|
13
|
+
Configures a Celery application to use AsyncIOPool and
|
|
14
|
+
optionally enables Celery consumer-side backpressure.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
app: Flask | None = None,
|
|
20
|
+
*,
|
|
21
|
+
celery: Celery | None = None,
|
|
22
|
+
broker_url: str | None = None,
|
|
23
|
+
result_backend: str | None = None,
|
|
24
|
+
max_tasks: int = 20,
|
|
25
|
+
disable_prefetch: bool = True,
|
|
26
|
+
**celery_options: Any,
|
|
27
|
+
) -> None:
|
|
28
|
+
if max_tasks < 1:
|
|
29
|
+
raise ValueError("max_tasks must be greater than zero")
|
|
30
|
+
|
|
31
|
+
self.app: Flask | None = None
|
|
32
|
+
self.celery: Celery
|
|
33
|
+
|
|
34
|
+
self.max_tasks = max_tasks
|
|
35
|
+
self.disable_prefetch = disable_prefetch
|
|
36
|
+
|
|
37
|
+
if celery is not None:
|
|
38
|
+
self.celery = celery
|
|
39
|
+
else:
|
|
40
|
+
self.celery = Celery(
|
|
41
|
+
app.import_name if app is not None else "flask_async_celery",
|
|
42
|
+
broker=broker_url,
|
|
43
|
+
backend=result_backend,
|
|
44
|
+
**celery_options,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self._configure()
|
|
48
|
+
|
|
49
|
+
if app is not None:
|
|
50
|
+
self.init_app(app)
|
|
51
|
+
|
|
52
|
+
def init_app(self, app: Flask) -> None:
|
|
53
|
+
self.app = app
|
|
54
|
+
|
|
55
|
+
app.extensions["async_celery"] = self
|
|
56
|
+
|
|
57
|
+
app.config.setdefault(
|
|
58
|
+
"ASYNC_CELERY_MAX_TASKS",
|
|
59
|
+
self.max_tasks,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
app.config.setdefault(
|
|
63
|
+
"ASYNC_CELERY_DISABLE_PREFETCH",
|
|
64
|
+
self.disable_prefetch,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.max_tasks = int(
|
|
68
|
+
app.config["ASYNC_CELERY_MAX_TASKS"]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.disable_prefetch = bool(
|
|
72
|
+
app.config["ASYNC_CELERY_DISABLE_PREFETCH"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.max_tasks < 1:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"ASYNC_CELERY_MAX_TASKS must be greater than zero"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self._configure()
|
|
81
|
+
|
|
82
|
+
def _configure(self) -> None:
|
|
83
|
+
self.celery.conf.update(
|
|
84
|
+
worker_pool="flask_async_celery.pool:AsyncIOPool",
|
|
85
|
+
worker_concurrency=self.max_tasks,
|
|
86
|
+
worker_disable_prefetch=self.disable_prefetch,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def task(self, *args: Any, **kwargs: Any):
|
|
90
|
+
"""
|
|
91
|
+
Register a Celery task.
|
|
92
|
+
|
|
93
|
+
Usage:
|
|
94
|
+
|
|
95
|
+
@celery.task
|
|
96
|
+
async def task():
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
or:
|
|
100
|
+
|
|
101
|
+
@celery.task(base=AsyncTask)
|
|
102
|
+
async def task():
|
|
103
|
+
...
|
|
104
|
+
"""
|
|
105
|
+
return self.celery.task(*args, **kwargs)
|
|
106
|
+
|
|
107
|
+
def send_task(self, *args: Any, **kwargs: Any):
|
|
108
|
+
return self.celery.send_task(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
def __getattr__(self, name: str) -> Any:
|
|
111
|
+
return getattr(self.celery, name)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
import time
|
|
8
|
+
from celery.concurrency.base import BasePool, apply_target
|
|
9
|
+
|
|
10
|
+
from .executor import AsyncExecutor
|
|
11
|
+
from .task import reset_async_executor, set_async_executor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApplyResult:
|
|
17
|
+
"""
|
|
18
|
+
Celery-compatible result wrapper around a Future.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, future: Future) -> None:
|
|
22
|
+
self.f = future
|
|
23
|
+
self.get = future.result
|
|
24
|
+
|
|
25
|
+
def wait(self, timeout: float | None = None) -> None:
|
|
26
|
+
wait([self.f], timeout)
|
|
27
|
+
|
|
28
|
+
def ready(self) -> bool:
|
|
29
|
+
return self.f.done()
|
|
30
|
+
|
|
31
|
+
def successful(self) -> bool:
|
|
32
|
+
if not self.f.done():
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
return self.f.exception() is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncIOPool(BasePool):
|
|
39
|
+
"""
|
|
40
|
+
Celery execution pool for asyncio tasks.
|
|
41
|
+
|
|
42
|
+
There are deliberately TWO execution layers:
|
|
43
|
+
|
|
44
|
+
Celery bridge threads
|
|
45
|
+
|
|
|
46
|
+
v
|
|
47
|
+
Celery trace function
|
|
48
|
+
|
|
|
49
|
+
v
|
|
50
|
+
AsyncIO executor
|
|
51
|
+
|
|
|
52
|
+
v
|
|
53
|
+
persistent asyncio event loop
|
|
54
|
+
|
|
55
|
+
The bridge threads are necessary because Celery's tracing
|
|
56
|
+
function is synchronous.
|
|
57
|
+
|
|
58
|
+
The asyncio executor is where async task bodies actually run.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
signal_safe = False
|
|
62
|
+
is_green = False
|
|
63
|
+
body_can_be_buffer = True
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*args: Any,
|
|
68
|
+
max_tasks: int | None = None,
|
|
69
|
+
thread_name: str = "celery-asyncio",
|
|
70
|
+
**kwargs: Any,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
configured_limit = (
|
|
75
|
+
max_tasks
|
|
76
|
+
if max_tasks is not None
|
|
77
|
+
else self.limit
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if configured_limit is None:
|
|
81
|
+
configured_limit = 20
|
|
82
|
+
|
|
83
|
+
self.max_tasks = int(configured_limit)
|
|
84
|
+
|
|
85
|
+
if self.max_tasks < 1:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"AsyncIOPool max_tasks must be greater than zero"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Persistent asyncio event loop.
|
|
91
|
+
self.async_executor = AsyncExecutor(
|
|
92
|
+
max_tasks=self.max_tasks,
|
|
93
|
+
thread_name=thread_name,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Celery tracing must NOT run on the asyncio event-loop
|
|
97
|
+
# thread. These threads execute Celery's synchronous trace
|
|
98
|
+
# function.
|
|
99
|
+
self.executor = ThreadPoolExecutor(
|
|
100
|
+
max_workers=self.max_tasks,
|
|
101
|
+
thread_name_prefix="celery-async-bridge",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
logger.info(
|
|
105
|
+
"AsyncIOPool initialized: max_tasks=%s",
|
|
106
|
+
self.max_tasks,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def num_processes(self) -> int:
|
|
111
|
+
return self.max_tasks
|
|
112
|
+
|
|
113
|
+
def on_start(self) -> None:
|
|
114
|
+
logger.info(
|
|
115
|
+
"Starting AsyncIOPool: max_tasks=%s",
|
|
116
|
+
self.max_tasks,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self.async_executor.start()
|
|
120
|
+
|
|
121
|
+
super().on_start()
|
|
122
|
+
|
|
123
|
+
def on_stop(self) -> None:
|
|
124
|
+
logger.info("Stopping AsyncIOPool")
|
|
125
|
+
|
|
126
|
+
self.executor.shutdown(
|
|
127
|
+
wait=True,
|
|
128
|
+
cancel_futures=False,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self.async_executor.shutdown(
|
|
132
|
+
wait=True,
|
|
133
|
+
timeout=10,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
super().on_stop()
|
|
137
|
+
|
|
138
|
+
def on_terminate(self) -> None:
|
|
139
|
+
logger.warning("Terminating AsyncIOPool")
|
|
140
|
+
|
|
141
|
+
self.executor.shutdown(
|
|
142
|
+
wait=False,
|
|
143
|
+
cancel_futures=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self.async_executor.shutdown(
|
|
147
|
+
wait=False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
super().on_terminate()
|
|
151
|
+
|
|
152
|
+
def on_apply(
|
|
153
|
+
self,
|
|
154
|
+
target: Callable[..., Any],
|
|
155
|
+
args: tuple[Any, ...] | None = None,
|
|
156
|
+
kwargs: dict[str, Any] | None = None,
|
|
157
|
+
callback: Callable[..., Any] | None = None,
|
|
158
|
+
accept_callback: Callable[..., Any] | None = None,
|
|
159
|
+
**options: Any,
|
|
160
|
+
) -> ApplyResult:
|
|
161
|
+
"""
|
|
162
|
+
Submit Celery's tracing function to a bridge thread.
|
|
163
|
+
|
|
164
|
+
The bridge thread executes Celery's normal synchronous
|
|
165
|
+
tracing machinery.
|
|
166
|
+
|
|
167
|
+
AsyncTask then detects the coroutine and submits it to
|
|
168
|
+
the persistent asyncio event loop.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
args = args or ()
|
|
172
|
+
kwargs = kwargs or {}
|
|
173
|
+
|
|
174
|
+
if not self.async_executor.is_running:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
"AsyncIO executor is not running"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
future = self.executor.submit(
|
|
180
|
+
self._run_celery_target,
|
|
181
|
+
target,
|
|
182
|
+
args,
|
|
183
|
+
kwargs,
|
|
184
|
+
callback,
|
|
185
|
+
accept_callback,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return ApplyResult(future)
|
|
189
|
+
|
|
190
|
+
def _run_celery_target(
|
|
191
|
+
self,
|
|
192
|
+
target: Callable[..., Any],
|
|
193
|
+
args: tuple[Any, ...],
|
|
194
|
+
kwargs: dict[str, Any],
|
|
195
|
+
callback: Callable[..., Any] | None,
|
|
196
|
+
accept_callback: Callable[..., Any] | None,
|
|
197
|
+
) -> Any:
|
|
198
|
+
token = set_async_executor(self.async_executor)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if accept_callback:
|
|
202
|
+
accept_callback(os.getpid(), time.monotonic())
|
|
203
|
+
|
|
204
|
+
result = target(*args, **kwargs)
|
|
205
|
+
|
|
206
|
+
# Async Celery target:
|
|
207
|
+
# run the coroutine on the persistent asyncio event loop.
|
|
208
|
+
if inspect.isawaitable(result):
|
|
209
|
+
future = self.async_executor.submit(result)
|
|
210
|
+
result = future.result()
|
|
211
|
+
|
|
212
|
+
if callback:
|
|
213
|
+
callback(result)
|
|
214
|
+
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
except BaseException:
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
finally:
|
|
221
|
+
reset_async_executor(token)
|
|
222
|
+
|
|
223
|
+
def _get_info(self) -> dict[str, Any]:
|
|
224
|
+
info = super()._get_info()
|
|
225
|
+
|
|
226
|
+
info.update(
|
|
227
|
+
{
|
|
228
|
+
"max-concurrency": self.max_tasks,
|
|
229
|
+
"asyncio-running": self.async_executor.running,
|
|
230
|
+
"asyncio-available": self.async_executor.available,
|
|
231
|
+
"bridge-threads": len(self.executor._threads),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return info
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from celery import Task
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_async_executor: ContextVar[Any | None] = ContextVar(
|
|
11
|
+
"flask_async_celery_executor",
|
|
12
|
+
default=None,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_async_executor(executor: Any):
|
|
17
|
+
return _async_executor.set(executor)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reset_async_executor(token) -> None:
|
|
21
|
+
_async_executor.reset(token)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_async_executor() -> Any | None:
|
|
25
|
+
return _async_executor.get()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncTask(Task):
|
|
29
|
+
abstract = True
|
|
30
|
+
|
|
31
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
if not inspect.iscoroutinefunction(self.run):
|
|
33
|
+
return super().__call__(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
executor = get_async_executor()
|
|
36
|
+
if executor is None:
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
"AsyncTask requires an AsyncExecutor. "
|
|
39
|
+
"Make sure the task is running inside AsyncIOPool."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Capture the real Celery request while we're still on
|
|
43
|
+
# the Celery worker/bridge thread.
|
|
44
|
+
request = self.request
|
|
45
|
+
|
|
46
|
+
async def execute_with_request() -> Any:
|
|
47
|
+
# Restore the Celery request inside the asyncio thread.
|
|
48
|
+
self.push_request(**request.__dict__)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return await self.run(*args, **kwargs)
|
|
52
|
+
finally:
|
|
53
|
+
self.pop_request()
|
|
54
|
+
|
|
55
|
+
coroutine = execute_with_request()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
future = executor.submit(coroutine)
|
|
59
|
+
return future.result()
|
|
60
|
+
except BaseException:
|
|
61
|
+
if inspect.iscoroutine(coroutine):
|
|
62
|
+
coroutine.close()
|
|
63
|
+
raise
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flask-async-celery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AsyncIO execution pool for Celery with Flask integration
|
|
5
|
+
Author: Mazhar Ali
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: celery,asyncio,flask,async,tasks,redis
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: Flask
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: celery<5.7,>=5.6
|
|
22
|
+
Requires-Dist: Flask>=2.3
|
|
23
|
+
Provides-Extra: redis
|
|
24
|
+
Requires-Dist: redis>=5.0; extra == "redis"
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
33
|
+
|
|
34
|
+
# Flask Async Celery
|
|
35
|
+
|
|
36
|
+
Run `async def` Celery tasks on a persistent asyncio event loop with bounded concurrency, Flask integration, and Celery consumer backpressure.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
* Persistent `asyncio` event loop in a dedicated thread per Celery worker process.
|
|
41
|
+
* Run native `async def` Celery tasks.
|
|
42
|
+
* Bounded asynchronous concurrency with `max_tasks`.
|
|
43
|
+
* Celery request context propagation into the asyncio execution thread.
|
|
44
|
+
* `self.retry()` support for async tasks.
|
|
45
|
+
* Normal synchronous Celery tasks continue to work.
|
|
46
|
+
* Redis consumer-side backpressure through Celery's `worker_disable_prefetch`.
|
|
47
|
+
* Flask extension with simple configuration.
|
|
48
|
+
* Graceful asyncio executor shutdown.
|
|
49
|
+
* Compatible with Celery 5.6.x and Python 3.10+.
|
|
50
|
+
|
|
51
|
+
## Architecture
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
Redis
|
|
55
|
+
│
|
|
56
|
+
▼
|
|
57
|
+
Celery Consumer
|
|
58
|
+
│
|
|
59
|
+
worker_disable_prefetch
|
|
60
|
+
│
|
|
61
|
+
▼
|
|
62
|
+
AsyncIOPool
|
|
63
|
+
max_tasks = N
|
|
64
|
+
│
|
|
65
|
+
▼
|
|
66
|
+
AsyncExecutor
|
|
67
|
+
asyncio.Semaphore(N)
|
|
68
|
+
│
|
|
69
|
+
Persistent loop
|
|
70
|
+
│
|
|
71
|
+
┌────────────┼────────────┐
|
|
72
|
+
▼ ▼ ▼
|
|
73
|
+
Async task Async task Async task
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The package separates Celery's worker execution from asyncio execution:
|
|
77
|
+
|
|
78
|
+
1. Celery receives and traces the task.
|
|
79
|
+
2. `AsyncIOPool` bridges Celery execution into the asyncio executor.
|
|
80
|
+
3. `AsyncExecutor` owns a persistent asyncio event loop.
|
|
81
|
+
4. A semaphore limits active async tasks.
|
|
82
|
+
5. Celery's Redis `worker_disable_prefetch` option can prevent the consumer from reserving work beyond the available pool capacity.
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
* Python 3.10+
|
|
87
|
+
* Celery 5.6.x
|
|
88
|
+
* Flask 2.3+
|
|
89
|
+
* Redis when using the Redis broker/result backend and consumer backpressure.
|
|
90
|
+
|
|
91
|
+
## Installation
|
|
92
|
+
|
|
93
|
+
From PyPI:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install flask-async-celery
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
For Redis support:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install "flask-async-celery[redis]"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For development and testing:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install "flask-async-celery[test]"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Basic Flask Setup
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import asyncio
|
|
115
|
+
|
|
116
|
+
from flask import Flask
|
|
117
|
+
|
|
118
|
+
from flask_async_celery import AsyncCelery, AsyncTask
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
app = Flask(__name__)
|
|
122
|
+
|
|
123
|
+
celery = AsyncCelery(
|
|
124
|
+
app,
|
|
125
|
+
broker_url="redis://127.0.0.1:6379/0",
|
|
126
|
+
result_backend="redis://127.0.0.1:6379/1",
|
|
127
|
+
max_tasks=5,
|
|
128
|
+
disable_prefetch=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@celery.task(base=AsyncTask)
|
|
133
|
+
async def my_task(value):
|
|
134
|
+
await asyncio.sleep(1)
|
|
135
|
+
return value * 2
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Send the task normally:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = my_task.delay(10)
|
|
142
|
+
|
|
143
|
+
print(result.get(timeout=30))
|
|
144
|
+
# 20
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Flask Configuration
|
|
148
|
+
|
|
149
|
+
Configuration can be supplied through Flask:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
app.config["ASYNC_CELERY_MAX_TASKS"] = 10
|
|
153
|
+
app.config["ASYNC_CELERY_DISABLE_PREFETCH"] = True
|
|
154
|
+
|
|
155
|
+
celery = AsyncCelery(
|
|
156
|
+
app,
|
|
157
|
+
broker_url="redis://127.0.0.1:6379/0",
|
|
158
|
+
result_backend="redis://127.0.0.1:6379/1",
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Available settings:
|
|
163
|
+
|
|
164
|
+
| Setting | Default | Description |
|
|
165
|
+
| ------------------------------- | ------: | ----------------------------------------------------- |
|
|
166
|
+
| `ASYNC_CELERY_MAX_TASKS` | `20` | Maximum number of concurrently executing async tasks. |
|
|
167
|
+
| `ASYNC_CELERY_DISABLE_PREFETCH` | `True` | Enables Celery consumer-side backpressure. |
|
|
168
|
+
|
|
169
|
+
Constructor arguments can also be used directly:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
celery = AsyncCelery(
|
|
173
|
+
app,
|
|
174
|
+
max_tasks=10,
|
|
175
|
+
disable_prefetch=True,
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Flask configuration takes precedence over the constructor defaults when the extension is initialized.
|
|
180
|
+
|
|
181
|
+
## Worker Configuration
|
|
182
|
+
|
|
183
|
+
Run the worker using the package's custom pool:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
celery -A your_app.celery worker \
|
|
187
|
+
-P flask_async_celery.pool:AsyncIOPool \
|
|
188
|
+
-c 5 \
|
|
189
|
+
--loglevel=INFO
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
For example:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
celery -A your_app.celery worker \
|
|
196
|
+
-P flask_async_celery.pool:AsyncIOPool \
|
|
197
|
+
-c 10 \
|
|
198
|
+
--loglevel=INFO
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The `-c` value should match the desired async concurrency.
|
|
202
|
+
|
|
203
|
+
The custom pool exposes its configured concurrency through `num_processes`, allowing Celery's consumer to use the same capacity when consumer-side prefetch is disabled.
|
|
204
|
+
|
|
205
|
+
## Concurrency
|
|
206
|
+
|
|
207
|
+
Set the maximum number of simultaneously executing async tasks with:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
celery = AsyncCelery(
|
|
211
|
+
app,
|
|
212
|
+
max_tasks=5,
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
With:
|
|
217
|
+
|
|
218
|
+
```text
|
|
219
|
+
max_tasks = 5
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
the asyncio executor allows at most five active coroutines at once.
|
|
223
|
+
|
|
224
|
+
Additional work waits for an available execution slot.
|
|
225
|
+
|
|
226
|
+
This is different from simply creating more threads. The package uses one persistent asyncio event loop and runs async coroutines concurrently on that loop.
|
|
227
|
+
|
|
228
|
+
## Consumer Backpressure
|
|
229
|
+
|
|
230
|
+
For Redis, Celery 5.6 supports:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
worker_disable_prefetch = True
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The extension enables this by default:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
celery = AsyncCelery(
|
|
240
|
+
app,
|
|
241
|
+
max_tasks=5,
|
|
242
|
+
disable_prefetch=True,
|
|
243
|
+
)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This provides two levels of protection:
|
|
247
|
+
|
|
248
|
+
```text
|
|
249
|
+
Celery Consumer
|
|
250
|
+
│
|
|
251
|
+
│ Don't reserve beyond available capacity
|
|
252
|
+
▼
|
|
253
|
+
AsyncIOPool
|
|
254
|
+
│
|
|
255
|
+
│ max_tasks
|
|
256
|
+
▼
|
|
257
|
+
AsyncExecutor
|
|
258
|
+
│
|
|
259
|
+
│ semaphore
|
|
260
|
+
▼
|
|
261
|
+
asyncio tasks
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
You can disable the consumer-side behavior:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
celery = AsyncCelery(
|
|
268
|
+
app,
|
|
269
|
+
max_tasks=5,
|
|
270
|
+
disable_prefetch=False,
|
|
271
|
+
)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
When disabled, the asyncio executor still enforces its own concurrency limit.
|
|
275
|
+
|
|
276
|
+
### Redis Requirement
|
|
277
|
+
|
|
278
|
+
`worker_disable_prefetch` is intended for supported Redis worker configurations. If you use another broker, verify that your Celery version and broker transport support this feature before relying on consumer-side backpressure.
|
|
279
|
+
|
|
280
|
+
The executor-level concurrency limit remains independent of consumer prefetch behavior.
|
|
281
|
+
|
|
282
|
+
## Async Tasks
|
|
283
|
+
|
|
284
|
+
Use `AsyncTask` as the base class for native async tasks:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from flask_async_celery import AsyncTask
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@celery.task(base=AsyncTask)
|
|
291
|
+
async def fetch_data():
|
|
292
|
+
await some_async_operation()
|
|
293
|
+
return "done"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The task can use normal Celery task features:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
@celery.task(
|
|
300
|
+
base=AsyncTask,
|
|
301
|
+
bind=True,
|
|
302
|
+
max_retries=3,
|
|
303
|
+
)
|
|
304
|
+
async def process_item(self, item_id):
|
|
305
|
+
try:
|
|
306
|
+
return await process(item_id)
|
|
307
|
+
except TemporaryError as exc:
|
|
308
|
+
raise self.retry(
|
|
309
|
+
exc=exc,
|
|
310
|
+
countdown=5,
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The Celery request context is propagated from the Celery worker thread into the asyncio execution thread, so task information such as the task ID, retry count, delivery information, and retry context remains available.
|
|
315
|
+
|
|
316
|
+
## Synchronous Tasks
|
|
317
|
+
|
|
318
|
+
Normal synchronous tasks can still be used:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
@celery.task
|
|
322
|
+
def sync_task(value):
|
|
323
|
+
return value * 2
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The package does not require every task to be asynchronous.
|
|
327
|
+
|
|
328
|
+
## Retries
|
|
329
|
+
|
|
330
|
+
Async `self.retry()` is supported:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
@celery.task(
|
|
334
|
+
base=AsyncTask,
|
|
335
|
+
bind=True,
|
|
336
|
+
max_retries=3,
|
|
337
|
+
)
|
|
338
|
+
async def retrying_task(self):
|
|
339
|
+
if should_retry():
|
|
340
|
+
raise self.retry(countdown=5)
|
|
341
|
+
|
|
342
|
+
return "success"
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The Celery task request is preserved when execution moves from the Celery worker thread to the asyncio event loop. This allows Celery retry metadata and delivery information to remain available to the async task.
|
|
346
|
+
|
|
347
|
+
## Exceptions
|
|
348
|
+
|
|
349
|
+
Exceptions raised by an async task propagate through the Celery execution path:
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
@celery.task(base=AsyncTask)
|
|
353
|
+
async def failing_task():
|
|
354
|
+
raise RuntimeError("something went wrong")
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Celery remains responsible for task failure state, result handling, retry behavior, and worker-level task tracing.
|
|
358
|
+
|
|
359
|
+
## Graceful Shutdown
|
|
360
|
+
|
|
361
|
+
The asyncio executor runs in a dedicated daemon thread.
|
|
362
|
+
|
|
363
|
+
When the pool stops, the executor:
|
|
364
|
+
|
|
365
|
+
1. Stops accepting new work.
|
|
366
|
+
2. Stops the asyncio event loop.
|
|
367
|
+
3. Cancels pending asyncio tasks.
|
|
368
|
+
4. Waits for the loop thread to terminate.
|
|
369
|
+
5. Closes the event loop.
|
|
370
|
+
|
|
371
|
+
## Public API
|
|
372
|
+
|
|
373
|
+
The main public API is intentionally small:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from flask_async_celery import AsyncCelery, AsyncTask
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### `AsyncCelery`
|
|
380
|
+
|
|
381
|
+
Flask integration and Celery configuration.
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
AsyncCelery(
|
|
385
|
+
app=None,
|
|
386
|
+
*,
|
|
387
|
+
celery=None,
|
|
388
|
+
broker_url=None,
|
|
389
|
+
result_backend=None,
|
|
390
|
+
max_tasks=20,
|
|
391
|
+
disable_prefetch=True,
|
|
392
|
+
)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### `AsyncTask`
|
|
396
|
+
|
|
397
|
+
Base class for asynchronous Celery tasks:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
@celery.task(base=AsyncTask)
|
|
401
|
+
async def my_task():
|
|
402
|
+
...
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Development
|
|
406
|
+
|
|
407
|
+
Clone the repository and install the project in editable mode:
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
git clone <repository-url>
|
|
411
|
+
cd flask-async-celery
|
|
412
|
+
|
|
413
|
+
pip install -e ".[test]"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Run the test suite:
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
pytest -v
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The test suite covers:
|
|
423
|
+
|
|
424
|
+
* asyncio executor concurrency
|
|
425
|
+
* AsyncIOPool execution
|
|
426
|
+
* async task exceptions
|
|
427
|
+
* async retries
|
|
428
|
+
* synchronous retries
|
|
429
|
+
* real Celery worker integration
|
|
430
|
+
* Redis consumer backpressure
|
|
431
|
+
* Flask extension configuration
|
|
432
|
+
* end-to-end Flask/Celery/async execution
|
|
433
|
+
|
|
434
|
+
## Project Structure
|
|
435
|
+
|
|
436
|
+
```text
|
|
437
|
+
flask-async-celery/
|
|
438
|
+
├── pyproject.toml
|
|
439
|
+
├── README.md
|
|
440
|
+
├── src/
|
|
441
|
+
│ └── flask_async_celery/
|
|
442
|
+
│ ├── __init__.py
|
|
443
|
+
│ ├── extension.py
|
|
444
|
+
│ ├── executor.py
|
|
445
|
+
│ ├── bootstep.py
|
|
446
|
+
│ ├── pool.py
|
|
447
|
+
│ └── task.py
|
|
448
|
+
└── test/
|
|
449
|
+
├── conftest.py
|
|
450
|
+
├── test_executor.py
|
|
451
|
+
├── test_tasks.py
|
|
452
|
+
├── test_backpressure.py
|
|
453
|
+
├── test_celery_pool.py
|
|
454
|
+
├── test_worker_integration.py
|
|
455
|
+
├── test_extension.py
|
|
456
|
+
└── test_extension_integration.py
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Design Notes
|
|
460
|
+
|
|
461
|
+
This package does not replace Celery's task tracing and lifecycle handling.
|
|
462
|
+
|
|
463
|
+
Celery remains responsible for:
|
|
464
|
+
|
|
465
|
+
* task delivery
|
|
466
|
+
* task acknowledgment
|
|
467
|
+
* retries
|
|
468
|
+
* result state
|
|
469
|
+
* task IDs
|
|
470
|
+
* worker lifecycle
|
|
471
|
+
* task tracing
|
|
472
|
+
|
|
473
|
+
The package provides the asyncio execution layer and integrates it with Celery's pool interface.
|
|
474
|
+
|
|
475
|
+
The asyncio event loop is persistent for the lifetime of the worker process rather than creating a new event loop for every task.
|
|
476
|
+
|
|
477
|
+
### Why a Persistent Event Loop?
|
|
478
|
+
|
|
479
|
+
Creating a new event loop for every task adds unnecessary setup and teardown overhead.
|
|
480
|
+
|
|
481
|
+
Instead, each worker process owns one persistent asyncio event loop:
|
|
482
|
+
|
|
483
|
+
```text
|
|
484
|
+
Celery Worker Process
|
|
485
|
+
│
|
|
486
|
+
├── Celery Consumer
|
|
487
|
+
├── Celery task execution
|
|
488
|
+
├── bridge threads
|
|
489
|
+
│
|
|
490
|
+
└── asyncio event loop thread
|
|
491
|
+
├── Task A
|
|
492
|
+
├── Task B
|
|
493
|
+
└── Task C
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Async tasks can therefore share the same event loop while still being bounded by `max_tasks`.
|
|
497
|
+
|
|
498
|
+
### Why Request Propagation?
|
|
499
|
+
|
|
500
|
+
Celery's request context is associated with the worker execution context.
|
|
501
|
+
|
|
502
|
+
The asyncio event loop runs in a separate thread, so the package explicitly transfers the current Celery request into that execution context.
|
|
503
|
+
|
|
504
|
+
This preserves information required by features such as:
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
self.request.id
|
|
508
|
+
self.request.retries
|
|
509
|
+
self.request.delivery_info
|
|
510
|
+
self.retry()
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The request is pushed before async execution and removed afterward.
|
|
514
|
+
|
|
515
|
+
## Limitations
|
|
516
|
+
|
|
517
|
+
### Broker-specific Backpressure
|
|
518
|
+
|
|
519
|
+
Consumer-side `worker_disable_prefetch` support depends on Celery and the broker transport.
|
|
520
|
+
|
|
521
|
+
The asyncio executor's own `max_tasks` limit remains the final execution boundary.
|
|
522
|
+
|
|
523
|
+
### Worker Pool
|
|
524
|
+
|
|
525
|
+
The worker must use:
|
|
526
|
+
|
|
527
|
+
```text
|
|
528
|
+
flask_async_celery.pool:AsyncIOPool
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
for the package's asyncio execution model.
|
|
532
|
+
|
|
533
|
+
### One Event Loop Per Worker Process
|
|
534
|
+
|
|
535
|
+
Each worker process owns its own asyncio event loop and concurrency limit.
|
|
536
|
+
|
|
537
|
+
For example:
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
celery -A your_app.celery worker \
|
|
541
|
+
-P flask_async_celery.pool:AsyncIOPool \
|
|
542
|
+
-c 5
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
creates a worker configuration with five execution slots.
|
|
546
|
+
|
|
547
|
+
If you run multiple worker processes, each process has its own pool and event loop.
|
|
548
|
+
|
|
549
|
+
## Testing
|
|
550
|
+
|
|
551
|
+
Run the complete test suite:
|
|
552
|
+
|
|
553
|
+
```bash
|
|
554
|
+
pytest -v
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The project currently tests the execution model with real Celery workers in addition to unit-level executor and pool tests.
|
|
558
|
+
|
|
559
|
+
A successful test run should show all tests passing.
|
|
560
|
+
|
|
561
|
+
## Building the Package
|
|
562
|
+
|
|
563
|
+
Install the build tooling:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
python -m pip install build
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Build the source distribution and wheel:
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
python -m build
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
The generated files will be placed in:
|
|
576
|
+
|
|
577
|
+
```text
|
|
578
|
+
dist/
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
You can inspect the generated wheel with:
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
python -m pip install dist/*.whl
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
## Version
|
|
588
|
+
|
|
589
|
+
Current version:
|
|
590
|
+
|
|
591
|
+
```text
|
|
592
|
+
0.1.0
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## License
|
|
596
|
+
|
|
597
|
+
Add the project's license information here.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flask_async_celery/__init__.py,sha256=GzLJSzIzc8yRJqvgYLVEKXKonmjiEGnm84TZTTdB4Rw,136
|
|
2
|
+
flask_async_celery/bootstep.py,sha256=b_FZs1h5Y7yyxEfeqWXnUf_1llps7MEseBBTr8vYwsU,2271
|
|
3
|
+
flask_async_celery/executor.py,sha256=UkgVsnKo7hI4scqDzTD__NDwriWJeVmfeTmdbOwdCq4,6527
|
|
4
|
+
flask_async_celery/extension.py,sha256=J0wTGCZNNJNv0ldaLV3NcbDfireHL2vwwQc2ltucH4w,2771
|
|
5
|
+
flask_async_celery/pool.py,sha256=bLLDOSde5dBeEoeBKsEBFomDV8ssImppY3RQscW5HqI,5905
|
|
6
|
+
flask_async_celery/task.py,sha256=VB56Br5J2A7BF8Epu3ilLKOQCyC9FDNsu1E7NgZX5lk,1633
|
|
7
|
+
flask_async_celery-0.1.0.dist-info/METADATA,sha256=8vxvwlmlk__OcxT76elhuC4FNBNV6ClPD3wiKOC1kc4,13645
|
|
8
|
+
flask_async_celery-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
flask_async_celery-0.1.0.dist-info/top_level.txt,sha256=RCoWK-DxiJ1nM8PZ-OvlEd-UqoX5tZscHm_cnjFww6s,19
|
|
10
|
+
flask_async_celery-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flask_async_celery
|