Tasks and options
import gylo
app = gylo.Gylo()
@app.task
async def send_receipt(order_id: int, *, email: str) -> None: ...
@app.task(name="billing.charge", store_result=True)
async def charge(customer: str, amount_cents: int) -> dict:
return {"charged": amount_cents}
@app.task(context=True)
async def flaky_import(ctx: gylo.JobContext, source: str) -> None:
if ctx.final:
# last attempt: page a human instead of failing into the void
...
async def enqueue_all(conn) -> None:
await send_receipt.enqueue(conn, 42, email="a@b.c")
await send_receipt.options(queue="mail", delay=30.0).enqueue(
conn, 43, email="c@d.e"
)
await send_receipt.options(unique=True).enqueue(conn, 42, email="a@b.c")
await send_receipt.enqueue_many(
conn,
[gylo.call(n, email=f"user{n}@example.com") for n in range(100)],
)
Defining tasks
@app.task registers a function under module.qualname, or under name=
when you want a stable identifier that survives refactors — recommended for
anything long-lived, since the name is what lives in the database.
Arguments are encoded with MessagePack. Anything msgspec can encode travels:
the usual scalars, lists, dicts, datetime. Anything it cannot raises at
enqueue, while you are still on the stack — as does a payload too large for a
dispatch frame to ever carry (16MB), because failing later would dead-letter a
job nobody can fix.
Tasks may be async def or plain def. Synchronous bodies run on a thread so
they never stall the event loop the other jobs in that child share.
Options ride the task, not the call
Enqueue options live on .options(...) rather than on enqueue itself, so
they can never collide with your task's own parameters — a task is free to
take an argument named queue or priority.
| Option | Meaning |
|---|---|
queue |
Which queue the job lands on ("default") |
priority |
Lower runs first; compared across every queue a worker consumes |
delay |
Seconds before the job becomes runnable |
max_attempts |
Total attempts before dead-lettering (20) |
unique |
True to dedup on arguments, a string to dedup on your own key |
concurrency_key + max_concurrency |
See Keyed concurrency |
A BoundTask from .options() is reusable — build it once, enqueue many
times.
Uniqueness
unique=True digests the task name, queue, and arguments; unique="customer-7"
uses your key instead. Either way, while a matching job is waiting or running,
a second enqueue inserts nothing and returns the id of the job already there.
Once the first finishes, the key frees. Deduplication is a database constraint,
not a racy check — two concurrent enqueues cannot both win.
Batching
enqueue_many inserts a list in one round trip: tuples for positional
arguments, gylo.call(...) when keywords are needed. It deliberately returns
no ids — reporting them per row would cost the pipelining that makes it worth
using.
Task self-context
context=True passes a JobContext first argument carrying job_id,
attempt, max_attempts, and final — true on the last attempt, which is
the moment to alert a human rather than fail into the void. Durable tasks'
step context carries the same fields.
submit — when you have no connection
app.bind(pool) once at startup, then await task.submit(...) borrows a
connection per call. It is the weaker promise, on purpose: the job commits on
its own, so it survives a transaction of yours that later rolls back. When
that distinction matters, you want enqueue.