Skip to content

Durable steps

The problem: fulfil_order charges the card, then crashes buying the shipping label. The retry runs the whole function again — and charges the card twice. Retries and idempotency are usually left as the reader's problem; durable steps make partial progress a first-class thing instead.

import gylo

app = gylo.Gylo()


@app.task(durable=True)
async def fulfil_order(ctx: gylo.StepContext, order_id: int) -> None:
    charge_id = await ctx.step("charge", lambda: charge_card(order_id))

    label = await ctx.step("label", lambda: buy_shipping_label(order_id))

    await ctx.step("email", lambda: send_confirmation(order_id, charge_id, label))


async def charge_card(order_id: int) -> str: ...
async def buy_shipping_label(order_id: int) -> str: ...
async def send_confirmation(order_id: int, charge_id: str, label: str) -> None: ...

How it works

A task registered with durable=True receives a StepContext. Each ctx.step(name, work) runs work once and records what it returned; when the task retries, completed steps replay their recorded result instead of running again. The crash above resumes as: "charge" returns the recorded charge_id without touching the card, "label" actually runs, life continues.

The guarantee is strict on purpose: control does not pass to the next step until the current one's record is durable in Postgres. A step that ran but was not yet recorded would be repeated after a crash — which is the entire thing being avoided — so the task waits for the write's acknowledgement, and that round trip is the feature's honest price. Recording a repeat after a crash-between-write-and-ack collapses into the existing record; a step never duplicates.

Rules that follow

Step names are identities. Stable names per logical action; a loop wants f"charge-{item.id}", not the same name twice — a repeated name replays the first result.

Step results must encode. Whatever work returns is stored with MessagePack and handed back on replay. Return identifiers, not live objects.

Code between steps repeats. Only steps replay; everything around them runs on every attempt. Anything with a side effect belongs inside a step — between steps, keep to pure computation on step results.

Durable tasks are async. The context is awaited, so a synchronous body cannot hold it; registration rejects the combination outright.

The step context also carries attempt, max_attempts, and final, so a durable task can tell its first attempt from its last.

When to reach for it

One non-idempotent side effect: make the task idempotent instead. Two or more, where a crash between them costs money or sends duplicate email — that is what this is for. Steps are per-task opt-in and jobs that do not use them pay nothing.