Every task moves through a fixed set of statuses defined in
dtq_core.models.TaskStatus. All transitions go through
dtq_core.models.transition, which rejects any move not listed in
VALID_TRANSITIONS. Terminal statuses have no outgoing transitions.
| Status | Meaning |
|---|---|
pending |
Accepted but not yet in a stream. Only delayed tasks (delay_seconds > 0) sit here, waiting in dtq:retry:schedule. |
queued |
An entry exists in dtq:stream:<queue>:p<N>. Eligible for claiming. |
running |
Claimed by a worker; the handler is executing (or was, before a crash). |
succeeded |
Terminal. The handler returned a JSON-serializable result. |
retrying |
Non-terminal holding state. The attempt failed retryably; the task sits in dtq:retry:schedule until its due time. |
failed |
Terminal. Attempts exhausted or permanent failure, with the DLQ disabled. |
dead_lettered |
Terminal. Attempts exhausted or permanent failure, moved to dtq:stream:dead-letter. |
cancelled |
Terminal. Cancelled before completion. |
expired |
Terminal. metadata.ttl_seconds elapsed before the task ran. |
+-----------+
| pending | (delayed only)
+-----+-----+
| scheduler requeue (delay elapsed)
v
+------------------> +-----------+ scheduler requeue (retry due)
| | queued |<-------------------+
| +-----+-----+ |
| | worker claim (Lua) | executor:
| v retrying -> queued
| +-----------+ |
| | running | |
| +-----+-----+ |
| | handler finished |
| +---------------+---------------+ |
| | | | |
| v v v |
| +----------+ +-----------+ +------------+ |
+----| retrying | | succeeded | | failed | |
+----------+ | (terminal)| | (terminal) | |
+-----------+ +------------+ |
+------------+ |
|dead_lettered| |
| (terminal) | |
+------------+ |
|
Any non-terminal status can move to cancelled or |
expired (expiry only when metadata.ttl_seconds set).+
In table form (VALID_TRANSITIONS in models.py):
pending->queued,cancelled,expiredqueued->running,cancelled,expiredrunning->succeeded,retrying,failed,dead_lettered,cancelledretrying->queued,cancelledsucceeded,failed,dead_lettered,cancelled,expired: terminal, no outgoing transitions
Note: retrying is the only non-terminal status that is not executable; it
is a holding state between the failure write and the scheduler's requeue.
The executor also treats a retrying task found in a stream as
already-finalized (its follow-up write happened; only the ACK was lost) and
ACKs it without executing.
- API ingest (
dtq_queue.ingest.submit_task): creates the task asPENDING(delayed) orQUEUED(immediate). This is the only place tasks are born. Attempt starts at 1. - Worker claim (
executor._claim_for_execution, Lua):QUEUED -> RUNNING, atomically. Setsstarted_at,worker_id, clears the previous attempt's error fields. Only one claimant wins; losers absorb their entry. - Worker redelivery (
executor._claim_redelivery, Lua): re-adopts aRUNNINGtask after a crash. Status staysRUNNING; the attempt increments atomically. - Executor finalize:
RUNNING -> SUCCEEDED(result recorded),RUNNING -> RETRYING(retry scheduled, due time in the schedule zset),RUNNING -> DEAD_LETTERED(DLQ entry written) orRUNNING -> FAILED(DLQ disabled). Permanent failures that never execute (unknown task type, payload decode errors, non-serializable results) take the same terminal path directly. - Scheduler (
Worker._requeue_claimed, leader only):RETRYING -> QUEUED(attempt incremented) orPENDING -> QUEUED(delay elapsed, attempt stays 1), each followed by anXADDinto the priority stream. - Cancel: the API moves
QUEUED/PENDING/RETRYINGtasks straight toCANCELLED(also adding them todtq:cancelledand removing them from the schedule). ARUNNINGtask getscancel_requestedset on its hash; the worker's cancel watcher or pre-execution check moves it toCANCELLEDand discards the handler result. - TTL expiry: checked when a worker is about to execute a claimed entry
and when the scheduler requeues a claim. Expired tasks move to
EXPIREDwithout executing. - DLQ admin (
dtq_api.store.dlq_requeue):DEAD_LETTERED -> QUEUED. This is an admin resurrection outside the worker lifecycle, so it deliberately bypassesmodels.transition. The attempt resets to 1 and the previous attempt count is preserved inmetadata.previous_attempts. Note:CONTRACT.mdsection 11 says the attempt resets to 0, but the sharedTaskmodel requiresattempt >= 1, so the code uses 1; this is documented instore.py.
POST /api/v1/tasks/{id}/cancel(requiresX-API-KeywhenDTQ_API_KEYis set).- Task not found -> 404. Already terminal -> 410.
- Task
RUNNING-> the API setscancel_requestedon the task hash and returns 409 with codeCANCEL_REQUESTED. Cancellation is best effort: async handlers are cancelled promptly; sync handlers running in a thread run to completion and their result is discarded, with the task still endingCANCELLED. - Task
QUEUED,PENDING, orRETRYING-> the API transitions it toCANCELLEDimmediately (202), adds the id todtq:cancelledso any in-flight stream entry is skipped by workers, and removes it from the retry schedule and claimed set. - Every cancel is written to the audit stream. The idempotency record is
set to
failed(see idempotency).
A task carries an optional metadata.ttl_seconds. Expiry is evaluated
lazily, never by a sweeper:
expired(now)is true whennow >= created_at + ttl_seconds. Negative, zero-absent, or non-numeric values never expire.- Checked in two places: the executor's pre-execution checks (a claimed
entry for an expired task is ACKed and moved to
EXPIRED), and the scheduler's_requeue_claimed(a scheduled task that expired while waiting is moved toEXPIREDinstead of being enqueued). - Expiry applies only before the task runs. A
RUNNINGtask whose TTL passes mid-execution is not interrupted; the TTL gates starting, not finishing.
The task hash itself lives for DTQ_TASK_RETENTION_S (default 7 days)
regardless of status; that is storage retention, not task TTL.
- delivery-guarantees: crash windows between these transitions
- retries: the
retryingholding state and attempt counting - idempotency: terminal-state replay of idempotency keys
- redis-streams: where each status lives in Redis
- architecture: system overview