Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

reflex-workflow

Prototype. Durable workflows where each workflow is an ordinary SQLAlchemy table: rows are runs, columns are their state, and workers in your app claim due rows and run their steps. Requires Postgres; there is nothing else to run.

from datetime import timedelta
from typing import Literal

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from reflex_workflow import Workflow, step, wake_in


class Onboarding(Base, Workflow):
    __tablename__ = "onboarding"

    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[str] = mapped_column(String, unique=True)
    email: Mapped[str] = mapped_column(String)
    nudges: Mapped[int] = mapped_column(default=0)
    status: Mapped[str] = mapped_column(String, default="welcoming")

    @step(retries=3)
    async def welcome(self):
        await send_email(self.email, "welcome")
        self.status = "waiting"
        return wake_in(Onboarding.check, timedelta(days=3))

    @step
    async def check(self):
        if await has_activated(self.user_id):
            self.status = "activated"
            return None
        self.nudges += 1
        await send_email(self.email, f"nudge_{self.nudges}")
        return wake_in(Onboarding.check, timedelta(days=4)) if self.nudges < 2 else None

    @step
    async def activated(self, plan: Literal["free", "pro"]):
        self.status = f"activated:{plan}"

A step that takes no arguments is passed as is: Onboarding.check. A step that takes arguments is called with them, Onboarding.activated("pro"), which builds a Call[Onboarding] for the engine to schedule rather than running the step. Arguments are type-checked against the step's signature, a bare step that needs arguments is a type error, and so is passing one workflow's step to another.

  • Start a run: await Onboarding(user_id=u, email=e).start(Onboarding.welcome). A row that hits a unique constraint is not inserted, so a key never starts two runs. It returns whether a run was started, and a row the database keyed itself is given that key, so row.id addresses the run that was just started.
  • Stop a run: await Onboarding.by(Onboarding.user_id == u).cancel() ends every matching run where it stands — nothing scheduled, no wait, no held event — and what the run has already done stays. A cancelled run counts as finished to the run that fanned it out, as one that gave up does, so a parent is never left waiting on it.
  • Advance a run from outside (a webhook, a button): await Onboarding.by(Onboarding.user_id == u).run(Onboarding.activated("pro")) runs the step now on every matching row, replacing whatever was scheduled. A step already running keeps its lease, so the new step waits for it rather than starting beside it, and starts as soon as it is done or its lease runs out.
  • A step returns another step (run now), wake_in(step, delay) (run later), wait_for(step, ...) (run when an event arrives), every(step, schedule) (run again and again), fan_out(children, then=step) (run many at once), or None (stop). Step arguments are stored as JSON until the step runs, so they must be JSON-serializable.
  • Failures: @step(retries=N, backoff=timedelta(seconds=30)) retries with doubling backoff, up to max_backoff (an hour by default); after the last attempt the row stops with last_error set. A failed step's changes to the row are discarded. A step whose body succeeds but whose commit the database refuses — a value too long for its column, a child that breaks a constraint — counts as a failed attempt too, and is retried the same way.

The mixin adds twelve columns: next_step, next_args, wake_at, attempts, last_error, claimed_until, waiting_for, pending_event, recent_event_keys, parent, children_left, wf_version. They're ordinary mapped columns, so Alembic picks them up with the rest of the table. attempts and wf_version carry a server default, so a workflow can be mixed into a table that already has rows.

Models that are dataclasses

An application whose base is a MappedAsDataclass takes the same mixins from reflex_workflow.dataclass instead. SQLAlchemy will not mix a class that is not a dataclass into one that is — it warns in 2.0 and refuses in 2.1 — so the import you want is decided by your base, not by preference:

from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
from reflex_workflow.dataclass import Workflow


class Base(MappedAsDataclass, DeclarativeBase, kw_only=True):
    pass


class Onboarding(Base, Workflow):
    __tablename__ = "onboarding"

    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    email: Mapped[str]
    nudges: Mapped[int] = mapped_column(default=0)

Everything else is the same: the same columns, steps and engine, and both kinds of table can run in one deployment. The engine's own columns are init=False, so Onboarding(email=...) reads as it did before the mixin was added rather than asking for twelve more arguments. AttemptLog and RateBucket have dataclass spellings too, and take no constructor arguments at all — the engine writes those rows itself.

Waiting for an event

A run can park until something outside it happens — an approval, a webhook, a reply — without holding a process open. The step it waits on is the channel, and the arguments travel with the event.

