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.
@@ -0,0 +1,9 @@
1
+ from .extension import AsyncCelery
2
+ from .task import AsyncTask
3
+
4
+ __all__ = [
5
+ "AsyncCelery",
6
+ "AsyncTask",
7
+ ]
8
+
9
+ __version__ = "0.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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flask_async_celery