baqueue 1.0.2__tar.gz → 1.2.0__tar.gz
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-1.0.2/baqueue.egg-info → baqueue-1.2.0}/PKG-INFO +39 -2
- {baqueue-1.0.2 → baqueue-1.2.0}/README.md +38 -1
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/__init__.py +1 -1
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/cli.py +29 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/config.py +3 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/api.py +8 -1
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/server.py +5 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/app.js +38 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/index.html +62 -18
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/style.css +17 -1
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/base.py +23 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/memory_driver.py +41 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/postgres_driver.py +45 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/redis_driver.py +108 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/sqlite_driver.py +47 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/serializer.py +13 -3
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/supervisor.py +37 -1
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/worker.py +42 -1
- {baqueue-1.0.2 → baqueue-1.2.0/baqueue.egg-info}/PKG-INFO +39 -2
- {baqueue-1.0.2 → baqueue-1.2.0}/LICENSE +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/MANIFEST.in +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/balancer.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/batch.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/__init__.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/__init__.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/events.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/job.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/pruner.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/queue.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/retry.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/scheduler.py +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/SOURCES.txt +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/dependency_links.txt +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/entry_points.txt +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/requires.txt +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/top_level.txt +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/pyproject.toml +0 -0
- {baqueue-1.0.2 → baqueue-1.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: baqueue
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A powerful Python queue management package inspired by Laravel Horizon
|
|
5
5
|
Author: Basalam, BaQueue Contributors
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
75
75
|
- [Dispatch Jobs](#dispatch-jobs)
|
|
76
76
|
- [Batch Jobs](#batch-jobs)
|
|
77
77
|
- [Run Workers](#run-workers)
|
|
78
|
+
- [Stuck Job Recovery](#stuck-job-recovery)
|
|
78
79
|
- [Pruning](#pruning)
|
|
79
80
|
- [Auto-pruning](#auto-pruning-runs-alongside-baqueue-work)
|
|
80
81
|
- [Manual pruning](#manual-pruning)
|
|
@@ -95,6 +96,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
95
96
|
## Features
|
|
96
97
|
- **Multi-driver**: SQLite (default), Redis, PostgreSQL, or In-Memory
|
|
97
98
|
- **Auto-balancing**: Dynamically scale workers based on queue pressure
|
|
99
|
+
- **Stuck-job recovery**: Jobs left in `processing` for more than 1 hour are requeued automatically
|
|
98
100
|
- **Auto-pruning**: Completed jobs are deleted about 5 seconds after they finish; failed/cancelled jobs are kept up to 1 day — all configurable
|
|
99
101
|
- **Disk-full cleanup**: Storage-full/OOM driver errors trigger emergency cleanup of terminal jobs and old metrics, then retry once
|
|
100
102
|
- **Pruning**: Remove old jobs by status, tag, or age
|
|
@@ -213,6 +215,41 @@ Or via CLI:
|
|
|
213
215
|
baqueue work -q emails -q payments -w 3 -b auto
|
|
214
216
|
```
|
|
215
217
|
|
|
218
|
+
#### Stuck Job Recovery
|
|
219
|
+
|
|
220
|
+
When `baqueue work` is running, the supervisor also checks for jobs that were
|
|
221
|
+
claimed by a worker but never finished. By default, any job that has stayed in
|
|
222
|
+
`processing` for more than 1 hour is moved back to `pending`, so another worker
|
|
223
|
+
can pick it up and run it again.
|
|
224
|
+
|
|
225
|
+
This is intended for worker crashes, process restarts, or other cases where a
|
|
226
|
+
job was left in-flight. The original claim still counts as an attempt; when the
|
|
227
|
+
job is picked up again, its attempt counter continues from there.
|
|
228
|
+
|
|
229
|
+
Configure it from Python:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
supervisor = Supervisor(
|
|
233
|
+
driver=Queue.get_driver(),
|
|
234
|
+
config=SupervisorConfig(
|
|
235
|
+
queues=["emails"],
|
|
236
|
+
recover_stuck_jobs=True,
|
|
237
|
+
stuck_processing_seconds=3600,
|
|
238
|
+
stuck_check_interval_seconds=60,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Or from the CLI:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
baqueue work --stuck-job-timeout-seconds 7200
|
|
247
|
+
baqueue work --no-stuck-job-recovery
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
If you intentionally run jobs for longer than 1 hour, increase
|
|
251
|
+
`stuck_processing_seconds` above your longest expected runtime.
|
|
252
|
+
|
|
216
253
|
### Pruning
|
|
217
254
|
|
|
218
255
|
#### Auto-pruning (runs alongside `baqueue work`)
|
|
@@ -509,7 +546,7 @@ Coverage includes:
|
|
|
509
546
|
- `Queue` facade — push / later / bulk / prune / `retry_failed`
|
|
510
547
|
- Cross-driver contract tests (memory + sqlite, parameterized)
|
|
511
548
|
- Worker lifecycle: success / failure / retry / timeout
|
|
512
|
-
- Supervisor pool + delayed-job promotion
|
|
549
|
+
- Supervisor pool + delayed-job promotion + stuck-job recovery
|
|
513
550
|
- Scheduler interval dispatch
|
|
514
551
|
- Pruner by status / tag / age
|
|
515
552
|
- Batch builder + completion callbacks
|
|
@@ -23,6 +23,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
23
23
|
- [Dispatch Jobs](#dispatch-jobs)
|
|
24
24
|
- [Batch Jobs](#batch-jobs)
|
|
25
25
|
- [Run Workers](#run-workers)
|
|
26
|
+
- [Stuck Job Recovery](#stuck-job-recovery)
|
|
26
27
|
- [Pruning](#pruning)
|
|
27
28
|
- [Auto-pruning](#auto-pruning-runs-alongside-baqueue-work)
|
|
28
29
|
- [Manual pruning](#manual-pruning)
|
|
@@ -43,6 +44,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
43
44
|
## Features
|
|
44
45
|
- **Multi-driver**: SQLite (default), Redis, PostgreSQL, or In-Memory
|
|
45
46
|
- **Auto-balancing**: Dynamically scale workers based on queue pressure
|
|
47
|
+
- **Stuck-job recovery**: Jobs left in `processing` for more than 1 hour are requeued automatically
|
|
46
48
|
- **Auto-pruning**: Completed jobs are deleted about 5 seconds after they finish; failed/cancelled jobs are kept up to 1 day — all configurable
|
|
47
49
|
- **Disk-full cleanup**: Storage-full/OOM driver errors trigger emergency cleanup of terminal jobs and old metrics, then retry once
|
|
48
50
|
- **Pruning**: Remove old jobs by status, tag, or age
|
|
@@ -161,6 +163,41 @@ Or via CLI:
|
|
|
161
163
|
baqueue work -q emails -q payments -w 3 -b auto
|
|
162
164
|
```
|
|
163
165
|
|
|
166
|
+
#### Stuck Job Recovery
|
|
167
|
+
|
|
168
|
+
When `baqueue work` is running, the supervisor also checks for jobs that were
|
|
169
|
+
claimed by a worker but never finished. By default, any job that has stayed in
|
|
170
|
+
`processing` for more than 1 hour is moved back to `pending`, so another worker
|
|
171
|
+
can pick it up and run it again.
|
|
172
|
+
|
|
173
|
+
This is intended for worker crashes, process restarts, or other cases where a
|
|
174
|
+
job was left in-flight. The original claim still counts as an attempt; when the
|
|
175
|
+
job is picked up again, its attempt counter continues from there.
|
|
176
|
+
|
|
177
|
+
Configure it from Python:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
supervisor = Supervisor(
|
|
181
|
+
driver=Queue.get_driver(),
|
|
182
|
+
config=SupervisorConfig(
|
|
183
|
+
queues=["emails"],
|
|
184
|
+
recover_stuck_jobs=True,
|
|
185
|
+
stuck_processing_seconds=3600,
|
|
186
|
+
stuck_check_interval_seconds=60,
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Or from the CLI:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
baqueue work --stuck-job-timeout-seconds 7200
|
|
195
|
+
baqueue work --no-stuck-job-recovery
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
If you intentionally run jobs for longer than 1 hour, increase
|
|
199
|
+
`stuck_processing_seconds` above your longest expected runtime.
|
|
200
|
+
|
|
164
201
|
### Pruning
|
|
165
202
|
|
|
166
203
|
#### Auto-pruning (runs alongside `baqueue work`)
|
|
@@ -457,7 +494,7 @@ Coverage includes:
|
|
|
457
494
|
- `Queue` facade — push / later / bulk / prune / `retry_failed`
|
|
458
495
|
- Cross-driver contract tests (memory + sqlite, parameterized)
|
|
459
496
|
- Worker lifecycle: success / failure / retry / timeout
|
|
460
|
-
- Supervisor pool + delayed-job promotion
|
|
497
|
+
- Supervisor pool + delayed-job promotion + stuck-job recovery
|
|
461
498
|
- Scheduler interval dispatch
|
|
462
499
|
- Pruner by status / tag / age
|
|
463
500
|
- Batch builder + completion callbacks
|
|
@@ -103,6 +103,18 @@ def cli(ctx: click.Context, config: str | None, verbose: bool) -> None:
|
|
|
103
103
|
"--no-disk-full-cleanup", is_flag=True,
|
|
104
104
|
help="Disable automatic emergency cleanup when the driver returns a disk-full error.",
|
|
105
105
|
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--no-stuck-job-recovery", is_flag=True,
|
|
108
|
+
help="Disable automatic recovery of jobs stuck in processing.",
|
|
109
|
+
)
|
|
110
|
+
@click.option(
|
|
111
|
+
"--stuck-job-timeout-seconds", type=int, default=None,
|
|
112
|
+
help="Requeue processing jobs older than N seconds (default 3600).",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--stuck-job-check-interval-seconds", type=int, default=None,
|
|
116
|
+
help="How often stuck-job recovery runs, in seconds (default 60).",
|
|
117
|
+
)
|
|
106
118
|
@click.pass_context
|
|
107
119
|
def work(
|
|
108
120
|
ctx: click.Context,
|
|
@@ -119,6 +131,9 @@ def work(
|
|
|
119
131
|
prune_other_seconds: int | None,
|
|
120
132
|
prune_interval_seconds: int | None,
|
|
121
133
|
no_disk_full_cleanup: bool,
|
|
134
|
+
no_stuck_job_recovery: bool,
|
|
135
|
+
stuck_job_timeout_seconds: int | None,
|
|
136
|
+
stuck_job_check_interval_seconds: int | None,
|
|
122
137
|
) -> None:
|
|
123
138
|
"""Start processing jobs."""
|
|
124
139
|
config: BaQueueConfig = ctx.obj["config"]
|
|
@@ -143,6 +158,13 @@ def work(
|
|
|
143
158
|
sleep=sleep,
|
|
144
159
|
timeout=timeout,
|
|
145
160
|
max_jobs_per_worker=max_jobs,
|
|
161
|
+
recover_stuck_jobs=not no_stuck_job_recovery,
|
|
162
|
+
stuck_processing_seconds=(
|
|
163
|
+
3600 if stuck_job_timeout_seconds is None else stuck_job_timeout_seconds
|
|
164
|
+
),
|
|
165
|
+
stuck_check_interval_seconds=(
|
|
166
|
+
60 if stuck_job_check_interval_seconds is None else stuck_job_check_interval_seconds
|
|
167
|
+
),
|
|
146
168
|
)
|
|
147
169
|
|
|
148
170
|
_validate_driver(driver)
|
|
@@ -162,6 +184,13 @@ def work(
|
|
|
162
184
|
click.echo(
|
|
163
185
|
f" Disk-full cleanup: {'enabled' if config.auto_cleanup_on_disk_full else 'disabled'}"
|
|
164
186
|
)
|
|
187
|
+
if supervisor_config.recover_stuck_jobs:
|
|
188
|
+
click.echo(
|
|
189
|
+
f" Stuck-job recovery: processing>{supervisor_config.stuck_processing_seconds}s, "
|
|
190
|
+
f"every {supervisor_config.stuck_check_interval_seconds}s"
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
click.echo(" Stuck-job recovery: disabled")
|
|
165
194
|
click.echo()
|
|
166
195
|
|
|
167
196
|
_run_async(_run_worker, config, supervisor_config)
|
|
@@ -28,6 +28,9 @@ class SupervisorConfig(BaseModel):
|
|
|
28
28
|
sleep: float = 1.0 # seconds to sleep when queue is empty
|
|
29
29
|
timeout: int = 60 # max job execution time in seconds
|
|
30
30
|
memory_limit: int = 128 # MB
|
|
31
|
+
recover_stuck_jobs: bool = True
|
|
32
|
+
stuck_processing_seconds: int = 3600
|
|
33
|
+
stuck_check_interval_seconds: int = 60
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
class ScheduleEntry(BaseModel):
|
|
@@ -117,7 +117,10 @@ class DashboardAPI:
|
|
|
117
117
|
created_from=created_from, created_to=created_to,
|
|
118
118
|
)
|
|
119
119
|
return {
|
|
120
|
-
|
|
120
|
+
# The list view never renders per-attempt history (the modal fetches
|
|
121
|
+
# job_detail for that), so omit it to keep the list and the live
|
|
122
|
+
# /ws/jobs push lean.
|
|
123
|
+
"jobs": [j.to_dict(include_history=False) for j in jobs],
|
|
121
124
|
"page": page,
|
|
122
125
|
"per_page": per_page,
|
|
123
126
|
"count": len(jobs),
|
|
@@ -128,6 +131,10 @@ class DashboardAPI:
|
|
|
128
131
|
job = await self.driver.get_job(job_id)
|
|
129
132
|
return job.to_dict() if job else None
|
|
130
133
|
|
|
134
|
+
async def promote_job(self, job_id: str) -> bool:
|
|
135
|
+
"""Make a scheduled/pending job runnable immediately. Returns True on success."""
|
|
136
|
+
return await self.driver.promote(job_id)
|
|
137
|
+
|
|
131
138
|
async def retry_job(self, job_id: str) -> bool:
|
|
132
139
|
job = await self.driver.get_job(job_id)
|
|
133
140
|
if not job or job.status != "failed":
|
|
@@ -150,6 +150,11 @@ def create_app(driver: BaseDriver, config: Optional[BaQueueConfig] = None) -> An
|
|
|
150
150
|
ok = await api.retry_job(job_id)
|
|
151
151
|
return JSONResponse({"success": ok})
|
|
152
152
|
|
|
153
|
+
@app.post("/api/jobs/{job_id}/execute")
|
|
154
|
+
async def execute_job(job_id: str):
|
|
155
|
+
ok = await api.promote_job(job_id)
|
|
156
|
+
return JSONResponse({"success": ok})
|
|
157
|
+
|
|
153
158
|
@app.delete("/api/jobs/{job_id}")
|
|
154
159
|
async def delete_job(job_id: str):
|
|
155
160
|
ok = await api.delete_job(job_id)
|
|
@@ -322,6 +322,14 @@ document.addEventListener("alpine:init", () => {
|
|
|
322
322
|
this.fetchOverview();
|
|
323
323
|
},
|
|
324
324
|
|
|
325
|
+
async executeJob(jobId) {
|
|
326
|
+
// Promote a scheduled/pending job so it runs immediately.
|
|
327
|
+
await fetch(`/api/jobs/${jobId}/execute`, { method: "POST" });
|
|
328
|
+
this.closeModal();
|
|
329
|
+
this.fetchJobs();
|
|
330
|
+
this.fetchOverview();
|
|
331
|
+
},
|
|
332
|
+
|
|
325
333
|
async retryAllFailed() {
|
|
326
334
|
const parts = [];
|
|
327
335
|
if (this.jobsFilter.queue) parts.push(`queue "${this.jobsFilter.queue}"`);
|
|
@@ -437,6 +445,36 @@ document.addEventListener("alpine:init", () => {
|
|
|
437
445
|
return Math.floor(diff / 60) + "m " + Math.floor(diff % 60) + "s";
|
|
438
446
|
},
|
|
439
447
|
|
|
448
|
+
// ── Per-attempt timeline ────────────────────────────────
|
|
449
|
+
|
|
450
|
+
attemptHistory(job) {
|
|
451
|
+
return job && Array.isArray(job.history) ? job.history : [];
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
hasHistory(job) {
|
|
455
|
+
return this.attemptHistory(job).length > 0;
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// A job currently processing has an in-flight attempt that isn't recorded in
|
|
459
|
+
// history yet (entries are appended only when an attempt concludes).
|
|
460
|
+
inFlightAttempt(job) {
|
|
461
|
+
return !!(job && job.status === "processing" && job.started_at);
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
attemptDotClass(entry) {
|
|
465
|
+
return entry && entry.status === "completed" ? "completed" : "failed";
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
attemptDuration(entry) {
|
|
469
|
+
if (!entry || !entry.started_at || !entry.finished_at) return "";
|
|
470
|
+
const diff = entry.finished_at - entry.started_at;
|
|
471
|
+
if (diff < 0) return "";
|
|
472
|
+
if (diff < 0.001) return "<1ms";
|
|
473
|
+
if (diff < 1) return Math.round(diff * 1000) + "ms";
|
|
474
|
+
if (diff < 60) return diff.toFixed(1) + "s";
|
|
475
|
+
return Math.floor(diff / 60) + "m " + Math.floor(diff % 60) + "s";
|
|
476
|
+
},
|
|
477
|
+
|
|
440
478
|
shortId(id) {
|
|
441
479
|
return id ? id.substring(0, 12) : "-";
|
|
442
480
|
},
|
|
@@ -511,27 +511,67 @@
|
|
|
511
511
|
<span class="tl-time" x-text="formatTimeFull(selectedJob.delay_until)"></span>
|
|
512
512
|
</div>
|
|
513
513
|
</div>
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
514
|
+
<!-- Per-attempt history (jobs that ran at least once on a
|
|
515
|
+
driver that persists history). Each backoff retry is its
|
|
516
|
+
own entry. -->
|
|
517
|
+
<template x-for="(entry, idx) in attemptHistory(selectedJob)" :key="idx">
|
|
518
|
+
<div class="tl-item">
|
|
519
|
+
<div class="tl-dot" :class="attemptDotClass(entry)"></div>
|
|
520
|
+
<div class="tl-content">
|
|
521
|
+
<span class="tl-label">
|
|
522
|
+
Attempt <span x-text="entry.attempt"></span> ·
|
|
523
|
+
<span x-text="entry.status"></span>
|
|
524
|
+
<span class="tl-dur" x-show="attemptDuration(entry)" x-text="'(' + attemptDuration(entry) + ')'"></span>
|
|
525
|
+
</span>
|
|
526
|
+
<span class="tl-time" x-text="formatTimeFull(entry.started_at) + (entry.finished_at ? ' → ' + formatTimeFull(entry.finished_at) : '')"></span>
|
|
527
|
+
<span class="tl-retry" x-show="entry.will_retry">
|
|
528
|
+
Retry scheduled <span x-text="entry.next_retry_at ? scheduledIn(entry.next_retry_at) : ''"></span>
|
|
529
|
+
</span>
|
|
530
|
+
<pre class="tl-error" x-show="entry.error" x-text="entry.error"></pre>
|
|
531
|
+
</div>
|
|
519
532
|
</div>
|
|
520
|
-
</
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
<
|
|
533
|
+
</template>
|
|
534
|
+
<!-- The currently-running attempt is not recorded in history
|
|
535
|
+
until it concludes, so surface it live. -->
|
|
536
|
+
<template x-if="inFlightAttempt(selectedJob)">
|
|
537
|
+
<div class="tl-item">
|
|
538
|
+
<div class="tl-dot processing"></div>
|
|
539
|
+
<div class="tl-content">
|
|
540
|
+
<span class="tl-label">Attempt <span x-text="selectedJob.attempts"></span> · running…</span>
|
|
541
|
+
<span class="tl-time" x-text="formatTimeFull(selectedJob.started_at)"></span>
|
|
542
|
+
</div>
|
|
526
543
|
</div>
|
|
527
|
-
</
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
544
|
+
</template>
|
|
545
|
+
|
|
546
|
+
<!-- Legacy single-attempt timeline: jobs created before history
|
|
547
|
+
tracking, or on drivers that don't persist history. -->
|
|
548
|
+
<template x-if="!hasHistory(selectedJob) && !inFlightAttempt(selectedJob) && selectedJob.started_at">
|
|
549
|
+
<div class="tl-item">
|
|
550
|
+
<div class="tl-dot processing"></div>
|
|
551
|
+
<div class="tl-content">
|
|
552
|
+
<span class="tl-label">Started</span>
|
|
553
|
+
<span class="tl-time" x-text="formatTimeFull(selectedJob.started_at)"></span>
|
|
554
|
+
</div>
|
|
533
555
|
</div>
|
|
534
|
-
</
|
|
556
|
+
</template>
|
|
557
|
+
<template x-if="!hasHistory(selectedJob) && selectedJob.completed_at">
|
|
558
|
+
<div class="tl-item">
|
|
559
|
+
<div class="tl-dot completed"></div>
|
|
560
|
+
<div class="tl-content">
|
|
561
|
+
<span class="tl-label">Completed</span>
|
|
562
|
+
<span class="tl-time" x-text="formatTimeFull(selectedJob.completed_at)"></span>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
</template>
|
|
566
|
+
<template x-if="!hasHistory(selectedJob) && selectedJob.failed_at">
|
|
567
|
+
<div class="tl-item">
|
|
568
|
+
<div class="tl-dot failed"></div>
|
|
569
|
+
<div class="tl-content">
|
|
570
|
+
<span class="tl-label">Failed</span>
|
|
571
|
+
<span class="tl-time" x-text="formatTimeFull(selectedJob.failed_at)"></span>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
</template>
|
|
535
575
|
</div>
|
|
536
576
|
</div>
|
|
537
577
|
|
|
@@ -563,6 +603,10 @@
|
|
|
563
603
|
</div>
|
|
564
604
|
|
|
565
605
|
<div class="modal-actions">
|
|
606
|
+
<button class="btn-primary" x-show="isScheduled(selectedJob)" @click="executeJob(selectedJob.id)">
|
|
607
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
608
|
+
Execute Now
|
|
609
|
+
</button>
|
|
566
610
|
<button class="btn-primary" x-show="selectedJob.status === 'failed'" @click="retryJob(selectedJob.id)">
|
|
567
611
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
|
568
612
|
Retry Job
|
|
@@ -1267,8 +1267,24 @@ body {
|
|
|
1267
1267
|
.tl-dot.failed { border-color: var(--red); background: var(--red); }
|
|
1268
1268
|
|
|
1269
1269
|
.tl-content { display: flex; flex-direction: column; gap: 1px; }
|
|
1270
|
-
.tl-label { font-size: 13px; font-weight: 600; }
|
|
1270
|
+
.tl-label { font-size: 13px; font-weight: 600; text-transform: capitalize; }
|
|
1271
1271
|
.tl-time { font-size: 12px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
|
|
1272
|
+
.tl-dur { font-weight: 400; color: var(--text-muted); }
|
|
1273
|
+
.tl-retry { font-size: 12px; color: var(--amber); }
|
|
1274
|
+
.tl-error {
|
|
1275
|
+
margin: 4px 0 0;
|
|
1276
|
+
padding: 6px 8px;
|
|
1277
|
+
font-size: 11px;
|
|
1278
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1279
|
+
color: var(--red);
|
|
1280
|
+
background: var(--bg-surface);
|
|
1281
|
+
border: 1px solid var(--border);
|
|
1282
|
+
border-radius: 6px;
|
|
1283
|
+
white-space: pre-wrap;
|
|
1284
|
+
word-break: break-word;
|
|
1285
|
+
max-height: 140px;
|
|
1286
|
+
overflow: auto;
|
|
1287
|
+
}
|
|
1272
1288
|
|
|
1273
1289
|
/* ── Tags ───────────────────────────────────────────────── */
|
|
1274
1290
|
|
|
@@ -112,9 +112,32 @@ class BaseDriver(ABC):
|
|
|
112
112
|
"""Release a job back onto the queue (for retries)."""
|
|
113
113
|
...
|
|
114
114
|
|
|
115
|
+
@abstractmethod
|
|
116
|
+
async def requeue_stuck_jobs(
|
|
117
|
+
self,
|
|
118
|
+
older_than_seconds: float,
|
|
119
|
+
queue: str | None = None,
|
|
120
|
+
) -> int:
|
|
121
|
+
"""Move stale processing jobs back to pending. Returns count requeued."""
|
|
122
|
+
...
|
|
123
|
+
|
|
115
124
|
@abstractmethod
|
|
116
125
|
async def delete(self, job_id: str) -> None: ...
|
|
117
126
|
|
|
127
|
+
async def promote(self, job_id: str) -> bool:
|
|
128
|
+
"""Make a scheduled/pending job runnable immediately (clear its delay).
|
|
129
|
+
|
|
130
|
+
Returns True if the job was promoted, False if it does not exist or is not
|
|
131
|
+
in the ``pending`` state. Concrete (non-abstract) so existing third-party
|
|
132
|
+
drivers keep working; the built-in drivers override it with a race-safe,
|
|
133
|
+
index-aware version. The default relies on ``release(delay=0)`` to enqueue
|
|
134
|
+
the job for immediate processing."""
|
|
135
|
+
job = await self.get_job(job_id)
|
|
136
|
+
if job is None or job.status != "pending":
|
|
137
|
+
return False
|
|
138
|
+
await self.release(job, delay=0)
|
|
139
|
+
return True
|
|
140
|
+
|
|
118
141
|
# ── Query ───────────────────────────────────────────────────
|
|
119
142
|
|
|
120
143
|
@abstractmethod
|
|
@@ -107,6 +107,32 @@ class MemoryDriver(BaseDriver):
|
|
|
107
107
|
self._queues[payload.queue].append(payload.id)
|
|
108
108
|
self._jobs[payload.id] = payload
|
|
109
109
|
|
|
110
|
+
async def requeue_stuck_jobs(
|
|
111
|
+
self,
|
|
112
|
+
older_than_seconds: float,
|
|
113
|
+
queue: str | None = None,
|
|
114
|
+
) -> int:
|
|
115
|
+
cutoff = _now_ts() - older_than_seconds
|
|
116
|
+
now = _now_ts()
|
|
117
|
+
count = 0
|
|
118
|
+
async with self._lock:
|
|
119
|
+
for payload in self._jobs.values():
|
|
120
|
+
if queue and payload.queue != queue:
|
|
121
|
+
continue
|
|
122
|
+
if payload.status != "processing":
|
|
123
|
+
continue
|
|
124
|
+
started = payload.started_at or payload.updated_at
|
|
125
|
+
if started is None or started > cutoff:
|
|
126
|
+
continue
|
|
127
|
+
payload.status = "pending"
|
|
128
|
+
payload.started_at = None
|
|
129
|
+
payload.delay_until = None
|
|
130
|
+
payload.updated_at = now
|
|
131
|
+
if payload.id not in self._queues[payload.queue]:
|
|
132
|
+
self._queues[payload.queue].append(payload.id)
|
|
133
|
+
count += 1
|
|
134
|
+
return count
|
|
135
|
+
|
|
110
136
|
async def delete(self, job_id: str) -> None:
|
|
111
137
|
async with self._lock:
|
|
112
138
|
self._jobs.pop(job_id, None)
|
|
@@ -116,6 +142,21 @@ class MemoryDriver(BaseDriver):
|
|
|
116
142
|
if job_id in self._delayed:
|
|
117
143
|
self._delayed.remove(job_id)
|
|
118
144
|
|
|
145
|
+
async def promote(self, job_id: str) -> bool:
|
|
146
|
+
async with self._lock:
|
|
147
|
+
payload = self._jobs.get(job_id)
|
|
148
|
+
if payload is None or payload.status != "pending":
|
|
149
|
+
return False
|
|
150
|
+
payload.delay_until = None
|
|
151
|
+
payload.updated_at = _now_ts()
|
|
152
|
+
if job_id in self._delayed:
|
|
153
|
+
self._delayed.remove(job_id)
|
|
154
|
+
# Only enqueue if it isn't already ready, so promoting a non-delayed
|
|
155
|
+
# pending job can never duplicate it in the ready list.
|
|
156
|
+
if job_id not in self._queues[payload.queue]:
|
|
157
|
+
self._queues[payload.queue].append(job_id)
|
|
158
|
+
return True
|
|
159
|
+
|
|
119
160
|
# ── Query ───────────────────────────────────────────────────
|
|
120
161
|
|
|
121
162
|
async def get_job(self, job_id: str) -> JobPayload | None:
|
|
@@ -317,6 +317,33 @@ class PostgresDriver(BaseDriver):
|
|
|
317
317
|
|
|
318
318
|
await self._with_disk_full_recovery(_do)
|
|
319
319
|
|
|
320
|
+
async def requeue_stuck_jobs(
|
|
321
|
+
self,
|
|
322
|
+
older_than_seconds: float,
|
|
323
|
+
queue: str | None = None,
|
|
324
|
+
) -> int:
|
|
325
|
+
now = _now_ts()
|
|
326
|
+
cutoff = now - older_than_seconds
|
|
327
|
+
conditions = ["status='processing'", "COALESCE(started_at, updated_at) <= $2"]
|
|
328
|
+
params: list[Any] = [now, cutoff]
|
|
329
|
+
idx = 3
|
|
330
|
+
if queue:
|
|
331
|
+
conditions.append(f"queue=${idx}")
|
|
332
|
+
params.append(queue)
|
|
333
|
+
where = " AND ".join(conditions)
|
|
334
|
+
|
|
335
|
+
async def _do():
|
|
336
|
+
async with self._pool.acquire() as conn:
|
|
337
|
+
return await conn.execute(
|
|
338
|
+
f"""UPDATE {self._jobs_table}
|
|
339
|
+
SET status='pending', started_at=NULL, delay_until=NULL, updated_at=$1
|
|
340
|
+
WHERE {where}""",
|
|
341
|
+
*params,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
result = await self._with_disk_full_recovery(_do)
|
|
345
|
+
return int(result.split()[-1])
|
|
346
|
+
|
|
320
347
|
async def delete(self, job_id: str) -> None:
|
|
321
348
|
async def _do():
|
|
322
349
|
async with self._pool.acquire() as conn:
|
|
@@ -324,6 +351,24 @@ class PostgresDriver(BaseDriver):
|
|
|
324
351
|
|
|
325
352
|
await self._with_disk_full_recovery(_do)
|
|
326
353
|
|
|
354
|
+
async def promote(self, job_id: str) -> bool:
|
|
355
|
+
now = _now_ts()
|
|
356
|
+
|
|
357
|
+
async def _do():
|
|
358
|
+
async with self._pool.acquire() as conn:
|
|
359
|
+
# Clearing delay_until is enough: pop() already accepts a pending
|
|
360
|
+
# row whose delay_until IS NULL or has elapsed.
|
|
361
|
+
return await conn.fetchrow(
|
|
362
|
+
f"""UPDATE {self._jobs_table}
|
|
363
|
+
SET delay_until=NULL, updated_at=$1
|
|
364
|
+
WHERE id=$2 AND status='pending'
|
|
365
|
+
RETURNING id""",
|
|
366
|
+
now, job_id,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
row = await self._with_disk_full_recovery(_do)
|
|
370
|
+
return row is not None
|
|
371
|
+
|
|
327
372
|
# ── Query ───────────────────────────────────────────────────
|
|
328
373
|
|
|
329
374
|
async def get_job(self, job_id: str) -> JobPayload | None:
|
|
@@ -296,6 +296,88 @@ class RedisDriver(BaseDriver):
|
|
|
296
296
|
await pipe.execute()
|
|
297
297
|
await self._with_disk_full_recovery(_do)
|
|
298
298
|
|
|
299
|
+
async def _requeue_stuck_job(self, job_id: str, cutoff: float) -> int:
|
|
300
|
+
from redis.exceptions import WatchError
|
|
301
|
+
|
|
302
|
+
job_key = self._key("job", job_id)
|
|
303
|
+
|
|
304
|
+
async def _attempt() -> int:
|
|
305
|
+
pipe = self._redis.pipeline()
|
|
306
|
+
try:
|
|
307
|
+
await pipe.watch(job_key)
|
|
308
|
+
raw = await pipe.hget(job_key, "data")
|
|
309
|
+
if not raw:
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
payload = JobPayload.from_json(raw)
|
|
313
|
+
if payload.status != "processing":
|
|
314
|
+
return 0
|
|
315
|
+
started = payload.started_at or payload.updated_at
|
|
316
|
+
if started is None or started > cutoff:
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
payload.status = "pending"
|
|
320
|
+
payload.started_at = None
|
|
321
|
+
payload.delay_until = None
|
|
322
|
+
payload.updated_at = _now_ts()
|
|
323
|
+
|
|
324
|
+
pipe.multi()
|
|
325
|
+
pipe.hset(job_key, mapping={"data": payload.to_json()})
|
|
326
|
+
pipe.zrem(self._key("delayed"), job_id)
|
|
327
|
+
pipe.lrem(self._key("queue", payload.queue), 0, job_id)
|
|
328
|
+
pipe.rpush(self._key("queue", payload.queue), job_id)
|
|
329
|
+
self._index_status_change(
|
|
330
|
+
pipe,
|
|
331
|
+
job_id,
|
|
332
|
+
payload.queue,
|
|
333
|
+
"processing",
|
|
334
|
+
"pending",
|
|
335
|
+
payload.created_at,
|
|
336
|
+
)
|
|
337
|
+
await pipe.execute()
|
|
338
|
+
return 1
|
|
339
|
+
finally:
|
|
340
|
+
await pipe.reset()
|
|
341
|
+
|
|
342
|
+
for _ in range(5):
|
|
343
|
+
try:
|
|
344
|
+
return int(await self._with_disk_full_recovery(_attempt) or 0)
|
|
345
|
+
except WatchError:
|
|
346
|
+
continue
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
async def requeue_stuck_jobs(
|
|
350
|
+
self,
|
|
351
|
+
older_than_seconds: float,
|
|
352
|
+
queue: str | None = None,
|
|
353
|
+
) -> int:
|
|
354
|
+
cutoff = _now_ts() - older_than_seconds
|
|
355
|
+
index = self._index_key(queue, "processing")
|
|
356
|
+
ids = await self._redis.zrange(index, 0, -1)
|
|
357
|
+
if not ids:
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
pipe = self._redis.pipeline()
|
|
361
|
+
for jid in ids:
|
|
362
|
+
pipe.hget(self._key("job", jid), "data")
|
|
363
|
+
raws = await pipe.execute()
|
|
364
|
+
|
|
365
|
+
count = 0
|
|
366
|
+
for jid, raw in zip(ids, raws):
|
|
367
|
+
if not raw:
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
job = JobPayload.from_json(raw)
|
|
371
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
372
|
+
continue
|
|
373
|
+
if queue and job.queue != queue:
|
|
374
|
+
continue
|
|
375
|
+
started = job.started_at or job.updated_at
|
|
376
|
+
if started is None or started > cutoff:
|
|
377
|
+
continue
|
|
378
|
+
count += await self._requeue_stuck_job(jid, cutoff)
|
|
379
|
+
return count
|
|
380
|
+
|
|
299
381
|
async def delete(self, job_id: str) -> None:
|
|
300
382
|
raw = await self._redis.hget(self._key("job", job_id), "data")
|
|
301
383
|
|
|
@@ -310,6 +392,32 @@ class RedisDriver(BaseDriver):
|
|
|
310
392
|
await pipe.execute()
|
|
311
393
|
await self._with_disk_full_recovery(_do)
|
|
312
394
|
|
|
395
|
+
async def promote(self, job_id: str) -> bool:
|
|
396
|
+
raw = await self._redis.hget(self._key("job", job_id), "data")
|
|
397
|
+
if not raw:
|
|
398
|
+
return False
|
|
399
|
+
payload = JobPayload.from_json(raw)
|
|
400
|
+
if payload.status != "pending":
|
|
401
|
+
return False
|
|
402
|
+
now = _now_ts()
|
|
403
|
+
# Only a job actually sitting in the delayed ZSET needs to be moved into
|
|
404
|
+
# its ready list. A pending job that is already ready (delay_until None or
|
|
405
|
+
# in the past) must NOT be re-pushed, or Redis pop — which does not
|
|
406
|
+
# re-check status — would process it twice.
|
|
407
|
+
was_scheduled = payload.delay_until is not None and payload.delay_until > now
|
|
408
|
+
payload.delay_until = None
|
|
409
|
+
payload.updated_at = now
|
|
410
|
+
|
|
411
|
+
async def _do():
|
|
412
|
+
pipe = self._redis.pipeline()
|
|
413
|
+
pipe.hset(self._key("job", job_id), mapping={"data": payload.to_json()})
|
|
414
|
+
if was_scheduled:
|
|
415
|
+
pipe.zrem(self._key("delayed"), job_id)
|
|
416
|
+
pipe.rpush(self._key("queue", payload.queue), job_id)
|
|
417
|
+
await pipe.execute()
|
|
418
|
+
await self._with_disk_full_recovery(_do)
|
|
419
|
+
return True
|
|
420
|
+
|
|
313
421
|
# ── Query ───────────────────────────────────────────────────
|
|
314
422
|
|
|
315
423
|
async def get_job(self, job_id: str) -> JobPayload | None:
|
|
@@ -369,6 +369,36 @@ class SqliteDriver(BaseDriver):
|
|
|
369
369
|
c.commit()
|
|
370
370
|
await self._execute_with_retry(_do)
|
|
371
371
|
|
|
372
|
+
async def requeue_stuck_jobs(
|
|
373
|
+
self,
|
|
374
|
+
older_than_seconds: float,
|
|
375
|
+
queue: str | None = None,
|
|
376
|
+
) -> int:
|
|
377
|
+
now = _now_ts()
|
|
378
|
+
cutoff = now - older_than_seconds
|
|
379
|
+
conditions = ["status='processing'", "COALESCE(started_at, updated_at) <= ?"]
|
|
380
|
+
params: list[Any] = [cutoff]
|
|
381
|
+
if queue:
|
|
382
|
+
conditions.append("queue=?")
|
|
383
|
+
params.append(queue)
|
|
384
|
+
params.insert(0, now)
|
|
385
|
+
where = " AND ".join(conditions)
|
|
386
|
+
result = [0]
|
|
387
|
+
|
|
388
|
+
async with self._lock:
|
|
389
|
+
def _do():
|
|
390
|
+
c = self._get_conn()
|
|
391
|
+
cur = c.execute(
|
|
392
|
+
f"""UPDATE jobs
|
|
393
|
+
SET status='pending', started_at=NULL, delay_until=NULL, updated_at=?
|
|
394
|
+
WHERE {where}""",
|
|
395
|
+
params,
|
|
396
|
+
)
|
|
397
|
+
c.commit()
|
|
398
|
+
result[0] = cur.rowcount
|
|
399
|
+
await self._execute_with_retry(_do)
|
|
400
|
+
return result[0]
|
|
401
|
+
|
|
372
402
|
async def delete(self, job_id: str) -> None:
|
|
373
403
|
async with self._lock:
|
|
374
404
|
def _do():
|
|
@@ -377,6 +407,23 @@ class SqliteDriver(BaseDriver):
|
|
|
377
407
|
c.commit()
|
|
378
408
|
await self._execute_with_retry(_do)
|
|
379
409
|
|
|
410
|
+
async def promote(self, job_id: str) -> bool:
|
|
411
|
+
now = _now_ts()
|
|
412
|
+
async with self._lock:
|
|
413
|
+
result = [False]
|
|
414
|
+
def _do():
|
|
415
|
+
c = self._get_conn()
|
|
416
|
+
# Clearing delay_until is enough: pop() already accepts a pending
|
|
417
|
+
# row whose delay_until IS NULL or has elapsed.
|
|
418
|
+
cur = c.execute(
|
|
419
|
+
"UPDATE jobs SET delay_until=NULL, updated_at=? WHERE id=? AND status='pending'",
|
|
420
|
+
(now, job_id),
|
|
421
|
+
)
|
|
422
|
+
c.commit()
|
|
423
|
+
result[0] = cur.rowcount == 1
|
|
424
|
+
await self._execute_with_retry(_do)
|
|
425
|
+
return result[0]
|
|
426
|
+
|
|
380
427
|
# ── Query ───────────────────────────────────────────────────
|
|
381
428
|
|
|
382
429
|
async def get_job(self, job_id: str) -> JobPayload | None:
|
|
@@ -35,6 +35,7 @@ class JobPayload:
|
|
|
35
35
|
"failed_at",
|
|
36
36
|
"status",
|
|
37
37
|
"error",
|
|
38
|
+
"history",
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
def __init__(
|
|
@@ -58,6 +59,7 @@ class JobPayload:
|
|
|
58
59
|
failed_at: float | None = None,
|
|
59
60
|
status: str = "pending",
|
|
60
61
|
error: str | None = None,
|
|
62
|
+
history: list[dict[str, Any]] | None = None,
|
|
61
63
|
):
|
|
62
64
|
self.id = id or uuid4().hex
|
|
63
65
|
self.job_class = job_class
|
|
@@ -77,9 +79,14 @@ class JobPayload:
|
|
|
77
79
|
self.failed_at = failed_at
|
|
78
80
|
self.status = status
|
|
79
81
|
self.error = error
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
# Per-attempt execution history (one record per processing attempt).
|
|
83
|
+
# Bounded by the number of attempts; persisted only by drivers that store
|
|
84
|
+
# the full payload (memory, redis). Older payloads without this key load
|
|
85
|
+
# as an empty list, so the field is fully backward compatible.
|
|
86
|
+
self.history = history or []
|
|
87
|
+
|
|
88
|
+
def to_dict(self, *, include_history: bool = True) -> dict[str, Any]:
|
|
89
|
+
d = {
|
|
83
90
|
"id": self.id,
|
|
84
91
|
"job_class": self.job_class,
|
|
85
92
|
"data": self.data,
|
|
@@ -99,6 +106,9 @@ class JobPayload:
|
|
|
99
106
|
"status": self.status,
|
|
100
107
|
"error": self.error,
|
|
101
108
|
}
|
|
109
|
+
if include_history:
|
|
110
|
+
d["history"] = self.history
|
|
111
|
+
return d
|
|
102
112
|
|
|
103
113
|
def to_json(self) -> str:
|
|
104
114
|
return json.dumps(self.to_dict())
|
|
@@ -43,6 +43,7 @@ class Supervisor:
|
|
|
43
43
|
self._heartbeat_task: asyncio.Task | None = None
|
|
44
44
|
self._balance_task: asyncio.Task | None = None
|
|
45
45
|
self._pruner_task: asyncio.Task | None = None
|
|
46
|
+
self._stuck_recovery_task: asyncio.Task | None = None
|
|
46
47
|
|
|
47
48
|
@property
|
|
48
49
|
def is_running(self) -> bool:
|
|
@@ -80,6 +81,8 @@ class Supervisor:
|
|
|
80
81
|
await self._report_stats()
|
|
81
82
|
self._delayed_task = asyncio.create_task(self._poll_delayed())
|
|
82
83
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
84
|
+
if self.config.recover_stuck_jobs and self.config.stuck_processing_seconds > 0:
|
|
85
|
+
self._stuck_recovery_task = asyncio.create_task(self._recover_stuck_loop())
|
|
83
86
|
|
|
84
87
|
if self.balancer:
|
|
85
88
|
self._balance_task = asyncio.create_task(self._balance_loop())
|
|
@@ -112,9 +115,17 @@ class Supervisor:
|
|
|
112
115
|
self._balance_task.cancel()
|
|
113
116
|
if self._pruner_task:
|
|
114
117
|
self._pruner_task.cancel()
|
|
118
|
+
if self._stuck_recovery_task:
|
|
119
|
+
self._stuck_recovery_task.cancel()
|
|
115
120
|
|
|
116
121
|
aux_tasks = [
|
|
117
|
-
t for t in (
|
|
122
|
+
t for t in (
|
|
123
|
+
self._delayed_task,
|
|
124
|
+
self._heartbeat_task,
|
|
125
|
+
self._balance_task,
|
|
126
|
+
self._pruner_task,
|
|
127
|
+
self._stuck_recovery_task,
|
|
128
|
+
)
|
|
118
129
|
if t is not None
|
|
119
130
|
]
|
|
120
131
|
if aux_tasks:
|
|
@@ -123,6 +134,7 @@ class Supervisor:
|
|
|
123
134
|
self._heartbeat_task = None
|
|
124
135
|
self._balance_task = None
|
|
125
136
|
self._pruner_task = None
|
|
137
|
+
self._stuck_recovery_task = None
|
|
126
138
|
|
|
127
139
|
for task in self._tasks:
|
|
128
140
|
task.cancel()
|
|
@@ -187,6 +199,30 @@ class Supervisor:
|
|
|
187
199
|
logger.exception("Error in balance loop")
|
|
188
200
|
await asyncio.sleep(5)
|
|
189
201
|
|
|
202
|
+
async def _recover_stuck_loop(self) -> None:
|
|
203
|
+
"""Periodically requeue jobs left in processing after a worker crash."""
|
|
204
|
+
interval = max(1.0, float(self.config.stuck_check_interval_seconds))
|
|
205
|
+
while self._running:
|
|
206
|
+
try:
|
|
207
|
+
await self._recover_stuck_once()
|
|
208
|
+
except Exception:
|
|
209
|
+
logger.exception("Error recovering stuck processing jobs")
|
|
210
|
+
await asyncio.sleep(interval)
|
|
211
|
+
|
|
212
|
+
async def _recover_stuck_once(self) -> int:
|
|
213
|
+
timeout = float(self.config.stuck_processing_seconds)
|
|
214
|
+
if timeout <= 0:
|
|
215
|
+
return 0
|
|
216
|
+
total = 0
|
|
217
|
+
for queue in dict.fromkeys(self.config.queues):
|
|
218
|
+
total += await self.driver.requeue_stuck_jobs(timeout, queue=queue)
|
|
219
|
+
if total:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"Requeued %d stuck processing job(s) older than %.0fs",
|
|
222
|
+
total, timeout,
|
|
223
|
+
)
|
|
224
|
+
return total
|
|
225
|
+
|
|
190
226
|
async def _heartbeat_loop(self) -> None:
|
|
191
227
|
while self._running:
|
|
192
228
|
await self._report_stats()
|
|
@@ -11,10 +11,16 @@ from baqueue.drivers.base import BaseDriver
|
|
|
11
11
|
from baqueue.events import EventBus
|
|
12
12
|
from baqueue.job import Job, FunctionJob
|
|
13
13
|
from baqueue.retry import compute_delay, should_retry
|
|
14
|
-
from baqueue.serializer import JobPayload, resolve_job_class
|
|
14
|
+
from baqueue.serializer import JobPayload, resolve_job_class, _now_ts
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger("baqueue.worker")
|
|
17
17
|
|
|
18
|
+
# Per-attempt errors stored in JobPayload.history are truncated to this many
|
|
19
|
+
# characters. The job's top-level `error` field keeps the full latest traceback;
|
|
20
|
+
# this bound keeps the history (and therefore the stored payload) from growing
|
|
21
|
+
# large across retries.
|
|
22
|
+
_HISTORY_ERROR_MAXLEN = 1000
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
class Worker:
|
|
20
26
|
"""Pulls and executes jobs from one or more queues."""
|
|
@@ -84,6 +90,33 @@ class Worker:
|
|
|
84
90
|
return job
|
|
85
91
|
return None
|
|
86
92
|
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _record_attempt(
|
|
95
|
+
payload: JobPayload,
|
|
96
|
+
*,
|
|
97
|
+
status: str,
|
|
98
|
+
finished_at: float,
|
|
99
|
+
error: str | None = None,
|
|
100
|
+
will_retry: bool = False,
|
|
101
|
+
next_retry_at: float | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Append one bounded record describing the attempt that just concluded.
|
|
104
|
+
|
|
105
|
+
Called once per attempt, right before the driver persists the new state, so
|
|
106
|
+
drivers that store the whole payload (memory, redis) keep the full history.
|
|
107
|
+
The list is bounded by the number of attempts and the error is truncated."""
|
|
108
|
+
if error is not None and len(error) > _HISTORY_ERROR_MAXLEN:
|
|
109
|
+
error = error[:_HISTORY_ERROR_MAXLEN] + "…"
|
|
110
|
+
payload.history.append({
|
|
111
|
+
"attempt": payload.attempts,
|
|
112
|
+
"started_at": payload.started_at,
|
|
113
|
+
"finished_at": finished_at,
|
|
114
|
+
"status": status,
|
|
115
|
+
"error": error,
|
|
116
|
+
"will_retry": will_retry,
|
|
117
|
+
"next_retry_at": next_retry_at,
|
|
118
|
+
})
|
|
119
|
+
|
|
87
120
|
async def _process(self, payload: JobPayload) -> None:
|
|
88
121
|
self._current_job = payload
|
|
89
122
|
job_timeout = payload.timeout or self.timeout
|
|
@@ -99,6 +132,7 @@ class Worker:
|
|
|
99
132
|
timeout=job_timeout,
|
|
100
133
|
)
|
|
101
134
|
|
|
135
|
+
self._record_attempt(payload, status="completed", finished_at=_now_ts())
|
|
102
136
|
await self.driver.complete(payload)
|
|
103
137
|
await self.driver.record_metric(payload.queue, "completed", 1)
|
|
104
138
|
await self.events.emit("job.completed", payload=payload, result=result, worker=self.name)
|
|
@@ -118,9 +152,16 @@ class Worker:
|
|
|
118
152
|
|
|
119
153
|
if should_retry(payload.attempts, payload.max_attempts):
|
|
120
154
|
delay = compute_delay(payload.backoff, payload.attempts)
|
|
155
|
+
self._record_attempt(
|
|
156
|
+
payload, status="failed", finished_at=_now_ts(),
|
|
157
|
+
error=error_msg, will_retry=True, next_retry_at=_now_ts() + delay,
|
|
158
|
+
)
|
|
121
159
|
await self.driver.release(payload, delay=delay)
|
|
122
160
|
await self.events.emit("job.retrying", payload=payload, error=error_msg, delay=delay)
|
|
123
161
|
else:
|
|
162
|
+
self._record_attempt(
|
|
163
|
+
payload, status="failed", finished_at=_now_ts(), error=error_msg,
|
|
164
|
+
)
|
|
124
165
|
await self.driver.fail(payload, error_msg)
|
|
125
166
|
await self.driver.record_metric(payload.queue, "failed", 1)
|
|
126
167
|
await self.events.emit("job.failed", payload=payload, error=error_msg, worker=self.name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: baqueue
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A powerful Python queue management package inspired by Laravel Horizon
|
|
5
5
|
Author: Basalam, BaQueue Contributors
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
75
75
|
- [Dispatch Jobs](#dispatch-jobs)
|
|
76
76
|
- [Batch Jobs](#batch-jobs)
|
|
77
77
|
- [Run Workers](#run-workers)
|
|
78
|
+
- [Stuck Job Recovery](#stuck-job-recovery)
|
|
78
79
|
- [Pruning](#pruning)
|
|
79
80
|
- [Auto-pruning](#auto-pruning-runs-alongside-baqueue-work)
|
|
80
81
|
- [Manual pruning](#manual-pruning)
|
|
@@ -95,6 +96,7 @@ A powerful Python queue management package. Multi-driver support, batch jobs, sc
|
|
|
95
96
|
## Features
|
|
96
97
|
- **Multi-driver**: SQLite (default), Redis, PostgreSQL, or In-Memory
|
|
97
98
|
- **Auto-balancing**: Dynamically scale workers based on queue pressure
|
|
99
|
+
- **Stuck-job recovery**: Jobs left in `processing` for more than 1 hour are requeued automatically
|
|
98
100
|
- **Auto-pruning**: Completed jobs are deleted about 5 seconds after they finish; failed/cancelled jobs are kept up to 1 day — all configurable
|
|
99
101
|
- **Disk-full cleanup**: Storage-full/OOM driver errors trigger emergency cleanup of terminal jobs and old metrics, then retry once
|
|
100
102
|
- **Pruning**: Remove old jobs by status, tag, or age
|
|
@@ -213,6 +215,41 @@ Or via CLI:
|
|
|
213
215
|
baqueue work -q emails -q payments -w 3 -b auto
|
|
214
216
|
```
|
|
215
217
|
|
|
218
|
+
#### Stuck Job Recovery
|
|
219
|
+
|
|
220
|
+
When `baqueue work` is running, the supervisor also checks for jobs that were
|
|
221
|
+
claimed by a worker but never finished. By default, any job that has stayed in
|
|
222
|
+
`processing` for more than 1 hour is moved back to `pending`, so another worker
|
|
223
|
+
can pick it up and run it again.
|
|
224
|
+
|
|
225
|
+
This is intended for worker crashes, process restarts, or other cases where a
|
|
226
|
+
job was left in-flight. The original claim still counts as an attempt; when the
|
|
227
|
+
job is picked up again, its attempt counter continues from there.
|
|
228
|
+
|
|
229
|
+
Configure it from Python:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
supervisor = Supervisor(
|
|
233
|
+
driver=Queue.get_driver(),
|
|
234
|
+
config=SupervisorConfig(
|
|
235
|
+
queues=["emails"],
|
|
236
|
+
recover_stuck_jobs=True,
|
|
237
|
+
stuck_processing_seconds=3600,
|
|
238
|
+
stuck_check_interval_seconds=60,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Or from the CLI:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
baqueue work --stuck-job-timeout-seconds 7200
|
|
247
|
+
baqueue work --no-stuck-job-recovery
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
If you intentionally run jobs for longer than 1 hour, increase
|
|
251
|
+
`stuck_processing_seconds` above your longest expected runtime.
|
|
252
|
+
|
|
216
253
|
### Pruning
|
|
217
254
|
|
|
218
255
|
#### Auto-pruning (runs alongside `baqueue work`)
|
|
@@ -509,7 +546,7 @@ Coverage includes:
|
|
|
509
546
|
- `Queue` facade — push / later / bulk / prune / `retry_failed`
|
|
510
547
|
- Cross-driver contract tests (memory + sqlite, parameterized)
|
|
511
548
|
- Worker lifecycle: success / failure / retry / timeout
|
|
512
|
-
- Supervisor pool + delayed-job promotion
|
|
549
|
+
- Supervisor pool + delayed-job promotion + stuck-job recovery
|
|
513
550
|
- Scheduler interval dispatch
|
|
514
551
|
- Pruner by status / tag / age
|
|
515
552
|
- Batch builder + completion callbacks
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|