class Expense(Base, Workflow):
    __tablename__ = "expense"

    id: Mapped[int] = mapped_column(primary_key=True)
    amount: Mapped[int] = mapped_column()
    status: Mapped[str] = mapped_column(String, default="draft")

    @step
    async def request_approval(self):
        await notify_manager(self.id)
        self.status = "pending"
        return wait_for(
            Expense.decide,
            timeout=timedelta(days=2),
            on_timeout=Expense.escalate,
        )

    @step
    async def decide(self, verdict: Literal["approve", "reject"], *, by: str):
        self.status = f"{verdict} by {by}"

The handler delivers the event to whichever runs are waiting for it:

await Expense.by(Expense.id == expense_id).deliver(
    Expense.decide("approve", by=manager), key=request_id
)

deliver returns how many runs accepted the event. An event that arrives before the run gets to its wait is held and applied as soon as the wait arms, so a fast reply is never lost; a run that goes on to wait for something else, stops, or goes back round a schedule, discards it. A run holds one event at a time, whichever wait it is for, so while one is held a second delivery is refused and deliver returns 0 for it, rather than replacing an answer already given — and a discarded event means its sender is the one who has to send it again. The held event stops counting once a worker takes it to run its step: an event delivered while that step runs is held for the next wait, so a run that waits on the same step again, such as a conversation, misses nothing. Passing a key makes delivery idempotent: a run refuses a key it has already taken, remembering the last sixteen. A key is taken when the run runs the event, not when it is held, so a resend of an event that was discarded is accepted. Arguments are checked against the step they address, so a payload that does not fit it is refused rather than failing once it runs. timeout and on_timeout go together and are optional; without them the run waits indefinitely.

A deadline decides when a wait ends. Once it has passed the wait stops accepting events — deliver returns 0 — even where no worker has run the timeout step yet, so a caller delivering faster than the workers pass cannot put a deadline off by keeping at it. The cost is that an answer arriving just after the deadline is refused rather than taken late, which is what a deadline means.

A run that has finished refuses events. For one that lives as long as its events keep coming, such as a conversation that closes when it goes quiet, restart=True has a finished run take the event by starting again with the step it names.

Schedules

A step that returns every(...) keeps running on a schedule, which is either an interval or a cron expression:

from reflex_workflow import Cron, every


class Digest(Base, Workflow):
    __tablename__ = "digest"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String, unique=True)

    @step
    async def send(self):
        await email_digest()
        return every(Digest.send, Cron("0 9 * * MON-FRI", "America/Los_Angeles"))

A schedule is an ordinary run, so it is registered by starting one. start does nothing when the row already exists, so a lifespan hook can declare the same schedule on every boot:

await Digest(name="daily").start(Digest.send)

A schedule lives in the step's return value, so it lasts only as long as the step keeps succeeding: once a scheduled step has used up its retries, the run stops and the schedule is over. Declaring it again on boot does not bring it back, because the row is still there. Give a step that has to keep its schedule enough retries to ride out what it depends on, and re-arm a stopped one with run:

await Digest.by(Digest.name == "daily").run(Digest.send)

An interval counts from the run's last scheduled time rather than from the moment the step finished, so a slow step doesn't push the schedule later and later. Occurrences that passed while nothing was running collapse into one: the step runs once and carries on, instead of firing once per hour the app was down.

Cron takes the five standard fields — minute, hour, day of month, month, day of week — with *, lists, ranges, steps, and names like MON-FRI or JAN. It follows Debian's cron: when a day field is written as a star both day fields have to match, and otherwise either one does, so 0 0 13 * FRI means the 13th or any Friday. Times are read on the clock of the timezone given, so a daily time the clock skips over in spring still fires once, and one the clock repeats in autumn fires on its first pass only. A malformed expression, and one that can never fire like 0 0 30 2 *, raise ValueError when the Cron is built.

Any function of the current time works in place of a Cron, so another cron library or a schedule of your own can be plugged in:

return every(Digest.send, lambda now: now.replace(hour=9) + timedelta(days=1))

Running many at once

A step returns fan_out(...) to split into runs that go at the same time, and names the step to run once they are all finished.

class Research(Base, Workflow):
    __tablename__ = "research"

    @step
    async def research_all(self):
        return fan_out(
            (
                child(Lookup(key=f"{self.name}:{c}", company=c), Lookup.research)
                for c in self.companies
            ),
            then=Research.report,
        )

    @step
    async def report(self):
        lookups = await self.children(Lookup).all()
        self.found = sum(lookup.summary is not None for lookup in lookups)

