Defining tasks¶
from potatoq import Potatoq, shared_task
app = Potatoq("proj")
@app.task
def resize(image_id, width=800):
...
@app.task(bind=True, queue="emails", time_limit=60, max_retries=5)
def send_email(self, to, subject):
self.request.id # this task's id
self.request.retries # how many times it was retried
...
@shared_task # not tied to an app: binds to the current one (reusable apps, Django)
def cleanup():
...
The task name defaults to module.function, as in Celery. Pass name= to keep names
stable across refactors; renaming a task while messages are queued leaves them
unregistered, and they get dead-lettered.
Options¶
Every option can be set per task in the decorator, and most also per call in
apply_async. Anything left unset falls back to the app-wide
setting.
| Option | Default | Meaning |
|---|---|---|
name |
module.function |
Registered task name |
bind |
False |
Pass the task instance as self |
queue |
task_default_queue ("default") |
Queue to send to |
priority |
0 |
Higher runs first, on every broker |
time_limit |
1800 s | Hard limit: the process is killed |
soft_time_limit |
30 s (or 10%) before time_limit |
Raises SoftTimeLimitExceeded inside the task |
max_retries |
10 |
For self.retry() and autoretry_for |
autoretry_for |
() |
Exception types that trigger an automatic retry |
dont_autoretry_for |
() |
Exceptions excluded from autoretry_for |
retry_backoff |
10 |
Exponential backoff base in seconds (False = fixed delay) |
retry_backoff_max |
600 |
Cap on the backoff delay |
retry_jitter |
True |
Randomise delays so retries don't stampede |
default_retry_delay |
— | A fixed retry delay; overrides the backoff |
retry_kwargs |
{} |
Extra retry() arguments for autoretry_for |
ignore_result |
auto | True to never store the result |
expires |
— | Seconds, timedelta, or aware datetime after which the task is discarded |
enqueue_on_commit |
True |
Send on transaction commit (details) |
typing |
True |
Check arguments against the signature at .delay() time |
base |
Task |
A custom Task subclass |
Arguments must be JSON¶
Task arguments and results are serialized as JSON, never pickle. Pickle would make broker write access equal to code execution. It also invites a classic bug: passing a Django model to a task ships a snapshot that is stale, or broken, by the time the worker runs it.
Besides plain JSON (strings, numbers, booleans, None, lists and dicts), a few common
types round-trip, and a few more are converted on the way
(details below). Anything else fails when you call .delay(), in
your web process, not later on the worker:
>>> send_receipt.delay(order)
TypeError: Order is a Django model instance and can't be a task argument. Pass its
primary key (obj.pk) and load it inside the task with Order.objects.get(pk=...), so the
task works with current data.
>>> send_receipts.delay(Order.objects.filter(paid=True))
TypeError: QuerySet (a Django queryset) can't be a task argument. Pass a list of primary
keys (list(qs.values_list("pk", flat=True))) and query inside the task.
Pass ids and load the object inside the task:
@shared_task
def send_receipt(order_id):
order = Order.objects.get(pk=order_id) # current data, at the time the task runs
...
send_receipt.delay(order.pk)
Arguments are also checked against the function signature when you enqueue, so
resize.delay(1, 2, 3) raises immediately. Eager mode (task_always_eager) still
round-trips arguments through the serializer, so tests catch these mistakes too.
What goes through¶
| You send | The task receives |
|---|---|
str, int, float, bool, None, list, dict |
the same |
datetime, date, time, timedelta |
the same. Naive datetimes stay naive; aware ones keep their UTC offset |
UUID, Decimal |
the same, exactly |
bytes, bytearray, memoryview |
bytes (sent as base64, about a third larger) |
set, frozenset |
set |
tuple, named tuples |
list |
dict keys that are int, float, bool or None |
str keys: {1: "a"} arrives as {"1": "a"} |
Enum members |
their value: Color.RED arrives as "red" |
pathlib.Path and other path-like objects |
str |
Django lazy strings (gettext_lazy) |
str, translated in the language active when you call .delay() |
objects with a __json__() method |
whatever __json__() returns (usually a dict), not the object |
| Django models and querysets | rejected, with a hint to pass primary keys |
dataclasses, deque, range, generators, Fraction, complex, other objects |
rejected |
| dict keys that are tuples or other objects | rejected |
Limitations¶
JSON is a smaller vocabulary than Python, so a few things need care:
- Types are lost on conversion. Tuples arrive as lists, integer dict keys as
strings, enums as plain values. If the task needs the type back, rebuild it:
Color(color),{int(k): v for k, v in counts.items()}. - Time zones become fixed offsets. An aware datetime in
Europe/Ljubljanaarrives with a+01:00offset, not the zone, so adding a day across a DST change gives a different wall-clock time. Send UTC, or send the zone name as a separate argument. - Objects don't survive. There is no way to send an instance and get the same
class back, on purpose: a worker may run different code than the web process that
enqueued it, and an object's state is stale by the time the task runs. Send ids or
plain dicts and build the object inside the task.
__json__()helps with the sending side, but the task still receives a dict. - Payloads should stay small. Every argument is stored in the broker until the task
finishes, and
bytesgrow by a third. Store files and large blobs elsewhere (S3, the database) and pass a key. - Numbers are exact. Big integers stay exact and
Decimalkeeps its digits, but afloatis still a float: useDecimalfor money.NaNand infinity go through. - Results follow the same rules. A task's return value is serialized the same way
when results are stored, so returning a model instance fails the task with the same
TypeError. Return ids or plain data. - Dicts of the form
{"__potatoq__": ..., "v": ...}are reserved for the types above. Any other dict arrives as you sent it.
async def tasks¶
@app.task
async def fetch(url):
async with httpx.AsyncClient() as client:
return (await client.get(url)).text
Each worker process keeps one event loop between tasks, so async clients and connection pools can be reused. The soft time limit cancels the coroutine.
Custom base classes¶
from potatoq import Task
class Tracked(Task):
def before_start(self, task_id, args, kwargs): ...
def on_success(self, retval, task_id, args, kwargs): ...
def on_failure(self, exc, task_id, args, kwargs, einfo): ...
def on_retry(self, exc, task_id, args, kwargs, einfo): ...
def after_return(self, status, retval, task_id, args, kwargs, einfo): ...
@app.task(base=Tracked)
def charge(order_id): ...
Set app.Task = Tracked (or Potatoq(task_cls=Tracked)) to make it the default.