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
queue_max/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Robusta Queue - Super robust task queue with SQLite sharding.
|
|
2
|
+
|
|
3
|
+
A zero-dependency (except typing-extensions) task queue library with:
|
|
4
|
+
- SQLite-backed persistence with WAL mode
|
|
5
|
+
- Physical sharding for concurrent access
|
|
6
|
+
- Token bucket rate limiting
|
|
7
|
+
- Circuit breaker pattern
|
|
8
|
+
- Exponential backoff with jitter
|
|
9
|
+
- Automatic orphan job recovery
|
|
10
|
+
- Heartbeat monitoring
|
|
11
|
+
- CLI for queue management
|
|
12
|
+
- Framework integrations (Django, FastAPI, Flask)
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from queue_max import Queue, Worker
|
|
16
|
+
>>> queue = Queue()
|
|
17
|
+
>>> queue.enqueue({"task": "send_email"})
|
|
18
|
+
>>> worker = Worker("worker-1", lambda p: print(p), queue)
|
|
19
|
+
>>> worker.start()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from queue_max.core.circuit_breaker import CircuitBreaker, CircuitState
|
|
23
|
+
from queue_max.core.queue import Queue
|
|
24
|
+
from queue_max.core.rate_limiter import RateLimitUnit, RateLimiter
|
|
25
|
+
from queue_max.core.worker import AsyncWorker, Worker, WorkerPool, WorkerState
|
|
26
|
+
from queue_max.core.decorator import periodic_task, retryable_task, task
|
|
27
|
+
from queue_max.exceptions import (
|
|
28
|
+
CircuitBreakerOpenError,
|
|
29
|
+
ConfigurationError,
|
|
30
|
+
JobFailedError,
|
|
31
|
+
QueueError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ShardError,
|
|
34
|
+
)
|
|
35
|
+
from queue_max.models.job import Job, JobPriority, JobResult, JobStatus
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Queue",
|
|
41
|
+
"Worker",
|
|
42
|
+
"AsyncWorker",
|
|
43
|
+
"WorkerPool",
|
|
44
|
+
"WorkerState",
|
|
45
|
+
"Job",
|
|
46
|
+
"JobStatus",
|
|
47
|
+
"JobPriority",
|
|
48
|
+
"JobResult",
|
|
49
|
+
"task",
|
|
50
|
+
"periodic_task",
|
|
51
|
+
"retryable_task",
|
|
52
|
+
"RateLimiter",
|
|
53
|
+
"RateLimitUnit",
|
|
54
|
+
"CircuitBreaker",
|
|
55
|
+
"CircuitState",
|
|
56
|
+
"QueueError",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"CircuitBreakerOpenError",
|
|
59
|
+
"JobFailedError",
|
|
60
|
+
"ShardError",
|
|
61
|
+
"ConfigurationError",
|
|
62
|
+
]
|
queue_max/cli.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Command-line interface for Robusta Queue.
|
|
2
|
+
|
|
3
|
+
Provides commands for managing the queue, running workers,
|
|
4
|
+
inspecting job status, and performing maintenance operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import importlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from queue_max import Queue, Worker, WorkerPool
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("queue_max")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _format_table(headers: List[str], rows: List[List[Any]]) -> str:
|
|
21
|
+
"""Format data as a simple ASCII table."""
|
|
22
|
+
col_widths = [len(h) for h in headers]
|
|
23
|
+
for row in rows:
|
|
24
|
+
for i, cell in enumerate(row):
|
|
25
|
+
col_widths[i] = max(col_widths[i], len(str(cell)))
|
|
26
|
+
|
|
27
|
+
separator = "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
|
|
28
|
+
header_row = (
|
|
29
|
+
"|"
|
|
30
|
+
+ "|".join(f" {h.center(col_widths[i])} " for i, h in enumerate(headers))
|
|
31
|
+
+ "|"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
lines = [separator, header_row, separator]
|
|
35
|
+
for row in rows:
|
|
36
|
+
line = (
|
|
37
|
+
"|"
|
|
38
|
+
+ "|".join(f" {str(cell).ljust(col_widths[i])} " for i, cell in enumerate(row))
|
|
39
|
+
+ "|"
|
|
40
|
+
)
|
|
41
|
+
lines.append(line)
|
|
42
|
+
lines.append(separator)
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_stats(args: argparse.Namespace) -> None:
|
|
47
|
+
"""Handle the 'stats' command."""
|
|
48
|
+
queue = Queue(
|
|
49
|
+
shards=args.shards,
|
|
50
|
+
rate_limit=args.rate_limit,
|
|
51
|
+
data_dir=args.data_dir,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if args.shard is not None:
|
|
55
|
+
stats = queue.shard_manager.get_stats(args.shard)
|
|
56
|
+
else:
|
|
57
|
+
stats = queue.get_stats()
|
|
58
|
+
|
|
59
|
+
if args.json:
|
|
60
|
+
print(json.dumps(stats, indent=2))
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
print("\n Robusta Queue Statistics")
|
|
64
|
+
print("=" * 45)
|
|
65
|
+
rows = [
|
|
66
|
+
["Pending", stats.get("pending", 0)],
|
|
67
|
+
["Processing", stats.get("processing", 0)],
|
|
68
|
+
["Failed", stats.get("failed", 0)],
|
|
69
|
+
["Shards", stats.get("num_shards", queue.num_shards)],
|
|
70
|
+
["Circuit State", stats.get("circuit_state", "closed")],
|
|
71
|
+
["Tokens Available", stats.get("tokens_available", 0)],
|
|
72
|
+
]
|
|
73
|
+
print(_format_table(["Metric", "Value"], rows))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_worker(args: argparse.Namespace) -> None:
|
|
77
|
+
"""Handle the 'worker' command."""
|
|
78
|
+
# Parse function reference (module:function)
|
|
79
|
+
if ":" not in args.function:
|
|
80
|
+
print("Error: --function must be in MODULE:FUNCTION format")
|
|
81
|
+
sys.exit(2)
|
|
82
|
+
|
|
83
|
+
module_path, func_name = args.function.split(":", 1)
|
|
84
|
+
try:
|
|
85
|
+
module = importlib.import_module(module_path)
|
|
86
|
+
func = getattr(module, func_name)
|
|
87
|
+
except (ImportError, AttributeError) as e:
|
|
88
|
+
print(f"Error: Could not load function '{args.function}': {e}")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
if not callable(func):
|
|
92
|
+
print(f"Error: '{func_name}' in '{module_path}' is not callable")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
queue = Queue(
|
|
96
|
+
shards=args.shards,
|
|
97
|
+
rate_limit=args.rate_limit,
|
|
98
|
+
data_dir=args.data_dir,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
num_workers = args.workers or 1
|
|
102
|
+
workers = []
|
|
103
|
+
for i in range(num_workers):
|
|
104
|
+
worker_id = f"worker-{i + 1}"
|
|
105
|
+
w = Worker(worker_id=worker_id, process_function=func, queue=queue)
|
|
106
|
+
workers.append(w)
|
|
107
|
+
|
|
108
|
+
pool = WorkerPool(workers)
|
|
109
|
+
pool.start_all()
|
|
110
|
+
|
|
111
|
+
print(f"\n Started {num_workers} worker(s) processing {args.function}")
|
|
112
|
+
print(f" Queue: {queue.num_shards} shards, {queue.rate_limiter.rate_limit} req/min")
|
|
113
|
+
print(" Press Ctrl+C to stop.\n")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
while True:
|
|
119
|
+
time.sleep(60)
|
|
120
|
+
stats = queue.get_stats()
|
|
121
|
+
worker_stats = pool.get_stats()
|
|
122
|
+
print(
|
|
123
|
+
f" [Stats] Pending: {stats['pending']} | "
|
|
124
|
+
f"Processed: {worker_stats['total_processed']} | "
|
|
125
|
+
f"Failed: {worker_stats['total_failed']} | "
|
|
126
|
+
f"Circuit: {stats['circuit_state']}"
|
|
127
|
+
)
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
print("\n Shutting down workers...")
|
|
130
|
+
finally:
|
|
131
|
+
pool.stop_all()
|
|
132
|
+
print(" Workers stopped.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_enqueue(args: argparse.Namespace) -> None:
|
|
136
|
+
"""Handle the 'enqueue' command."""
|
|
137
|
+
try:
|
|
138
|
+
payload = json.loads(args.payload)
|
|
139
|
+
except json.JSONDecodeError as e:
|
|
140
|
+
print(f"Error: Invalid JSON payload: {e}")
|
|
141
|
+
sys.exit(2)
|
|
142
|
+
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
print("Error: Payload must be a JSON object (dict)")
|
|
145
|
+
sys.exit(2)
|
|
146
|
+
|
|
147
|
+
queue = Queue(
|
|
148
|
+
shards=args.shards,
|
|
149
|
+
data_dir=args.data_dir,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result = queue.enqueue(
|
|
153
|
+
payload=payload,
|
|
154
|
+
pagina_id=args.pagina_id,
|
|
155
|
+
priority=args.priority,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps(result))
|
|
160
|
+
else:
|
|
161
|
+
print(f" Job enqueued: id={result['id']}, shard={result['shard_id']}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_purge(args: argparse.Namespace) -> None:
|
|
165
|
+
"""Handle the 'purge' command."""
|
|
166
|
+
queue = Queue(
|
|
167
|
+
shards=args.shards,
|
|
168
|
+
data_dir=args.data_dir,
|
|
169
|
+
)
|
|
170
|
+
removed = queue.cleanup_old_jobs(days=args.days)
|
|
171
|
+
print(f" Removed {removed} old job(s).")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_retry(args: argparse.Namespace) -> None:
|
|
175
|
+
"""Handle the 'retry' command."""
|
|
176
|
+
queue = Queue(
|
|
177
|
+
shards=args.shards,
|
|
178
|
+
data_dir=args.data_dir,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if args.job_id is not None:
|
|
182
|
+
# Retry a single job (requires shard)
|
|
183
|
+
if args.shard is None:
|
|
184
|
+
print("Error: --shard is required when using --job-id")
|
|
185
|
+
sys.exit(2)
|
|
186
|
+
# For a single job, we update it directly
|
|
187
|
+
if args.shard is not None:
|
|
188
|
+
retried = queue.shard_manager.retry_failed_jobs(args.shard)
|
|
189
|
+
# Note: retry_failed_jobs retries all in shard since we don't have single-job retry
|
|
190
|
+
print(f" Retried jobs in shard {args.shard}.")
|
|
191
|
+
else:
|
|
192
|
+
retried = queue.retry_failed_jobs(shard_id=args.shard)
|
|
193
|
+
if args.shard is not None:
|
|
194
|
+
print(f" Retried {retried} job(s) in shard {args.shard}.")
|
|
195
|
+
else:
|
|
196
|
+
print(f" Retried {retried} job(s) across all shards.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
200
|
+
"""Handle the 'list' command."""
|
|
201
|
+
queue = Queue(
|
|
202
|
+
shards=args.shards,
|
|
203
|
+
data_dir=args.data_dir,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
status = args.status
|
|
207
|
+
jobs_list: List[Dict[str, Any]] = []
|
|
208
|
+
|
|
209
|
+
if status in ("all", "failed"):
|
|
210
|
+
for job in queue.get_failed_jobs(limit=args.limit):
|
|
211
|
+
jd = job.to_dict()
|
|
212
|
+
jd["payload"] = json.dumps(jd["payload"], ensure_ascii=False)[:80]
|
|
213
|
+
jobs_list.append(jd)
|
|
214
|
+
|
|
215
|
+
if status in ("all", "processing"):
|
|
216
|
+
for job in queue.get_processing_jobs():
|
|
217
|
+
jd = job.to_dict()
|
|
218
|
+
jd["payload"] = json.dumps(jd["payload"], ensure_ascii=False)[:80]
|
|
219
|
+
jobs_list.append(jd)
|
|
220
|
+
|
|
221
|
+
if status == "all":
|
|
222
|
+
# Also add pending from stats
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
# Sort by id descending
|
|
226
|
+
jobs_list.sort(key=lambda j: j.get("id", 0), reverse=True)
|
|
227
|
+
jobs_list = jobs_list[: args.limit]
|
|
228
|
+
|
|
229
|
+
if args.json:
|
|
230
|
+
print(json.dumps(jobs_list, indent=2, ensure_ascii=False))
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if not jobs_list:
|
|
234
|
+
print(f" No jobs with status '{status}'.")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
headers = ["ID", "Shard", "Status", "Priority", "Payload"]
|
|
238
|
+
rows = []
|
|
239
|
+
for jd in jobs_list:
|
|
240
|
+
rows.append([
|
|
241
|
+
jd.get("id", ""),
|
|
242
|
+
jd.get("shard_id", ""),
|
|
243
|
+
jd.get("status", ""),
|
|
244
|
+
jd.get("priority", ""),
|
|
245
|
+
str(jd.get("payload", ""))[:60],
|
|
246
|
+
])
|
|
247
|
+
print()
|
|
248
|
+
print(_format_table(headers, rows[:20])) # Limit display rows
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
252
|
+
"""Build the argument parser for the CLI."""
|
|
253
|
+
parser = argparse.ArgumentParser(
|
|
254
|
+
prog="queue-max",
|
|
255
|
+
description="Robusta Queue - Super robust task queue with SQLite sharding",
|
|
256
|
+
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--shards",
|
|
259
|
+
type=int,
|
|
260
|
+
default=None,
|
|
261
|
+
help="Number of shards (default: NUM_SHARDS env or 6)",
|
|
262
|
+
)
|
|
263
|
+
parser.add_argument(
|
|
264
|
+
"--rate-limit",
|
|
265
|
+
type=int,
|
|
266
|
+
default=None,
|
|
267
|
+
help="Requests per minute (default: RATE_LIMIT_MAX env or 160)",
|
|
268
|
+
)
|
|
269
|
+
parser.add_argument(
|
|
270
|
+
"--data-dir",
|
|
271
|
+
default=None,
|
|
272
|
+
help="Data directory (default: DATA_DIR env or ./data)",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
276
|
+
|
|
277
|
+
# stats
|
|
278
|
+
stats_parser = subparsers.add_parser("stats", help="Show queue statistics")
|
|
279
|
+
stats_parser.add_argument("--shard", type=int, default=None, help="Specific shard")
|
|
280
|
+
stats_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
281
|
+
|
|
282
|
+
# worker
|
|
283
|
+
worker_parser = subparsers.add_parser("worker", help="Start worker(s)")
|
|
284
|
+
worker_parser.add_argument(
|
|
285
|
+
"--function",
|
|
286
|
+
required=True,
|
|
287
|
+
help="Function reference (MODULE:FUNCTION)",
|
|
288
|
+
)
|
|
289
|
+
worker_parser.add_argument(
|
|
290
|
+
"--workers",
|
|
291
|
+
type=int,
|
|
292
|
+
default=1,
|
|
293
|
+
help="Number of worker threads (default: 1)",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# enqueue
|
|
297
|
+
enqueue_parser = subparsers.add_parser("enqueue", help="Enqueue a job")
|
|
298
|
+
enqueue_parser.add_argument(
|
|
299
|
+
"--payload",
|
|
300
|
+
required=True,
|
|
301
|
+
help="Job payload as JSON string",
|
|
302
|
+
)
|
|
303
|
+
enqueue_parser.add_argument("--priority", type=int, default=0, choices=[0, 1, 2])
|
|
304
|
+
enqueue_parser.add_argument("--pagina-id", type=int, default=None)
|
|
305
|
+
enqueue_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
306
|
+
|
|
307
|
+
# purge
|
|
308
|
+
purge_parser = subparsers.add_parser("purge", help="Remove old jobs")
|
|
309
|
+
purge_parser.add_argument("--days", type=int, default=7, help="Age threshold (days)")
|
|
310
|
+
|
|
311
|
+
# retry
|
|
312
|
+
retry_parser = subparsers.add_parser("retry", help="Retry failed jobs")
|
|
313
|
+
retry_parser.add_argument("--shard", type=int, default=None, help="Specific shard")
|
|
314
|
+
retry_parser.add_argument("--job-id", type=int, default=None, help="Specific job ID")
|
|
315
|
+
|
|
316
|
+
# list
|
|
317
|
+
list_parser = subparsers.add_parser("list", help="List jobs")
|
|
318
|
+
list_parser.add_argument(
|
|
319
|
+
"--status",
|
|
320
|
+
choices=["pending", "processing", "failed", "all"],
|
|
321
|
+
default="failed",
|
|
322
|
+
)
|
|
323
|
+
list_parser.add_argument("--limit", type=int, default=50)
|
|
324
|
+
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
325
|
+
|
|
326
|
+
return parser
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main(argv: List[str] | None = None) -> int:
|
|
330
|
+
"""Main entry point for the Robusta Queue CLI.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
argv: Command-line arguments (default: sys.argv[1:]).
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Exit code (0=success, 1=error, 2=usage error).
|
|
337
|
+
"""
|
|
338
|
+
parser = build_parser()
|
|
339
|
+
args = parser.parse_args(argv)
|
|
340
|
+
|
|
341
|
+
# Set up logging
|
|
342
|
+
logging.basicConfig(
|
|
343
|
+
level=os.environ.get("QUEUE_MAX_LOG_LEVEL", "WARNING").upper(),
|
|
344
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
345
|
+
datefmt="%H:%M:%S",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not args.command:
|
|
349
|
+
parser.print_help()
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
command_map = {
|
|
353
|
+
"stats": cmd_stats,
|
|
354
|
+
"worker": cmd_worker,
|
|
355
|
+
"enqueue": cmd_enqueue,
|
|
356
|
+
"purge": cmd_purge,
|
|
357
|
+
"retry": cmd_retry,
|
|
358
|
+
"list": cmd_list,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
command_map[args.command](args)
|
|
363
|
+
return 0
|
|
364
|
+
except KeyboardInterrupt:
|
|
365
|
+
return 0
|
|
366
|
+
except Exception as e:
|
|
367
|
+
logger.exception("Command failed")
|
|
368
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
369
|
+
return 1
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
sys.exit(main())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Django integration for Queue Max.
|
|
2
|
+
|
|
3
|
+
Provides a @task decorator that integrates with Django models
|
|
4
|
+
and management commands for queue operations.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
# settings.py
|
|
8
|
+
INSTALLED_APPS = [
|
|
9
|
+
...
|
|
10
|
+
'queue_max.contrib.django',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
QUEUE_MAX = {
|
|
14
|
+
'SHARDS': 4,
|
|
15
|
+
'RATE_LIMIT': 160,
|
|
16
|
+
'MAX_RETRIES': 3,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# tasks.py
|
|
20
|
+
from queue_max.contrib.django import task
|
|
21
|
+
|
|
22
|
+
@task
|
|
23
|
+
def send_welcome_email(user_id):
|
|
24
|
+
from myapp.models import User
|
|
25
|
+
user = User.objects.get(id=user_id)
|
|
26
|
+
# send email
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from typing import Any, Callable, Dict, Optional
|
|
30
|
+
|
|
31
|
+
from queue_max import Queue as BaseQueue
|
|
32
|
+
from queue_max import task as base_task
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_django_queue() -> BaseQueue:
|
|
36
|
+
"""Get or create a Queue configured from Django settings."""
|
|
37
|
+
try:
|
|
38
|
+
from django.conf import settings
|
|
39
|
+
except ImportError:
|
|
40
|
+
return BaseQueue()
|
|
41
|
+
|
|
42
|
+
config = getattr(settings, "QUEUE_MAX", {})
|
|
43
|
+
return BaseQueue(
|
|
44
|
+
shards=config.get("SHARDS"),
|
|
45
|
+
rate_limit=config.get("RATE_LIMIT"),
|
|
46
|
+
max_retries=config.get("MAX_RETRIES"),
|
|
47
|
+
data_dir=config.get("DATA_DIR"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def task(
|
|
52
|
+
queue: Optional[BaseQueue] = None,
|
|
53
|
+
priority: int = 0,
|
|
54
|
+
max_retries: Optional[int] = None,
|
|
55
|
+
) -> Callable:
|
|
56
|
+
"""Django-aware @task decorator.
|
|
57
|
+
|
|
58
|
+
Uses Django settings if available for queue configuration.
|
|
59
|
+
"""
|
|
60
|
+
_queue = queue or _get_django_queue()
|
|
61
|
+
return base_task(queue=_queue, priority=priority, max_retries=max_retries)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Django management command to purge old jobs."""
|
|
2
|
+
|
|
3
|
+
from django.core.management.base import BaseCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
"""Remove old jobs from the queue."""
|
|
8
|
+
|
|
9
|
+
help = "Remove old jobs from Robusta Queue"
|
|
10
|
+
|
|
11
|
+
def add_arguments(self, parser):
|
|
12
|
+
parser.add_argument("--days", type=int, default=7, help="Age threshold (days)")
|
|
13
|
+
|
|
14
|
+
def handle(self, *args, **options):
|
|
15
|
+
from queue_max.contrib.django import _get_django_queue
|
|
16
|
+
|
|
17
|
+
queue = _get_django_queue()
|
|
18
|
+
removed = queue.cleanup_old_jobs(days=options["days"])
|
|
19
|
+
self.stdout.write(self.style.SUCCESS(f"Removed {removed} old job(s)"))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Django management command to display queue statistics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand
|
|
6
|
+
|
|
7
|
+
from queue_max import Queue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
"""Display Robusta Queue statistics."""
|
|
12
|
+
|
|
13
|
+
help = "Display Robusta Queue statistics"
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser):
|
|
16
|
+
parser.add_argument("--shard", type=int, default=None, help="Specific shard")
|
|
17
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
18
|
+
|
|
19
|
+
def handle(self, *args, **options):
|
|
20
|
+
from queue_max.contrib.django import _get_django_queue
|
|
21
|
+
|
|
22
|
+
queue = _get_django_queue()
|
|
23
|
+
|
|
24
|
+
if options["shard"] is not None:
|
|
25
|
+
stats = queue.shard_manager.get_stats(options["shard"])
|
|
26
|
+
else:
|
|
27
|
+
stats = queue.get_stats()
|
|
28
|
+
|
|
29
|
+
if options["json"]:
|
|
30
|
+
self.stdout.write(json.dumps(stats, indent=2))
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
self.stdout.write("\n Robusta Queue Statistics")
|
|
34
|
+
self.stdout.write("=" * 40)
|
|
35
|
+
self.stdout.write(f" Pending: {stats.get('pending', 0)}")
|
|
36
|
+
self.stdout.write(f" Processing: {stats.get('processing', 0)}")
|
|
37
|
+
self.stdout.write(f" Failed: {stats.get('failed', 0)}")
|
|
38
|
+
self.stdout.write(f" Shards: {stats.get('num_shards', queue.num_shards)}")
|
|
39
|
+
self.stdout.write(f" Circuit: {stats.get('circuit_state', 'closed')}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Django management command to start a queue worker."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
6
|
+
|
|
7
|
+
from queue_max import Queue, Worker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
"""Start a Robusta Queue worker."""
|
|
12
|
+
|
|
13
|
+
help = "Start a Robusta Queue worker"
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser):
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--function",
|
|
18
|
+
required=True,
|
|
19
|
+
help="Function reference (MODULE:FUNCTION)",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--workers",
|
|
23
|
+
type=int,
|
|
24
|
+
default=1,
|
|
25
|
+
help="Number of worker threads (default: 1)",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def handle(self, *args, **options):
|
|
29
|
+
module_path, func_name = options["function"].split(":", 1)
|
|
30
|
+
try:
|
|
31
|
+
module = importlib.import_module(module_path)
|
|
32
|
+
func = getattr(module, func_name)
|
|
33
|
+
except (ImportError, AttributeError) as e:
|
|
34
|
+
raise CommandError(f"Could not load function '{options['function']}': {e}")
|
|
35
|
+
|
|
36
|
+
if not callable(func):
|
|
37
|
+
raise CommandError(f"'{func_name}' is not callable")
|
|
38
|
+
|
|
39
|
+
from queue_max.contrib.django import _get_django_queue
|
|
40
|
+
|
|
41
|
+
queue = _get_django_queue()
|
|
42
|
+
num_workers = options["workers"]
|
|
43
|
+
|
|
44
|
+
workers = [
|
|
45
|
+
Worker(worker_id=f"django-worker-{i + 1}", process_function=func, queue=queue)
|
|
46
|
+
for i in range(num_workers)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
from queue_max import WorkerPool
|
|
50
|
+
|
|
51
|
+
pool = WorkerPool(workers)
|
|
52
|
+
pool.start_all()
|
|
53
|
+
|
|
54
|
+
self.stdout.write(
|
|
55
|
+
self.style.SUCCESS(
|
|
56
|
+
f"Started {num_workers} worker(s) processing {options['function']}"
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
import time
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
self.stdout.write("\nShutting down...")
|
|
67
|
+
finally:
|
|
68
|
+
pool.stop_all()
|
|
69
|
+
self.stdout.write(self.style.SUCCESS("Workers stopped"))
|