Each child is an ordinary run: claimed by whichever worker is free, retried on its own schedule, and holding its own last_error. A company whose provider is down retries by itself while the others carry on, and self.children(Lookup) reads them back afterwards.

The parent holds nothing open while they run — it is a row with nothing scheduled — and then runs once the last child finishes, whether that child succeeded or gave up, so one item nobody can process does not strand the batch. Children are started inside the parent's own commit: a parent whose row was taken over mid-step starts none of them.

A child that hits a unique constraint is not started, and the parent does not count it, so keying children by the work they do is what keeps a repeated fan-out to one run each.

Limits and fairness

A workflow can say how much of one group may run at once. The group is a column — a customer, a tenant, a provider — and the limit is counted across every worker, not per process.

class Lookup(Base, Workflow):
    __tablename__ = "lookup"
    __workflow_limit__ = Limit(by="customer", at_most=3)

    customer: Mapped[str] = mapped_column(String, index=True)

A limited workflow is claimed a group at a time, longest-waiting group first, and a group already at its limit is skipped rather than allowed to spend the pass. So a customer who imports ten thousand rows holds three slots, and the customer behind them with two rows is not waiting for the ten thousand. Index the column you limit by: every pass groups on it.

Claims for the same group serialize on a Postgres advisory lock for the length of one short transaction, which is what makes the count exact when several workers ask at the same moment. Different groups never wait for each other.

The same declaration says how often a group may start, which is the other half of a provider's quota:

class Charge(Base, Workflow):
    __workflow_limit__ = Limit(
        by="provider", at_most=5, rate=60, per=timedelta(minutes=1)
    )

Rates count in a token bucket that refills continuously and holds one period's worth, so a provider that has been quiet may start a burst and one that has been busy waits. The bucket needs a table, which the application maps onto its own base so its migrations carry it:

from reflex_workflow import RateBucket


class WorkflowRate(Base, RateBucket):
    __tablename__ = "workflow_rate"

One row per group, and nothing else reads it: truncating it only means every limit starts full again.

Between tables, a worker takes them in turn and gives each a share of the pass, so a table with a backlog cannot hold the whole worker, even when there are more tables than slots.

Which workers run what

A step can name the kind of worker it belongs on, and a worker is started to serve particular lanes. A step in a lane of its own runs only where that lane is served, so heavy work stays off the machines that answer requests.

class Upload(Base, Workflow):
    __tablename__ = "upload"

    @step(lane="media")
    async def transcode(self): ...

    @step
    async def notify(self): ...
async with run_workflows(Session, lanes=["media"], max_concurrency=2):
    ...

Routing costs nothing at the database: the row already names the step it will run next, so a worker claims only the steps it serves. A worker skips a table it can run nothing of, and a table it can run all of is claimed exactly as before.

A worker serves the default lane unless told otherwise, and a step is in default unless it says otherwise, so nothing changes until a lane is named. The other side of that: work in a lane nobody serves waits — the run is not lost, it simply sits until such a worker exists.

What a run has tried

The row says where a run is now. To see how it got there, map a history table onto your own base and the engine writes a row per attempt:

from reflex_workflow import AttemptLog


class WorkflowAttempt(Base, AttemptLog):
    __tablename__ = "workflow_attempt"
for attempt in await expense.history():
    print(attempt.step, attempt.outcome, attempt.error, attempt.took_ms)

Each row holds the step, which attempt it was, what came of it (ok, retry, failed), the error if there was one, how long it took, and when it finished. The record is written in the same transaction as the step's own commit, so history never shows an attempt that did not happen and never misses one that did — and an attempt whose commit was refused leaves nothing behind, because it changed nothing.

Nothing in the engine reads this table. Prune it on whatever schedule suits, and skip mapping it entirely if you do not want it: a deployment without one pays nothing.

In a Reflex app

rx.Model is a SQLModel, and a step on one is an unannotated attribute that pydantic refuses, so workflows are declared on a DeclarativeBase of their own. Register it so reflex db makemigrations sees the tables, and build the session factory on Reflex's own async engine so the app has one pool:

import reflex as rx
from reflex.model import get_async_engine
from reflex_workflow import Workflow, run_workflows, step
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


rx.ModelRegistry.register(Base)


