Recipe: RQ, arq and Dramatiq workers
Drive a persisted statechart from background jobs: load, send one event, save under an optimistic version check, retry on ConflictError. Written by hand for RQ, arq and Dramatiq.
A background job that advances a statechart has one shape, whatever the queue: load the snapshot, send one event, save it. The job carries only (key, event, payload). It never carries a pickled interpreter. When two jobs for the same key race, one save raises ConflictError. The loser reloads and re-applies its event, so no update is lost. This page writes that loop once and hands it to three queues.
Files: examples/recipes/task_queue_workers/.
xsm simulate examples/recipes/task_queue_workers/machine.json --events LABEL_PRINTED,PICKED_UP,SCANNED,SCANNED,DELIVERED
# -> shipment.delivered
The loop
import threading
from xstate_statemachine import MachineLogic, create_machine
from xstate_statemachine.persistence import (
ConflictError,
MemoryStore,
persisted,
)
chart = {
"id": "shipment",
"initial": "pending",
"context": {
"scans": 0,
},
"states": {
"pending": {
"on": {
"LABEL_PRINTED": "labelled",
},
},
"labelled": {
"on": {
"PICKED_UP": "in_transit",
},
},
"in_transit": {
"on": {
"SCANNED": {
"actions": "countScan",
},
"DELIVERED": "delivered",
},
},
"delivered": {
"type": "final",
},
},
}
def count_scan(i, ctx, e, a):
ctx["scans"] += 1
MACHINE = create_machine(
chart,
logic=MachineLogic(
actions={
"countScan": count_scan,
}
),
)
STORE = (
MemoryStore()
) # SQLiteStore / RedisStore / SQLAlchemyStore in production
def apply_event(key: str, event: str, retries: int = 50) -> str:
"""The job body: load -> send -> save; reload and re-apply on a conflict."""
for attempt in range(retries + 1):
try:
with persisted(STORE, key, MACHINE) as inst:
inst.send(event)
return inst.value
except ConflictError:
if attempt == retries:
raise # the queue's retry / dead-letter takes over
apply_event("shipment.42", "LABEL_PRINTED")
apply_event("shipment.42", "PICKED_UP")
workers = [
threading.Thread(target=apply_event, args=("shipment.42", "SCANNED"))
for _ in range(8)
]
[w.start() for w in workers]
[w.join() for w in workers]
import json
assert (
json.loads(STORE.load("shipment.42").snapshot)["context"]["scans"] == 8
) # no lost update
persisted_retry(store, key, machine, fn) is the library’s built-in form of the same loop, if you prefer not to write it yourself.
Three queues, one function
# RQ -- a plain function; the worker imports it by dotted path
from rq import Queue
Queue(connection=redis).enqueue(queue_workers.rq_job, "shipment.42", "PICKED_UP")
# arq -- `async def job(ctx, ...)`, using `apersisted` on the async engine
class WorkerSettings:
functions = [queue_workers.arq_job]
on_startup = lambda ctx: queue_workers.configure(SQLiteStore("ship.db"))
await pool.enqueue_job("arq_job", "shipment.42", "PICKED_UP")
# Dramatiq -- wrap the function as an actor (done lazily, so importing the module needs no dramatiq)
send_event = queue_workers.dramatiq_actor()
send_event.send("shipment.42", "PICKED_UP")
(Sketch: it needs a Redis/RabbitMQ broker. The tested module is queue_workers.py.) Three things matter here, whatever the queue:
- Configure the store once per worker process (
configure(store)at start-up). Do not open a connection per job. - Retry conflicts inside the job and let the queue’s retry policy handle everything else. A
ConflictErroris expected under concurrency. It is not a failure. - Keep payloads JSON. The job arguments cross a broker, and the event payload ends up in the snapshot.
Guarantees
What this does: each job is one
persisted()block. The load, the send and a save guarded byexpected_versionform one unit, so a concurrent job for the same key causes aConflictErrorand a re-apply, never a lost update (tested: 8 threads, one key, 8 scans counted). An exception inside the block writes nothing.What this does not do: queues deliver at least once. A job retried after a successful save sends its event again. Give each event an id and attach
IdempotencyPlugin(as in the Stripe recipe) when a duplicate must be a no-op. Ordering between jobs for one key is whatever order the queue delivers them in. Nothing here is a distributed lock. UsePessimisticLock()if the work inside the block is slow and conflicts are frequent.
Troubleshooting
| You see | Why | Fix |
|---|---|---|
ConflictError: Store conflict on 'shipment.42': expected version 3, found 4. Reload the snapshot and retry. in the queue’s failed jobs |
apply_event re-applies on a conflict RETRIES times (5 in queue_workers.py), then re-raises so the queue’s own retry or dead-letter takes over. Many workers hammering one key exhaust the budget. |
Raise RETRIES, route one key’s jobs to one queue, or use PessimisticLock(). |
ModuleNotFoundError: No module named 'dramatiq' |
dramatiq_actor() imports Dramatiq lazily; only the Dramatiq worker needs it. |
pip install dramatiq on that worker. |
| An event counted twice after a worker crash | The queue redelivered a job whose save had already committed (at-least-once). | Send an event id and attach IdempotencyPlugin. |
RuntimeError: queue_workers: call configure(store) at worker start-up |
The job ran in a process that never called configure(). |
Call it in the worker’s start-up hook (on_startup for arq). |
Related: Persistence → locking, all recipes.