queue-max 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.
- queue_max/__init__.py +62 -0
- queue_max/cli.py +373 -0
- queue_max/contrib/__init__.py +7 -0
- queue_max/contrib/django/__init__.py +61 -0
- queue_max/contrib/django/management/__init__.py +0 -0
- queue_max/contrib/django/management/commands/__init__.py +0 -0
- queue_max/contrib/django/management/commands/queue_purge.py +19 -0
- queue_max/contrib/django/management/commands/queue_stats.py +39 -0
- queue_max/contrib/django/management/commands/queue_worker.py +69 -0
- queue_max/contrib/fastapi/__init__.py +117 -0
- queue_max/contrib/flask/__init__.py +99 -0
- queue_max/core/__init__.py +16 -0
- queue_max/core/circuit_breaker.py +162 -0
- queue_max/core/database.py +253 -0
- queue_max/core/decorator.py +346 -0
- queue_max/core/queue.py +420 -0
- queue_max/core/rate_limiter.py +214 -0
- queue_max/core/worker.py +426 -0
- queue_max/exceptions.py +25 -0
- queue_max/models/__init__.py +5 -0
- queue_max/models/job.py +340 -0
- queue_max/py.typed +0 -0
- queue_max/utils/__init__.py +23 -0
- queue_max/utils/helpers.py +156 -0
- queue_max-0.1.0.dist-info/METADATA +233 -0
- queue_max-0.1.0.dist-info/RECORD +30 -0
- queue_max-0.1.0.dist-info/WHEEL +5 -0
- queue_max-0.1.0.dist-info/entry_points.txt +2 -0
- queue_max-0.1.0.dist-info/licenses/LICENSE +21 -0
- queue_max-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""@task decorator for Queue Max.
|
|
2
|
+
|
|
3
|
+
Converts regular functions into enqueuable tasks with support for:
|
|
4
|
+
- Synchronous execution (direct call)
|
|
5
|
+
- Background execution (.delay())
|
|
6
|
+
- Scheduled execution (.schedule_at(), .schedule_in())
|
|
7
|
+
- Parallel map (.map())
|
|
8
|
+
- Bulk operations (.bulk_delay())
|
|
9
|
+
- Argument validation
|
|
10
|
+
- Task versioning
|
|
11
|
+
- Timeout support
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from queue_max.core.queue import Queue
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("queue_max.task")
|
|
24
|
+
|
|
25
|
+
def _is_serializable(obj: Any) -> bool:
|
|
26
|
+
"""Check if an object is JSON-serializable."""
|
|
27
|
+
try:
|
|
28
|
+
json.dumps(obj)
|
|
29
|
+
return True
|
|
30
|
+
except (TypeError, OverflowError):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def task(
|
|
34
|
+
queue: Optional[Queue] = None,
|
|
35
|
+
priority: int = 0,
|
|
36
|
+
max_retries: Optional[int] = None,
|
|
37
|
+
rate_limit: Optional[int] = None,
|
|
38
|
+
timeout: Optional[int] = None,
|
|
39
|
+
retry_delay: int = 60,
|
|
40
|
+
on_success: Optional[Callable] = None,
|
|
41
|
+
on_failure: Optional[Callable] = None,
|
|
42
|
+
version: int = 1,
|
|
43
|
+
) -> Callable:
|
|
44
|
+
"""Decorator that converts a function into an enqueuable task.
|
|
45
|
+
|
|
46
|
+
The decorated function can be called directly (synchronous execution)
|
|
47
|
+
or via .delay() to enqueue it for background processing.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
queue: Queue instance (creates a new one if None).
|
|
51
|
+
priority: Default priority (0=low, 1=medium, 2=high).
|
|
52
|
+
max_retries: Max retry attempts (default: queue default).
|
|
53
|
+
rate_limit: Optional rate limit override for the queue.
|
|
54
|
+
timeout: Max execution time in seconds (Unix only).
|
|
55
|
+
retry_delay: Base delay in seconds for retry backoff.
|
|
56
|
+
on_success: Callback on successful completion.
|
|
57
|
+
on_failure: Callback on task failure.
|
|
58
|
+
version: Task version for schema migrations.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Decorated function with .delay(), .schedule_at(), .map(), etc.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
@task(priority=2, timeout=30)
|
|
65
|
+
def send_email(to: str, subject: str):
|
|
66
|
+
return send(to, subject)
|
|
67
|
+
|
|
68
|
+
# Enqueue for background processing
|
|
69
|
+
send_email.delay("user@example.com", "Hello!")
|
|
70
|
+
|
|
71
|
+
# Schedule for later
|
|
72
|
+
from datetime import datetime, timedelta
|
|
73
|
+
future = datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
74
|
+
send_email.schedule_at(future, "user@example.com", "Hello!")
|
|
75
|
+
|
|
76
|
+
# Parallel processing
|
|
77
|
+
send_email.map(["a@b.com", "c@d.com"], "Welcome!")
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def decorator(func: Callable) -> Callable:
|
|
81
|
+
_queue = queue or Queue(rate_limit=rate_limit)
|
|
82
|
+
task_name = f"{func.__module__}.{func.__name__}"
|
|
83
|
+
sig = inspect.signature(func)
|
|
84
|
+
|
|
85
|
+
@functools.wraps(func)
|
|
86
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
87
|
+
"""Execute the task synchronously."""
|
|
88
|
+
try:
|
|
89
|
+
sig.bind(*args, **kwargs)
|
|
90
|
+
|
|
91
|
+
if timeout is not None:
|
|
92
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
93
|
+
|
|
94
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
95
|
+
future = executor.submit(func, *args, **kwargs)
|
|
96
|
+
try:
|
|
97
|
+
result = future.result(timeout=timeout)
|
|
98
|
+
except FuturesTimeoutError:
|
|
99
|
+
raise TimeoutError(
|
|
100
|
+
f"Task {task_name} exceeded {timeout}s timeout"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
result = func(*args, **kwargs)
|
|
104
|
+
|
|
105
|
+
if on_success:
|
|
106
|
+
try:
|
|
107
|
+
on_success(result, task_name=task_name)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error("on_success callback failed: %s", e)
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if on_failure:
|
|
115
|
+
try:
|
|
116
|
+
on_failure(e, task_name=task_name)
|
|
117
|
+
except Exception as cb_err:
|
|
118
|
+
logger.error("on_failure callback failed: %s", cb_err)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def delay(*args, **kwargs) -> Dict[str, Any]:
|
|
122
|
+
"""Enqueue the function for background processing.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with 'id' (job ID) and 'shard_id' (assigned shard).
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
sig.bind(*args, **kwargs)
|
|
129
|
+
except TypeError as e:
|
|
130
|
+
raise TypeError(f"Invalid arguments for {task_name}: {e}")
|
|
131
|
+
|
|
132
|
+
for key, value in kwargs.items():
|
|
133
|
+
if not _is_serializable(value):
|
|
134
|
+
logger.warning(
|
|
135
|
+
"Non-serializable argument '%s' for task %s", key, task_name
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
payload = {
|
|
139
|
+
"task": task_name,
|
|
140
|
+
"version": version,
|
|
141
|
+
"args": args,
|
|
142
|
+
"kwargs": kwargs,
|
|
143
|
+
"timeout": timeout,
|
|
144
|
+
"retry_delay": retry_delay,
|
|
145
|
+
}
|
|
146
|
+
return _queue.enqueue(
|
|
147
|
+
payload=payload,
|
|
148
|
+
priority=priority,
|
|
149
|
+
max_retries=max_retries,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def schedule_at(when: Union[datetime, str], *args, **kwargs) -> Dict[str, Any]:
|
|
153
|
+
"""Schedule task to run at a specific time.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
when: datetime object or ISO 8601 string.
|
|
157
|
+
*args: Positional arguments for the task.
|
|
158
|
+
**kwargs: Keyword arguments for the task.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dict with job id and shard id.
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(when, str):
|
|
164
|
+
when = datetime.fromisoformat(when)
|
|
165
|
+
if when.tzinfo is None:
|
|
166
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
167
|
+
|
|
168
|
+
now = datetime.now(timezone.utc)
|
|
169
|
+
if when <= now:
|
|
170
|
+
raise ValueError(f"Scheduled time {when} is in the past")
|
|
171
|
+
|
|
172
|
+
delay_seconds = (when - now).total_seconds()
|
|
173
|
+
return schedule_in(delay_seconds, *args, **kwargs)
|
|
174
|
+
|
|
175
|
+
def schedule_in(seconds: float, *args, **kwargs) -> Dict[str, Any]:
|
|
176
|
+
"""Schedule task to run after N seconds.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
seconds: Delay in seconds.
|
|
180
|
+
*args: Positional arguments for the task.
|
|
181
|
+
**kwargs: Keyword arguments for the task.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dict with job id and shard id.
|
|
185
|
+
"""
|
|
186
|
+
scheduled = (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
|
|
187
|
+
payload = {
|
|
188
|
+
"task": task_name,
|
|
189
|
+
"version": version,
|
|
190
|
+
"args": args,
|
|
191
|
+
"kwargs": kwargs,
|
|
192
|
+
"scheduled_at": scheduled,
|
|
193
|
+
"timeout": timeout,
|
|
194
|
+
"retry_delay": retry_delay,
|
|
195
|
+
}
|
|
196
|
+
result = _queue.enqueue(
|
|
197
|
+
payload=payload,
|
|
198
|
+
priority=priority,
|
|
199
|
+
max_retries=max_retries,
|
|
200
|
+
)
|
|
201
|
+
logger.info("Task %s scheduled at %s (job %s)", task_name, scheduled, result["id"])
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def map(items: List[Any], *args, **kwargs) -> List[Dict[str, Any]]:
|
|
205
|
+
"""Enqueue multiple items for parallel processing.
|
|
206
|
+
|
|
207
|
+
Each item in the list becomes the first positional argument.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
items: List of items to process.
|
|
211
|
+
*args: Additional positional arguments.
|
|
212
|
+
**kwargs: Keyword arguments.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of job result dicts.
|
|
216
|
+
"""
|
|
217
|
+
return [delay(item, *args, **kwargs) for item in items]
|
|
218
|
+
|
|
219
|
+
def bulk_delay(arg_list: List[Tuple[tuple, dict]]) -> List[Dict[str, Any]]:
|
|
220
|
+
"""Enqueue multiple calls with different arguments.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
arg_list: List of (args, kwargs) tuples.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of job result dicts.
|
|
227
|
+
"""
|
|
228
|
+
return [delay(*a, **kw) for a, kw in arg_list]
|
|
229
|
+
|
|
230
|
+
def get_task_stats() -> Dict[str, Any]:
|
|
231
|
+
"""Get statistics for this task."""
|
|
232
|
+
return {
|
|
233
|
+
"task_name": task_name,
|
|
234
|
+
"version": version,
|
|
235
|
+
"priority": priority,
|
|
236
|
+
"max_retries": max_retries,
|
|
237
|
+
"timeout": timeout,
|
|
238
|
+
"queue_stats": _queue.get_stats(),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Attach methods
|
|
242
|
+
wrapper.delay = delay
|
|
243
|
+
wrapper.schedule_at = schedule_at
|
|
244
|
+
wrapper.schedule_in = schedule_in
|
|
245
|
+
wrapper.map = map
|
|
246
|
+
wrapper.bulk_delay = bulk_delay
|
|
247
|
+
wrapper.get_queue = lambda: _queue
|
|
248
|
+
wrapper.get_stats = get_task_stats
|
|
249
|
+
|
|
250
|
+
# Attach metadata
|
|
251
|
+
wrapper.task_name = task_name
|
|
252
|
+
wrapper.version = version
|
|
253
|
+
wrapper.queue = _queue
|
|
254
|
+
wrapper.priority = priority
|
|
255
|
+
wrapper.max_retries = max_retries
|
|
256
|
+
|
|
257
|
+
return wrapper
|
|
258
|
+
|
|
259
|
+
return decorator
|
|
260
|
+
|
|
261
|
+
def periodic_task(interval: int, **task_kwargs) -> Callable:
|
|
262
|
+
"""Decorator for tasks that run periodically.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
interval: Seconds between automatic executions.
|
|
266
|
+
**task_kwargs: Arguments passed through to @task.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Decorated function with .start_scheduler() method.
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
@periodic_task(interval=3600, priority=1)
|
|
273
|
+
def cleanup():
|
|
274
|
+
print("Cleaning up...")
|
|
275
|
+
|
|
276
|
+
cleanup.start_scheduler()
|
|
277
|
+
"""
|
|
278
|
+
def decorator(func: Callable) -> Callable:
|
|
279
|
+
decorated = task(**task_kwargs)(func)
|
|
280
|
+
|
|
281
|
+
def start_scheduler() -> None:
|
|
282
|
+
import threading
|
|
283
|
+
import time as _time
|
|
284
|
+
|
|
285
|
+
def _run():
|
|
286
|
+
while True:
|
|
287
|
+
try:
|
|
288
|
+
decorated.delay()
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error("Periodic task %s failed: %s", func.__name__, e)
|
|
291
|
+
_time.sleep(interval)
|
|
292
|
+
|
|
293
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
294
|
+
thread.start()
|
|
295
|
+
|
|
296
|
+
decorated.start_scheduler = start_scheduler
|
|
297
|
+
return decorated
|
|
298
|
+
|
|
299
|
+
return decorator
|
|
300
|
+
|
|
301
|
+
def retryable_task(
|
|
302
|
+
max_retries: int = 5,
|
|
303
|
+
retry_on: Optional[List[Exception]] = None,
|
|
304
|
+
**task_kwargs,
|
|
305
|
+
) -> Callable:
|
|
306
|
+
"""Decorator for tasks that should retry synchronously on specific exceptions.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
max_retries: Max retry attempts.
|
|
310
|
+
retry_on: List of exception types to retry on (default: all).
|
|
311
|
+
**task_kwargs: Arguments passed through to @task.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Decorated function with .delay() and synchronous retry logic.
|
|
315
|
+
"""
|
|
316
|
+
def decorator(func: Callable) -> Callable:
|
|
317
|
+
task_kwargs.setdefault("max_retries", max_retries)
|
|
318
|
+
decorated = task(**task_kwargs)(func)
|
|
319
|
+
|
|
320
|
+
@functools.wraps(func)
|
|
321
|
+
def wrapper(*args, **kwargs):
|
|
322
|
+
retry_on_exceptions = retry_on or (Exception,)
|
|
323
|
+
attempt = 0
|
|
324
|
+
while attempt <= max_retries:
|
|
325
|
+
try:
|
|
326
|
+
return func(*args, **kwargs)
|
|
327
|
+
except retry_on_exceptions as e:
|
|
328
|
+
attempt += 1
|
|
329
|
+
if attempt > max_retries:
|
|
330
|
+
raise
|
|
331
|
+
import time as _time
|
|
332
|
+
delay = task_kwargs.get("retry_delay", 60) * (2 ** (attempt - 1))
|
|
333
|
+
logger.warning(
|
|
334
|
+
"Retry %d/%d for %s in %.1fs: %s",
|
|
335
|
+
attempt, max_retries, func.__name__, delay, e,
|
|
336
|
+
)
|
|
337
|
+
_time.sleep(delay)
|
|
338
|
+
|
|
339
|
+
wrapper.delay = decorated.delay
|
|
340
|
+
wrapper.schedule_at = decorated.schedule_at
|
|
341
|
+
wrapper.schedule_in = decorated.schedule_in
|
|
342
|
+
wrapper.map = decorated.map
|
|
343
|
+
wrapper.task_name = decorated.task_name
|
|
344
|
+
return wrapper
|
|
345
|
+
|
|
346
|
+
return decorator
|