class Expense(Base, Workflow):
    __tablename__ = "expense"

    id: Mapped[int] = mapped_column(primary_key=True)
    amount: Mapped[int]

    @step
    async def submit(self): ...


Session = async_sessionmaker(get_async_engine(None), expire_on_commit=False)

expire_on_commit=False matters: the engine reads a row's columns after committing it. Do not reuse the factory behind rx.asession() — its sessions are SQLModel's, which warn on every execute() the engine makes.

reflex db makemigrations then writes the workflow columns and the engine's indexes along with the rest of the schema. Start runs from an event handler as from anywhere else:

class State(rx.State):
    @rx.event
    async def file_expense(self, amount: int):
        await Expense(amount=amount).start(Expense.submit)

Tracing

With reflex-otel instrumented, every step attempt is an OpenTelemetry span named <table>.<step>, linked to the span that scheduled it: the handler or request that called start, run or deliver, or the step that returned it. Nothing to configure here; ReflexInstrumentor().instrument() turns it on, and without it the engine imports nothing from opentelemetry.

Running

Enter run_workflows from the app's lifespan. Every process that does so is a worker; they claim different rows.

import contextlib
from datetime import timedelta

from reflex_workflow import run_workflows


@app.register_lifespan_task
@contextlib.asynccontextmanager
async def workflows():
    async with run_workflows(
        Session,  # async_sessionmaker for the database with the workflow tables
        max_concurrency=8,
        lease=timedelta(minutes=5),
    ):
        yield

A worker with nothing to do asks the database when its next run comes due and sleeps until then, so an idle deployment stops querying rather than polling on a timer. It waits at most max_idle_interval (30 seconds by default, an hour under suspends_when_idle), which is also how long it waits when nothing is scheduled at all. poll_interval is the floor instead: how soon it looks again when something is due but could not be taken, because a limit or this worker's own concurrency held it back.

A worker is woken before any of that by anything that leaves work ready: a start, a run, a delivered event, a step that asked for the next one, or the last child of a fan-out. The news travels through Postgres itself — NOTIFY in the same transaction as the write, so a transaction that rolls back announces nothing — which means a run started by the web process is picked up by a worker in another process straight away rather than at its next poll. A worker ignores news about tables it does not run.

A row written straight into the table announces nothing, so it waits for the next wake-up: up to max_idle_interval. Use start and the workers hear about it at once.

Connection poolers in transaction mode — Neon's pooled endpoint, PgBouncer — cannot hold a LISTEN. Where notifications are worth a second connection, pass listen_engine pointing at the direct endpoint and leave the pooled one to the steps:

async with run_workflows(
    Session,
    listen_engine=create_async_engine(os.environ["REFLEX_DB_DIRECT_URL"]),
):
    yield

Not on a database that suspends itself, though. The connection a LISTEN is held on is work as far as that database is concerned, so a worker with nothing to do keeps it awake for having asked to be told about nothing — measured on Neon, whose compute suspends after five idle minutes: with a listener held on the direct endpoint it never suspended at all, and without one it went down five minutes after the last query. Say so instead, and the worker listens for nothing and waits an hour rather than thirty seconds:

async with run_workflows(Session, suspends_when_idle=True):
    yield

The cost is that work another process writes — the database's own console, another replica — waits for the next look rather than arriving at once. Everything the table already knows about still comes due when it said it would, since that is what the worker asked about before going to sleep. Passing both suspends_when_idle and a listen_engine is refused rather than quietly resolved one way.

A worker that is not listening — a driver that cannot, a connection that broke — waits the same as one that is. What it asked the database already covers every timer, retry, schedule and lease the table knows about, so the ear only shortens the wait for work another process writes meanwhile: losing it costs that latency, up to max_idle_interval, and nothing else. A listener that cannot reconnect backs off to the same bound rather than retrying on a short timer, since a database that suspends itself closes that connection on the way down and a listener coming straight back would wake it again. The one case that cannot be detected is a pooler that accepts a LISTEN and never delivers on it, which is what listen_engine is for. On shutdown a worker stops claiming, gives running steps shutdown_timeout to finish, and hands back the rows of any it had to cancel so the next worker can take them straight away.

A process that starts runs and takes approvals but should not run anything — a web process, usually — enters connect_workflows instead:

@app.register_lifespan_task
@contextlib.asynccontextmanager
async def workflows():
    async with connect_workflows(Session):
        yield

