v 0.1.1 ยท on crates.io โ†— / open source ยท mit / rust ยท postgres

Workflows,
posted.

A Rust library for durable job execution and workflow orchestration. Postgres is the runtime โ€” and the ledger.

Every claim, every retry, every step is posted transactionally to a table you already own. No queue server. No external state machine. No vendor. The schema you can grep is the schema that runs.

0deps
beyond postgres
1mig
to install schema
3crates
core ยท postgres ยท runtime
โˆžaudit
your data, your db
ยง II โ€” Specimen

The ledger, in real time.

Each row is one workflow step, written in the same transaction as the work it represents. There is no separate queue process. There is no in-memory state. The table you'd SELECT * FROM is the table that runs.

runledger.workflow_step_runs order by posted_at desc ยท limit 12
live ยท streaming
โ„– posted workflow step status duration
0047 18:23:11 invoice.process validate2/5 done 142ms
0046 18:23:10 user.signup welcome_email4/4 running โ€”
47 entries ยท committed ยท isolation: read committed โœ“ posted
Transactional, end-to-end. Each step's status change is part of the same transaction as the side-effect it produces. Crashes don't leak partial work; restarts don't double-charge.
Queryable, by you. The ledger lives in your database. Join it against orders, against users, against anything. No webhook bridges, no exported metrics.
Auditable, forever. Every attempt, every error, every cancellation is a row. Forensic timelines reconstruct themselves with one ORDER BY.
ยง III โ€” The API

Define a workflow.
Enqueue it. Done.

Workflows are DAGs of steps. Each step names a job handler and declares its dependencies. Validation โ€” cycles, blank keys, non-positive timeouts, missing job types โ€” runs at enqueue, not at runtime.

Reading the example โ†’

An invoice flow, posted to the ledger.

Four steps. fetch feeds validate; validate gates charge; notify closes the loop. Each carries its own retry and timeout policy.

  • idem.One run per invoice_id. Retrying the enqueue is a no-op.
  • retriesThe charge step retries up to five times, with backoff applied by the runtime.
  • timeoutIf fetch exceeds 30s its attempt is reaped and re-queued.
  • externalThe final step waits for an external signal โ€” a webhook, a human approval, anything.
  • errorsWorkflowBuildError is an exhaustive enum. Match it. Log it.
