Ergon

Built with Nix[Nix] Build & TestLicense

Warning

This project is under active development. Avoid using it for production apps.

A library for PostgreSQL-native background job and workflow processing in Erlang/OTP, with the simple premise that a recent PostgreSQL is enough on its own, so:

Everything leans on database capabilities:

Ergon also ships generic infrastructure for the common PostgreSQL stack: pgmq durable queues with topics, FIFO groups and long polling, pg_cron guarded schedule helpers, and monthly partition lifecycle management.

Note

Ergon targets PostgreSQL 19 and OTP 28 (see flake.nix). Both are pinned: GRAPH_TABLE and FOR PORTION OF are PG19 features, and the source uses OTP 28 syntax.

Quick start

1. Add the dependency

%% rebar.config
{deps, [{ergon, "0.5.0"}]}.

2. Point it at a database

Connection settings come from the standard libpq environment at boot: PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE. Nothing else is required, though config/sys.config can tune the pool and the wake paths.

3. Install the schema

{ok, _Summary} = ergon_migrate:migrate().

Ergon owns its own schema, in priv/migrations/, applied by migraterl under the ergon namespace. Host migrations can be registered alongside it, see migration helpers.

4. Enqueue and Work

{ok, Job} = ergon:enqueue(
ergon_new_job:on_queue(
ergon_new_job:new(~"send_email", #{~"to" => ~"a@b.com"}),
~"mailers")),
{ok, _Worker} = ergon:start_worker(
ergon_queue:with_concurrency(ergon_queue:new(~"mailers"), 4),
fun(#{payload := #{~"to" := To}}) ->
my_mailer:send(To)
end).

A handler returns ok to complete the job, or {error, Reason} to record the reason and retry until attempts are spent. Anything else, and any raise or throw, is treated as an error, so one bad job never takes a worker down.

What the database does

Almost everything, which is the point:

ConcernWhere it lives
Uniquenessa partial EXCLUDE USING gist over a temporal dedup window
Retry backoffjittered capped exponential, in ergon.retry_backoff
Legal state transitionsthe jobs_transition_guard trigger
HistoryFOR PORTION OF splits plus a system-time history twin
Workflow blockingpending_parents, inside the fetch index's predicate
Fair, contention-free checkoutFOR UPDATE SKIP LOCKED

The Erlang side is the thin part: a poller, an executor pool, and a LISTEN connection.

Transactional enqueue

Because a job is a row, enqueuing inside your own transaction makes the job and the data that justifies it commit or roll back together:

ergon_repo:transaction(fun() ->
{ok, _} = ergon_repo:query("UPDATE orders SET status = 'paid' WHERE id = $1", [OrderId]),
{ok, _} = ergon:enqueue(ergon_new_job:new(~"send_receipt", #{~"order" => OrderId}))
end).

This is the transactional outbox pattern without the outbox. There is no window in which the order was marked paid but the job was lost, and none in which the job runs for an update that rolled back.

Guides

Task-oriented walkthroughs live in examples/.

Development

# devenv shell: PG19 with pg_cron and pgmq, OTP 28
nix develop --impure
# start postgres
devenv up
rebar3 compile
rebar3 dialyzer
# nixfmt, erlfmt and pg_format
nix fmt