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 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,7 @@
1
+ """Framework integrations for Robusta Queue.
2
+
3
+ Available integrations:
4
+ - Django: Management commands and @task decorator
5
+ - FastAPI: Background task middleware
6
+ - Flask: Extension pattern
7
+ """
@@ -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
@@ -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"))