examples/invoice.rs โŽ˜ copy
use runledger::prelude::*; async fn enqueue_invoice(    pool: &PgPool,    invoice_id: Uuid,) -> Result<WorkflowRunId, WorkflowBuildError> {    let workflow = WorkflowBuilder::new("invoice.process")        .idempotency_key(invoice_id.to_string())         .step("fetch")            .job("invoice.fetch")            .max_attempts(3)            .timeout_seconds(30)         .step("validate")            .after("fetch")            .job("invoice.validate")         .step("charge")            .after("validate")            .job("invoice.charge")            .max_attempts(5)            .timeout_seconds(15)         .step("notify")            .after("charge")            .external() // awaits webhook / approval         .build()?;     // validates the DAG, then writes the run + steps    // in a single transaction. returns the run id.    runledger_postgres::enqueue_workflow(pool, workflow).await}
ยง IV โ€” Properties

Six guarantees,
written down.

Runledger is not a framework; it is a small, opinionated library that makes a small set of strong promises. Each one is a column in the ledger and a line in the test suite.

โ„– 01

Postgres is the runtime.

No Redis. No Kafka. No external orchestrator. Your database holds the queue, the schedules, the workflow state, the hooks, and the audit log โ€” in tables you already know how to back up.

$ psql -c 'SELECT * FROM runledger.jobs LIMIT 5;'
โ„– 02

DAGs, validated at the door.

Cycles, dangling dependencies, blank step keys, non-positive timeouts โ€” caught at build(), before a worker picks up the first step. Exhaustive error enums, not strings.

WorkflowBuildError::Cycle { step_keys: [..] }
โ„– 03

Idempotent by construction.

Every workflow run carries an idempotency key. Retrying the enqueue is a no-op. Replaying a step is a no-op. The runtime treats your handlers as if they will be called twice โ€” because, eventually, they will be.

.idempotency_key(invoice.id)
โ„– 05

Cancel-safe locking.

Lock ordering is enforced across the cancel, reap, and step-completion paths. The chaos test suite proves that you cannot deadlock the runtime by racing cancellation against completion.

tests/workflow_cancel_lock_order.rs
โ„– 04

Hooks, not magic.

Subscribe to lifecycle events โ€” enqueued, started, succeeded, failed, cancelled โ€” with ordinary functions. No inheritance, no trait gymnastics, no surprise reflection.

.on_step_failed(|ctx, err| async { .. })
โ„– 06

Embed, don't deploy.

Runledger is three crates that compile into your existing service. You bring the handlers, the process bootstrap, the auth. It brings worker, scheduler, reaper, and a durable schema.

[dependencies]
runledger-runtime = "0.1"
ยง V โ€” The Shape

One database.
Three loops.

Your application enqueues. Workers claim and execute. The scheduler materializes cron rows. The reaper cleans up after orphans. All four meet in one place โ€” the ledger.

YOUR SERVICE enqueue ยท cancel YOUR SERVICE enqueue ยท cancel YOUR SERVICE enqueue ยท cancel INSERT INSERT INSERT postgres ยท the ledger SCHEMA: runledger jobs queue ยท attempts ยท status workflow_runs dag state ยท idempotency workflow_step_runs step lifecycle ยท hooks schedules cron ยท next_run_at โ”€ SELECT โ€ฆ FOR UPDATE SKIP LOCKED ยท BEGIN ยท COMMIT โ”€ CLAIM CLAIM ADVANCE REAP WORKER handler invocation WORKER handler invocation SCHEDULER cron ยท next_run_at REAPER timeouts ยท orphans one schema three loops
worker Claims jobs with FOR UPDATE SKIP LOCKED, runs your handler, heartbeats, retries.
scheduler Materializes cron rows into jobs in the same transaction that advances their next_run_at.
reaper Times out heartbeat-lost claims, requeues orphans, advances dead-letter rows.
your service Imports the crates, registers handlers, and enqueues. The loops run inside your process.
ยง VI โ€” Principles

Five things we
will not do.

Constraints, written down once. Runledger is small on purpose โ€” a smaller surface area is the most reliable kind of reliability.

i.
We will not ship a server.
Runledger is a library. It compiles into your binary, runs inside your process, and uses your existing Postgres connection pool. There is no runledger-server, and there never will be.
ii.
We will not invent a new query language.
Your data is in tables, indexed and joinable. To audit a workflow, write SELECT. To debug a stuck job, write SELECT. The query layer is the one you already use.
iii.
We will not bind to a single runtime.
The core crate has no tokio, no async-std, no I/O. Persistence and execution are in separate crates so you can swap, fork, or replace them.
iv.
We will not return stringly-typed errors.
Every failure mode is a variant in an exhaustive enum. Match on it. Log it structurally. Alert on the variants that matter, not on substrings.
v.
We will not bypass your transactions.
Enqueue inside BEGIN. Roll back, and nothing was queued. Commit, and the work is in the ledger. There is no separate broker to fall out of sync.
ยง VII โ€” Begin

Open the ledger.

install ยท cargo โŽ˜ copy
$ cargo add runledger-core \
              runledger-postgres \
              runledger-runtime

# apply the schema (one migration)
$ sqlx migrate run \
   --source migrations/

# register a handler and start a worker
$ cargo run --bin my-service
read next

Two starting points, depending on where you are.

If you already have a Postgres-backed Rust service, you can be running a job in about ten minutes. If you don't, the quickstart includes a docker-compose.yml.