baqueue 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.
- baqueue/__init__.py +19 -0
- baqueue/balancer.py +108 -0
- baqueue/batch.py +159 -0
- baqueue/cli.py +459 -0
- baqueue/config.py +79 -0
- baqueue/dashboard/__init__.py +1 -0
- baqueue/dashboard/api.py +193 -0
- baqueue/dashboard/server.py +263 -0
- baqueue/dashboard/static/app.js +450 -0
- baqueue/dashboard/static/index.html +580 -0
- baqueue/dashboard/static/style.css +1415 -0
- baqueue/drivers/__init__.py +1 -0
- baqueue/drivers/base.py +212 -0
- baqueue/drivers/memory_driver.py +318 -0
- baqueue/drivers/postgres_driver.py +656 -0
- baqueue/drivers/redis_driver.py +656 -0
- baqueue/drivers/sqlite_driver.py +706 -0
- baqueue/events.py +64 -0
- baqueue/job.py +128 -0
- baqueue/pruner.py +128 -0
- baqueue/queue.py +225 -0
- baqueue/retry.py +55 -0
- baqueue/scheduler.py +101 -0
- baqueue/serializer.py +124 -0
- baqueue/supervisor.py +206 -0
- baqueue/worker.py +165 -0
- baqueue-0.1.0.dist-info/METADATA +609 -0
- baqueue-0.1.0.dist-info/RECORD +32 -0
- baqueue-0.1.0.dist-info/WHEEL +5 -0
- baqueue-0.1.0.dist-info/entry_points.txt +2 -0
- baqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
- baqueue-0.1.0.dist-info/top_level.txt +1 -0
baqueue/cli.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""CLI commands for BaQueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from baqueue.config import BaQueueConfig, DriverConfig, SupervisorConfig
|
|
15
|
+
from baqueue.queue import Queue
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_config(config_path: str | None) -> BaQueueConfig:
|
|
19
|
+
if config_path and Path(config_path).exists():
|
|
20
|
+
raw = json.loads(Path(config_path).read_text())
|
|
21
|
+
return BaQueueConfig.from_dict(raw)
|
|
22
|
+
return BaQueueConfig()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _setup_logging(verbose: bool) -> None:
|
|
26
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
27
|
+
logging.basicConfig(
|
|
28
|
+
level=level,
|
|
29
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
30
|
+
datefmt="%H:%M:%S",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
VALID_DRIVERS = ("sqlite", "memory", "redis", "postgres")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_driver(name: str) -> str:
|
|
38
|
+
if name not in VALID_DRIVERS:
|
|
39
|
+
raise click.BadParameter(
|
|
40
|
+
f"Unknown driver '{name}'. Choose from: {', '.join(VALID_DRIVERS)}",
|
|
41
|
+
param_hint="'--driver / -d'",
|
|
42
|
+
)
|
|
43
|
+
return name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_async(coro_fn, *args: Any, **kwargs: Any) -> Any:
|
|
47
|
+
"""Run an async function with clean error reporting."""
|
|
48
|
+
try:
|
|
49
|
+
return asyncio.run(coro_fn(*args, **kwargs))
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
click.echo("\nShutting down...")
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
click.echo(f"Error: {e}", err=True)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
except ConnectionError as e:
|
|
56
|
+
click.echo(f"Error: Connection failed - {e}", err=True)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
except OSError as e:
|
|
59
|
+
if "10048" in str(e) or "address already in use" in str(e).lower():
|
|
60
|
+
click.echo("Error: Port is already in use. Kill the existing process or use a different port.", err=True)
|
|
61
|
+
else:
|
|
62
|
+
click.echo(f"Error: {e}", err=True)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
click.echo(f"Error: {e}", err=True)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
70
|
+
@click.option("--config", "-c", default=None, help="Path to baqueue config JSON file.")
|
|
71
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def cli(ctx: click.Context, config: str | None, verbose: bool) -> None:
|
|
74
|
+
"""BaQueue - Python Queue Management."""
|
|
75
|
+
_setup_logging(verbose)
|
|
76
|
+
ctx.ensure_object(dict)
|
|
77
|
+
ctx.obj["config"] = _load_config(config)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@cli.command()
|
|
81
|
+
@click.option("--queue", "-q", multiple=True, default=["default"], help="Queues to process.")
|
|
82
|
+
@click.option("--workers", "-w", default=3, help="Number of workers.")
|
|
83
|
+
@click.option("--balance", "-b", default="auto", help="Balancing strategy: auto, simple, false.")
|
|
84
|
+
@click.option("--sleep", "-s", default=1.0, help="Sleep interval when idle (seconds).")
|
|
85
|
+
@click.option("--timeout", "-t", default=60, help="Max job execution time (seconds).")
|
|
86
|
+
@click.option("--max-jobs", default=0, help="Max jobs per worker (0 = unlimited).")
|
|
87
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
88
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
89
|
+
@click.option("--no-auto-prune", is_flag=True, help="Disable the background auto-pruner.")
|
|
90
|
+
@click.option(
|
|
91
|
+
"--prune-completed-seconds", type=int, default=None,
|
|
92
|
+
help="Override: delete completed jobs older than N seconds (default 5).",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--prune-other-seconds", type=int, default=None,
|
|
96
|
+
help="Override: delete failed/cancelled jobs older than N seconds (default 86400).",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--prune-interval-seconds", type=int, default=None,
|
|
100
|
+
help="Override: how often the auto-pruner runs, in seconds (default 5).",
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--no-disk-full-cleanup", is_flag=True,
|
|
104
|
+
help="Disable automatic emergency cleanup when the driver returns a disk-full error.",
|
|
105
|
+
)
|
|
106
|
+
@click.pass_context
|
|
107
|
+
def work(
|
|
108
|
+
ctx: click.Context,
|
|
109
|
+
queue: tuple[str, ...],
|
|
110
|
+
workers: int,
|
|
111
|
+
balance: str,
|
|
112
|
+
sleep: float,
|
|
113
|
+
timeout: int,
|
|
114
|
+
max_jobs: int,
|
|
115
|
+
driver: str,
|
|
116
|
+
driver_url: str | None,
|
|
117
|
+
no_auto_prune: bool,
|
|
118
|
+
prune_completed_seconds: int | None,
|
|
119
|
+
prune_other_seconds: int | None,
|
|
120
|
+
prune_interval_seconds: int | None,
|
|
121
|
+
no_disk_full_cleanup: bool,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Start processing jobs."""
|
|
124
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
125
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
126
|
+
|
|
127
|
+
if no_auto_prune:
|
|
128
|
+
config.auto_prune = False
|
|
129
|
+
if prune_completed_seconds is not None:
|
|
130
|
+
config.prune_completed_seconds = prune_completed_seconds
|
|
131
|
+
if prune_other_seconds is not None:
|
|
132
|
+
config.prune_other_seconds = prune_other_seconds
|
|
133
|
+
if prune_interval_seconds is not None:
|
|
134
|
+
config.prune_interval_seconds = prune_interval_seconds
|
|
135
|
+
if no_disk_full_cleanup:
|
|
136
|
+
config.auto_cleanup_on_disk_full = False
|
|
137
|
+
|
|
138
|
+
supervisor_config = SupervisorConfig(
|
|
139
|
+
queues=list(queue),
|
|
140
|
+
min_workers=workers,
|
|
141
|
+
max_workers=workers * 2,
|
|
142
|
+
balance=balance,
|
|
143
|
+
sleep=sleep,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
max_jobs_per_worker=max_jobs,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
_validate_driver(driver)
|
|
149
|
+
|
|
150
|
+
click.echo("BaQueue worker starting...")
|
|
151
|
+
click.echo(f" Driver: {config.driver.name}")
|
|
152
|
+
click.echo(f" Queues: {', '.join(queue)}")
|
|
153
|
+
click.echo(f" Workers: {workers}")
|
|
154
|
+
click.echo(f" Balance: {balance}")
|
|
155
|
+
if config.auto_prune:
|
|
156
|
+
click.echo(
|
|
157
|
+
f" Auto-prune: completed>{config.prune_completed_seconds}s, "
|
|
158
|
+
f"other>{config.prune_other_seconds}s, every {config.prune_interval_seconds}s"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
click.echo(" Auto-prune: disabled")
|
|
162
|
+
click.echo(
|
|
163
|
+
f" Disk-full cleanup: {'enabled' if config.auto_cleanup_on_disk_full else 'disabled'}"
|
|
164
|
+
)
|
|
165
|
+
click.echo()
|
|
166
|
+
|
|
167
|
+
_run_async(_run_worker, config, supervisor_config)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _run_worker(config: BaQueueConfig, supervisor_config: SupervisorConfig) -> None:
|
|
171
|
+
from baqueue.balancer import create_balancer
|
|
172
|
+
from baqueue.pruner import Pruner
|
|
173
|
+
from baqueue.supervisor import Supervisor
|
|
174
|
+
|
|
175
|
+
Queue.configure(config)
|
|
176
|
+
await Queue.connect()
|
|
177
|
+
|
|
178
|
+
balancer = create_balancer(
|
|
179
|
+
supervisor_config.balance,
|
|
180
|
+
min_workers=supervisor_config.min_workers,
|
|
181
|
+
max_workers=supervisor_config.max_workers,
|
|
182
|
+
)
|
|
183
|
+
pruner = Pruner(driver=Queue.get_driver(), config=config) if config.auto_prune else None
|
|
184
|
+
supervisor = Supervisor(
|
|
185
|
+
driver=Queue.get_driver(),
|
|
186
|
+
config=supervisor_config,
|
|
187
|
+
balancer=balancer,
|
|
188
|
+
pruner=pruner,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
await supervisor.start()
|
|
193
|
+
finally:
|
|
194
|
+
await supervisor.stop()
|
|
195
|
+
await Queue.disconnect()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@cli.command()
|
|
199
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
200
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def schedule(ctx: click.Context, driver: str, driver_url: str | None) -> None:
|
|
203
|
+
"""Start the job scheduler."""
|
|
204
|
+
_validate_driver(driver)
|
|
205
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
206
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
207
|
+
|
|
208
|
+
if not config.schedules:
|
|
209
|
+
click.echo("No schedules configured. Add entries to your config file.")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
click.echo(f"Scheduler starting with {len(config.schedules)} entries...")
|
|
213
|
+
_run_async(_run_scheduler, config)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _run_scheduler(config: BaQueueConfig) -> None:
|
|
217
|
+
from baqueue.scheduler import Scheduler
|
|
218
|
+
|
|
219
|
+
Queue.configure(config)
|
|
220
|
+
await Queue.connect()
|
|
221
|
+
|
|
222
|
+
scheduler = Scheduler(driver=Queue.get_driver(), entries=config.schedules)
|
|
223
|
+
try:
|
|
224
|
+
await scheduler.start()
|
|
225
|
+
finally:
|
|
226
|
+
scheduler.stop()
|
|
227
|
+
await Queue.disconnect()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@cli.command()
|
|
231
|
+
@click.option("--host", "-H", default="0.0.0.0", help="Dashboard host.")
|
|
232
|
+
@click.option("--port", "-p", default=9100, help="Dashboard port.")
|
|
233
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
234
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
235
|
+
@click.pass_context
|
|
236
|
+
def dashboard(ctx: click.Context, host: str, port: int, driver: str, driver_url: str | None) -> None:
|
|
237
|
+
"""Launch the monitoring dashboard."""
|
|
238
|
+
_validate_driver(driver)
|
|
239
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
240
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
241
|
+
config.dashboard_host = host
|
|
242
|
+
config.dashboard_port = port
|
|
243
|
+
|
|
244
|
+
if config.driver.name == "memory":
|
|
245
|
+
click.echo(
|
|
246
|
+
"WARNING: Memory driver only works within a single process.\n"
|
|
247
|
+
" The dashboard will NOT see jobs from other processes.\n"
|
|
248
|
+
" Use 'sqlite' driver instead, or run 'python examples/dashboard_demo.py'.\n"
|
|
249
|
+
)
|
|
250
|
+
click.echo(f"BaQueue Dashboard: http://{host}:{port}")
|
|
251
|
+
click.echo(f" Driver: {config.driver.name}")
|
|
252
|
+
_run_async(_run_dashboard, config)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def _run_dashboard(config: BaQueueConfig) -> None:
|
|
256
|
+
from baqueue.dashboard.server import create_app
|
|
257
|
+
import uvicorn
|
|
258
|
+
|
|
259
|
+
Queue.configure(config)
|
|
260
|
+
await Queue.connect()
|
|
261
|
+
|
|
262
|
+
app = create_app(Queue.get_driver(), config)
|
|
263
|
+
server_config = uvicorn.Config(app, host=config.dashboard_host, port=config.dashboard_port, log_level="info")
|
|
264
|
+
server = uvicorn.Server(server_config)
|
|
265
|
+
await server.serve()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@cli.command()
|
|
269
|
+
@click.option("--status", "-s", default=None, help="Prune jobs by status (completed, failed, cancelled).")
|
|
270
|
+
@click.option("--tag", "-t", default=None, help="Prune jobs by tag.")
|
|
271
|
+
@click.option("--hours", default=None, type=float, help="Prune jobs older than N hours.")
|
|
272
|
+
@click.option("--queue", "-q", default=None, help="Limit to a specific queue.")
|
|
273
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
274
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
275
|
+
@click.pass_context
|
|
276
|
+
def prune(
|
|
277
|
+
ctx: click.Context,
|
|
278
|
+
status: str | None,
|
|
279
|
+
tag: str | None,
|
|
280
|
+
hours: float | None,
|
|
281
|
+
queue: str | None,
|
|
282
|
+
driver: str,
|
|
283
|
+
driver_url: str | None,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Prune old jobs from the queue."""
|
|
286
|
+
if not status and not tag:
|
|
287
|
+
click.echo("Please specify --status or --tag to prune.")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
_validate_driver(driver)
|
|
291
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
292
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
293
|
+
|
|
294
|
+
count = _run_async(_run_prune, config, status, tag, hours, queue)
|
|
295
|
+
click.echo(f"Pruned {count or 0} jobs.")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _run_prune(
|
|
299
|
+
config: BaQueueConfig,
|
|
300
|
+
status: str | None,
|
|
301
|
+
tag: str | None,
|
|
302
|
+
hours: float | None,
|
|
303
|
+
queue: str | None,
|
|
304
|
+
) -> int:
|
|
305
|
+
Queue.configure(config)
|
|
306
|
+
await Queue.connect()
|
|
307
|
+
try:
|
|
308
|
+
return await Queue.prune(status=status, tag=tag, hours=hours, queue=queue)
|
|
309
|
+
finally:
|
|
310
|
+
await Queue.disconnect()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@cli.command(name="retry-failed")
|
|
314
|
+
@click.option("--queue", "-q", default=None, help="Limit to a specific queue.")
|
|
315
|
+
@click.option("--tag", "-t", default=None, help="Limit to a specific tag.")
|
|
316
|
+
@click.option("--hours", default=None, type=float, help="Only retry jobs created within the last N hours.")
|
|
317
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
318
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
319
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
|
|
320
|
+
@click.pass_context
|
|
321
|
+
def retry_failed(
|
|
322
|
+
ctx: click.Context,
|
|
323
|
+
queue: str | None,
|
|
324
|
+
tag: str | None,
|
|
325
|
+
hours: float | None,
|
|
326
|
+
driver: str,
|
|
327
|
+
driver_url: str | None,
|
|
328
|
+
yes: bool,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Retry all failed jobs (optionally filtered by queue, tag, or age)."""
|
|
331
|
+
_validate_driver(driver)
|
|
332
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
333
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
334
|
+
|
|
335
|
+
scope_parts = []
|
|
336
|
+
if queue:
|
|
337
|
+
scope_parts.append(f"queue={queue}")
|
|
338
|
+
if tag:
|
|
339
|
+
scope_parts.append(f"tag={tag}")
|
|
340
|
+
if hours:
|
|
341
|
+
scope_parts.append(f"created within last {hours}h")
|
|
342
|
+
scope_str = f" ({', '.join(scope_parts)})" if scope_parts else ""
|
|
343
|
+
|
|
344
|
+
if not yes:
|
|
345
|
+
click.confirm(f"Retry all failed jobs{scope_str}?", abort=True)
|
|
346
|
+
|
|
347
|
+
count = _run_async(_run_retry_failed, config, queue, tag, hours)
|
|
348
|
+
click.echo(f"Retried {count or 0} failed job(s).")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def _run_retry_failed(
|
|
352
|
+
config: BaQueueConfig,
|
|
353
|
+
queue: str | None,
|
|
354
|
+
tag: str | None,
|
|
355
|
+
hours: float | None,
|
|
356
|
+
) -> int:
|
|
357
|
+
from baqueue.serializer import _now_ts
|
|
358
|
+
|
|
359
|
+
Queue.configure(config)
|
|
360
|
+
await Queue.connect()
|
|
361
|
+
try:
|
|
362
|
+
created_from = (_now_ts() - hours * 3600) if hours else None
|
|
363
|
+
return await Queue.retry_failed(queue=queue, tag=tag, created_from=created_from)
|
|
364
|
+
finally:
|
|
365
|
+
await Queue.disconnect()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@cli.command()
|
|
369
|
+
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
370
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
371
|
+
@click.pass_context
|
|
372
|
+
def status(ctx: click.Context, driver: str, driver_url: str | None) -> None:
|
|
373
|
+
"""Show queue status and metrics."""
|
|
374
|
+
_validate_driver(driver)
|
|
375
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
376
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
377
|
+
|
|
378
|
+
_run_async(_show_status, config)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def _show_status(config: BaQueueConfig) -> None:
|
|
382
|
+
Queue.configure(config)
|
|
383
|
+
await Queue.connect()
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
queues = await Queue.queues()
|
|
387
|
+
if not queues:
|
|
388
|
+
click.echo("No queues found.")
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
metrics = await Queue.metrics()
|
|
392
|
+
click.echo(f"{'Queue':<20} {'Pending':<10} {'Processing':<12} {'Completed':<12} {'Failed':<10}")
|
|
393
|
+
click.echo("-" * 64)
|
|
394
|
+
for q in queues:
|
|
395
|
+
m = metrics.get(q, {})
|
|
396
|
+
click.echo(
|
|
397
|
+
f"{q:<20} {m.get('pending', 0):<10} {m.get('processing', 0):<12} "
|
|
398
|
+
f"{m.get('completed', 0):<12} {m.get('failed', 0):<10}"
|
|
399
|
+
)
|
|
400
|
+
finally:
|
|
401
|
+
await Queue.disconnect()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@cli.command()
|
|
405
|
+
@click.option("--match", "-k", default=None, help="Run only tests whose name matches this expression.")
|
|
406
|
+
@click.option("--marker", "-m", default=None, help="Run only tests with the given pytest marker (e.g. 'not slow').")
|
|
407
|
+
@click.option("--path", "-p", default="tests", help="Test path or file (defaults to the tests/ folder).")
|
|
408
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose pytest output.")
|
|
409
|
+
@click.option("--quiet", "-q", is_flag=True, help="Quieter pytest output.")
|
|
410
|
+
@click.option("--stop-on-first-failure", "-x", is_flag=True, help="Stop at the first failure.")
|
|
411
|
+
@click.option("--last-failed", is_flag=True, help="Re-run only tests that failed in the previous run.")
|
|
412
|
+
@click.option(
|
|
413
|
+
"--no-header", is_flag=True, default=False,
|
|
414
|
+
help="Suppress pytest's session header banner.",
|
|
415
|
+
)
|
|
416
|
+
def test(
|
|
417
|
+
match: str | None,
|
|
418
|
+
marker: str | None,
|
|
419
|
+
path: str,
|
|
420
|
+
verbose: bool,
|
|
421
|
+
quiet: bool,
|
|
422
|
+
stop_on_first_failure: bool,
|
|
423
|
+
last_failed: bool,
|
|
424
|
+
no_header: bool,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Run the BaQueue test suite (pytest under the hood)."""
|
|
427
|
+
try:
|
|
428
|
+
import pytest as _pytest
|
|
429
|
+
except ImportError:
|
|
430
|
+
click.echo(
|
|
431
|
+
"Error: pytest is not installed.\n"
|
|
432
|
+
" Install it with: pip install baqueue[dev]\n"
|
|
433
|
+
" Or directly: pip install pytest pytest-asyncio",
|
|
434
|
+
err=True,
|
|
435
|
+
)
|
|
436
|
+
sys.exit(1)
|
|
437
|
+
|
|
438
|
+
args: list[str] = [path]
|
|
439
|
+
if verbose:
|
|
440
|
+
args.append("-v")
|
|
441
|
+
if quiet:
|
|
442
|
+
args.append("-q")
|
|
443
|
+
if stop_on_first_failure:
|
|
444
|
+
args.append("-x")
|
|
445
|
+
if last_failed:
|
|
446
|
+
args.append("--lf")
|
|
447
|
+
if match:
|
|
448
|
+
args.extend(["-k", match])
|
|
449
|
+
if marker:
|
|
450
|
+
args.extend(["-m", marker])
|
|
451
|
+
if no_header:
|
|
452
|
+
args.append("--no-header")
|
|
453
|
+
|
|
454
|
+
exit_code = _pytest.main(args)
|
|
455
|
+
sys.exit(int(exit_code))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
if __name__ == "__main__":
|
|
459
|
+
cli()
|
baqueue/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""BaQueue configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DriverConfig(BaseModel):
|
|
11
|
+
"""Configuration for a specific driver."""
|
|
12
|
+
|
|
13
|
+
name: str = "memory"
|
|
14
|
+
url: str = ""
|
|
15
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SupervisorConfig(BaseModel):
|
|
19
|
+
"""Configuration for a supervisor process."""
|
|
20
|
+
|
|
21
|
+
name: str = "default"
|
|
22
|
+
queues: list[str] = Field(default_factory=lambda: ["default"])
|
|
23
|
+
balance: str = "auto" # auto | simple | false
|
|
24
|
+
min_workers: int = 1
|
|
25
|
+
max_workers: int = 10
|
|
26
|
+
max_jobs_per_worker: int = 0 # 0 = unlimited
|
|
27
|
+
max_time: int = 0 # seconds, 0 = unlimited
|
|
28
|
+
sleep: float = 1.0 # seconds to sleep when queue is empty
|
|
29
|
+
timeout: int = 60 # max job execution time in seconds
|
|
30
|
+
memory_limit: int = 128 # MB
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScheduleEntry(BaseModel):
|
|
34
|
+
"""A scheduled job entry."""
|
|
35
|
+
|
|
36
|
+
job_class: str
|
|
37
|
+
cron: str = ""
|
|
38
|
+
every: int = 0 # seconds
|
|
39
|
+
queue: str = "default"
|
|
40
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaQueueConfig(BaseModel):
|
|
44
|
+
"""Main configuration for BaQueue."""
|
|
45
|
+
|
|
46
|
+
prefix: str = "baqueue"
|
|
47
|
+
driver: DriverConfig = Field(default_factory=DriverConfig)
|
|
48
|
+
supervisors: list[SupervisorConfig] = Field(
|
|
49
|
+
default_factory=lambda: [SupervisorConfig()]
|
|
50
|
+
)
|
|
51
|
+
schedules: list[ScheduleEntry] = Field(default_factory=list)
|
|
52
|
+
dashboard_port: int = 9100
|
|
53
|
+
dashboard_host: str = "0.0.0.0"
|
|
54
|
+
metrics_trim_minutes: int = 1440 # 24 hours
|
|
55
|
+
|
|
56
|
+
# ── Auto-prune (primary, seconds-based) ─────────────────────
|
|
57
|
+
auto_prune: bool = True
|
|
58
|
+
prune_interval_seconds: int = 5
|
|
59
|
+
prune_completed_seconds: int = 5 # delete completed jobs ~5s after completion
|
|
60
|
+
prune_other_seconds: int = 86400 # 1 day — applies to failed + cancelled
|
|
61
|
+
prune_metrics_seconds: int = 604800 # 7 days
|
|
62
|
+
|
|
63
|
+
# ── Legacy hour-based overrides (kept for back-compat) ──────
|
|
64
|
+
# When > 0, these take precedence over the seconds fields above for the
|
|
65
|
+
# corresponding status. Default 0 means "use the *_seconds value".
|
|
66
|
+
prune_completed_hours: float = 0
|
|
67
|
+
prune_failed_hours: float = 0
|
|
68
|
+
prune_cancelled_hours: float = 0
|
|
69
|
+
prune_metrics_hours: float = 0
|
|
70
|
+
|
|
71
|
+
# ── Disk-full auto-cleanup ─────────────────────────────────
|
|
72
|
+
# When a driver write hits "disk full" / "out of memory" / "out of space",
|
|
73
|
+
# the driver runs an emergency cleanup (purges all completed/failed/cancelled
|
|
74
|
+
# jobs + old metrics) and retries the operation once before re-raising.
|
|
75
|
+
auto_cleanup_on_disk_full: bool = True
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict[str, Any]) -> BaQueueConfig:
|
|
79
|
+
return cls(**data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BaQueue monitoring dashboard."""
|