Everything that addresses runs works there: start, deliver, run, cancel, get, all. Nothing runs steps here, and what it writes wakes the workers that do — or waits on the row for them, if every worker is down at the time.

Deployments that suspend when idle

A deployment that suspends when nothing is talking to it has no process to run a timer with. Between runs a worker is asleep, and a host that stops giving it CPU stops it reaching the run that comes due. The engine gives a platform the two halves it needs to fix that.

on_idle is told the instant this worker is next waiting for, and None when it is waiting for nothing, each time that answer changes. The instant is the database's own, so it stays the same value until the work behind it moves, and a caller registering it somewhere else writes once per answer rather than once per pass:

async def register(at: datetime | None) -> None:
    if at is None:
        await platform.delete("wakeups/my-app")
    else:
        await platform.put("wakeups/my-app", wake_at=at, path="/wake")


async with run_workflows(Session, on_idle=register):
    yield

wake is the other half: whatever the platform reaches when that instant arrives calls it, and the request is held open until the worker has run the work or has nothing left to take. Holding it open is the point on hosts that only give an instance CPU while it is answering a request.

@app.api.get("/wake")
async def wake_workflows() -> Response:
    caught_up = await reflex_workflow.wake(timedelta(seconds=50))
    return Response(status_code=200 if caught_up else 503)

It returns True once the worker has made a pass that claimed nothing, with none of the steps it took still running, and the instant behind that pass has been reported. Both halves matter: the first is the worker saying there is nothing it can take — either nothing is due, or what is due is held back by a limit and waiting longer would not help — and the second is the promise that whatever wakes this deployment has been told when to do it next. A caller that lets the machine stop when this returns needs both, so it returns True for nothing less.

Calling it is safe from anywhere and as often as anyone likes: it asks the worker to look, which it would do anyway. Past eight callers at once the rest are not queued behind callers asking for the same thing; they ask the worker to look and return False, having watched nothing, so the caller tries again rather than acting on a success it was not shown.

A worker also re-derives what is left of its wait from the wall clock, so a machine that suspends mid-wait does not serve the run late by however long it was away.

Guarantees

  • A step runs at least once. A worker claims a row with a lease that it renews while the step runs; if the worker dies, the row is claimed again once the lease expires. A worker shutting down gives back the lease of any step it has to cancel, so a deploy that interrupts a long step does not cost that run a whole lease. So a step can run twice: make it safe to repeat (unique constraints, provider idempotency keys).
  • A step commits only if the row hasn't moved since it was claimed. A step whose lease was taken over, or which was preempted by .run(...), discards its changes rather than overwriting newer state.
  • Timers survive restarts: wake_at is a column, compared against the database clock.
  • A wait ends once, and its deadline decides when. Past the deadline an event is refused and deliver returns 0 for it, whether or not a worker has reached the timeout step yet — so a caller delivering faster than the workers pass cannot keep a deadline from being kept. Before it, the event wins and the timeout does not run. The deadline is judged as the delivery reaches the row, so one held up on a lock across the deadline is taken.

Metadata

Release files for reflex-workflow 0.0.1a1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for reflex-workflow 0.0.1a1
File Size Uploaded Provenance Actions
reflex_workflow-0.0.1a1.tar.gz 71.4 kB Without provenance Details for reflex_workflow-0.0.1a1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for reflex-workflow 0.0.1a1
File Interpreter ABI Platform Actions
reflex_workflow-0.0.1a1-py3-none-any.whl Python 3 none any Details for reflex_workflow-0.0.1a1-py3-none-any.whl

Total release size: 138.0 kB

Release files / reflex_workflow-0.0.1a1.tar.gz

Download URL reflex_workflow-0.0.1a1.tar.gz
Size 71.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c326e907c36c0445bbe132aa7508f305320fb3f86a9a9dadf1715d19f656be47
BLAKE2b-256 checksum
How to use checksums
cc2aac902eb1619ad473005d112864719208aa77f782b30f595cb132debe08e7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / reflex_workflow-0.0.1a1-py3-none-any.whl

Download URL reflex_workflow-0.0.1a1-py3-none-any.whl
Size 66.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5a17eea36fc227ac7ec03cfcfd6376fb225e6127a8a4c3a133243a69bd4fca10
BLAKE2b-256 checksum
How to use checksums
02baaab797a7403e33c357baed99f96ae1aa800376d14ad0a70b8a316f12196c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for reflex-workflow 0.0.1a1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page