Job Management
Cancel, pause, archive, revoke, replay, purge, and clean up jobs.
Cancel, pause, archive, revoke, replay, purge, and clean up jobs.
Manage running jobs — cancel, pause queues, archive, revoke, replay, and clean up.
Cancel a pending job before it starts:
job = send_email.delay("user@example.com", "Hello", "World")
cancelled = queue.cancel_job(job.id) # True if was pendingTrue if the job was pending and is now cancelledFalse if the job was already running, completed, or in another non-pending state# Purge completed jobs older than 1 hour
deleted = queue.purge_completed(older_than=3600)
# Purge dead letters older than 24 hours
deleted = queue.purge_dead(older_than=86400)A queue that configures no retention at all automatically applies recommended per-table windows — history is bounded out of the box, with no configuration required:
| Table | Default window |
|---|---|
archived_jobs (completed/failed/cancelled jobs) | 7 days |
task_metrics | 7 days |
job_errors | 7 days |
task_logs | 3 days |
dead_letter (DLQ) | 30 days |
The DLQ is deliberately the longest-lived — it's the only copy of a payload a human must act on. The scheduler runs cleanup periodically (on its cleanup tick, not a fixed wall-clock interval) and purges rows older than their table's window.
The first time a worker running on this defaulted policy runs a cleanup
sweep, it logs a one-time warn-level message naming the queue and every
window it resolved, plus how to opt out. A queue with an explicit
retention or legacy result_ttl is silent — the announcement exists only
because nothing was configured.
RetentionPass a Retention to override only the tables you care about — any field
you leave unset (None, the default) keeps that table forever:
from flexiq import Queue, Retention
queue = Queue(
db_path="myapp.db",
retention=Retention(
archived_jobs=604_800, # 7 days
dead_letter=2_592_000, # 30 days
task_logs=259_200, # 3 days
),
)All windows are in seconds.
Pass a fully empty Retention() to disable the table-wide windows, so no table
is auto-cleaned on a schedule. This is deliberately different from omitting
retention, which applies the recommended defaults above:
# No table-wide retention windows
queue = Queue(db_path="myapp.db", retention=Retention())A per-entry result_ttl on an individual job or DLQ entry is still honored even
with the windows disabled — the per-entry sweep runs every cleanup tick.
result_ttlresult_ttl (seconds) is the older, coarser knob — it applies the same
window to every table:
queue = Queue(
db_path="myapp.db",
result_ttl=3600, # every table purges rows older than 1 hour
)result_ttl still works but is superseded by Retention for per-table
control; when both are set, retention wins. A negative result_ttl is
rejected at construction.
Retention runs in the worker process, so the resolved windows are reported by
the worker elected to run cleanup rather than read from local config. The
dashboard's Settings page echoes them, and effective_retention() returns
the same report:
policy = queue.effective_retention()
if policy is None:
print("no worker has swept yet — the active policy is unknown")
else:
print(policy.enabled, policy.defaulted, policy.windows["task_logs"])Windows are in milliseconds here (the reporting unit), and None keeps a
table forever. None for the whole report means unreported — the first sweep
publishes it — which is deliberately distinct from retention being disabled
(enabled=False).
Tuning a window without knowing how much it deletes is guesswork.
dry_run_retention() counts, per table, the rows a purge would remove right
now — without deleting anything:
preview = queue.dry_run_retention()
print(f"{preview.total} rows would be deleted")
print(preview.counts["archived_jobs"], preview.counts["task_logs"])With no argument it previews this queue's configured (or default-recommended) windows. Pass candidate windows to size one before committing to it — no worker restart needed:
from flexiq import Retention
preview = queue.dry_run_retention(Retention(archived_jobs=3 * 86_400))
print(f"a 3-day archive window would delete {preview.counts['archived_jobs']} jobs")The counts are a point-in-time snapshot; nothing is deleted, so it is safe to
run against production. Unlike effective_retention(), it is computed
in-process against the live data and always returns a result. The async twin is
adry_run_retention().
Manual purge_completed() / purge_dead() is a blunt delete — it removes a
job together with all of its child records:
job_errors)task_logs)task_metrics)job_dependencies)replay_history)Automatic retention is per-table by design: purging an archived job does
not cascade-delete its logs, metrics, or errors — each of those tables
ages out on its own window instead, so archived_jobs=7d with task_logs
left unset keeps the logs forever. Only the structural relations
(job_dependencies, replay_history) always follow the job.
# Manual purge — child records are cleaned up automatically
deleted = queue.purge_completed(older_than=3600)
print(f"Purged {deleted} jobs and their related records")
# With per-job TTL — cascade cleanup still applies
job = resize_image.apply_async(
args=("photo.jpg",),
result_ttl=600, # This job's results expire after 10 minutes
)
# When this job is purged (after 10 min), its errors, logs,
# metrics, dependencies, and replay history are also removed.Dead letter entries are not cascade-deleted — they have their own
lifecycle managed by purge_dead(). Retention's timestamp-based cleanup of
error history, logs, and metrics also continues to run independently,
catching old records regardless of whether the parent job still exists.
Temporarily pause job processing on a queue without stopping the worker:
# Pause the "emails" queue
queue.pause("emails")
# Check which queues are paused
print(queue.paused_queues()) # ["emails"]
# Resume processing
queue.resume("emails")Paused queues still accept new jobs — they just won't be dequeued until resumed.
# Before maintenance: pause all queues
for q in ["default", "emails", "reports"]:
queue.pause(q)
print(f"Paused: {queue.paused_queues()}")
# ... perform maintenance ...
# After maintenance: resume all queues
for q in ["default", "emails", "reports"]:
queue.resume(q)Move old completed jobs to an archive table to keep the main jobs table lean:
# Archive completed jobs older than 24 hours
archived_count = queue.archive(older_than=86400)
print(f"Archived {archived_count} jobs")
# Browse archived jobs
archived = queue.list_archived(limit=50, offset=0)
for job in archived:
print(f"{job.id}: {job.task_name} ({job.status})")Archived jobs are no longer returned by queue.stats() or
queue.list_jobs(), but remain queryable via queue.list_archived().
from flexiq import current_job
@queue.periodic(cron="0 0 2 * * *") # Daily at 2 AM
def nightly_archival():
archived = queue.archive(older_than=7 * 86400) # Archive jobs older than 7 days
current_job.log(f"Archived {archived} jobs")Cancel all pending jobs for a specific task:
# Revoke all pending "send_newsletter" jobs
cancelled = queue.revoke_task("myapp.tasks.send_newsletter")
print(f"Revoked {cancelled} jobs")Remove all pending jobs from a specific queue:
purged = queue.purge("emails")
print(f"Purged {purged} jobs from the emails queue")Replay a completed or dead job with the same arguments:
new_job = queue.replay(job_id)
print(f"Replayed as {new_job.id}")
# Check replay history
history = queue.replay_history(job_id)# List dead letters and replay them
dead = queue.dead_letters()
for entry in dead:
print(f"Replaying dead job {entry['original_job_id']}: {entry['task_name']}")
new_id = queue.retry_dead(entry["id"])
print(f" -> New job: {new_id}")Left at their defaults, SQLite's own pragmas favor safety over throughput and let concurrent readers block on writers — so flexiq sets these at connection time to give WAL-mode concurrency and predictable behavior under lock contention without any configuration on your part:
| Pragma | Value | Purpose |
|---|---|---|
journal_mode | WAL | Concurrent reads during writes |
busy_timeout | 5000ms | Wait instead of failing on lock contention |
synchronous | NORMAL | Balance between safety and speed |
journal_size_limit | 64MB | Prevent unbounded WAL growth |
The connection pool uses up to 8 connections via r2d2.