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.
Files changed (38) hide show
  1. {baqueue-1.0.2/baqueue.egg-info → baqueue-1.2.0}/PKG-INFO +39 -2
  2. {baqueue-1.0.2 → baqueue-1.2.0}/README.md +38 -1
  3. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/__init__.py +1 -1
  4. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/cli.py +29 -0
  5. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/config.py +3 -0
  6. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/api.py +8 -1
  7. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/server.py +5 -0
  8. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/app.js +38 -0
  9. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/index.html +62 -18
  10. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/static/style.css +17 -1
  11. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/base.py +23 -0
  12. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/memory_driver.py +41 -0
  13. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/postgres_driver.py +45 -0
  14. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/redis_driver.py +108 -0
  15. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/sqlite_driver.py +47 -0
  16. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/serializer.py +13 -3
  17. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/supervisor.py +37 -1
  18. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/worker.py +42 -1
  19. {baqueue-1.0.2 → baqueue-1.2.0/baqueue.egg-info}/PKG-INFO +39 -2
  20. {baqueue-1.0.2 → baqueue-1.2.0}/LICENSE +0 -0
  21. {baqueue-1.0.2 → baqueue-1.2.0}/MANIFEST.in +0 -0
  22. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/balancer.py +0 -0
  23. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/batch.py +0 -0
  24. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/dashboard/__init__.py +0 -0
  25. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/drivers/__init__.py +0 -0
  26. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/events.py +0 -0
  27. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/job.py +0 -0
  28. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/pruner.py +0 -0
  29. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/queue.py +0 -0
  30. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/retry.py +0 -0
  31. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue/scheduler.py +0 -0
  32. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/SOURCES.txt +0 -0
  33. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/dependency_links.txt +0 -0
  34. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/entry_points.txt +0 -0
  35. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/requires.txt +0 -0
  36. {baqueue-1.0.2 → baqueue-1.2.0}/baqueue.egg-info/top_level.txt +0 -0
  37. {baqueue-1.0.2 → baqueue-1.2.0}/pyproject.toml +0 -0
  38. {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.2
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
@@ -7,7 +7,7 @@ from baqueue.batch import Batch
7
7
  from baqueue.events import EventBus
8
8
  from baqueue.retry import BackoffStrategy
9
9
 
10
- __version__ = "1.0.2"
10
+ __version__ = "1.2.0"
11
11
 
12
12
  __all__ = [
13
13
  "BaQueueConfig",
@@ -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
- "jobs": [j.to_dict() for j in jobs],
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
- <div class="tl-item" x-show="selectedJob.started_at">
515
- <div class="tl-dot processing"></div>
516
- <div class="tl-content">
517
- <span class="tl-label">Started</span>
518
- <span class="tl-time" x-text="formatTimeFull(selectedJob.started_at)"></span>
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> &middot;
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
- </div>
521
- <div class="tl-item" x-show="selectedJob.completed_at">
522
- <div class="tl-dot completed"></div>
523
- <div class="tl-content">
524
- <span class="tl-label">Completed</span>
525
- <span class="tl-time" x-text="formatTimeFull(selectedJob.completed_at)"></span>
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> &middot; running&hellip;</span>
541
+ <span class="tl-time" x-text="formatTimeFull(selectedJob.started_at)"></span>
542
+ </div>
526
543
  </div>
527
- </div>
528
- <div class="tl-item" x-show="selectedJob.failed_at">
529
- <div class="tl-dot failed"></div>
530
- <div class="tl-content">
531
- <span class="tl-label">Failed</span>
532
- <span class="tl-time" x-text="formatTimeFull(selectedJob.failed_at)"></span>
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
- </div>
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
- def to_dict(self) -> dict[str, Any]:
82
- return {
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 (self._delayed_task, self._heartbeat_task, self._balance_task, self._pruner_task)
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.2
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