Belay is a durable job engine that runs inside your Elixir application and stores everything in Postgres. It needs Postgrex, Jason, and telemetry — no Ecto, no Redis, no separate orchestrator service.
Install
# mix.exs
def deps do
[{:belay, "~> 1.0.0-rc.6"}]
endCreate the schema once (from a release task, migration, or iex):
Belay.Storage.Postgres.migrate!("postgres://user:pass@localhost/my_app")migrate!/1 is idempotent and versioned — safe to run on every deploy.
Configure and start an instance
Belay reads its configuration from your application environment, keyed by
the instance name — the same convention as Ecto.Repo and
Phoenix.Endpoint:
# config/config.exs
config :my_app, MyApp.Belay,
queues: [
default: 10,
mailers: [limit: 20],
ai: [limit: 5, global_limit: 2, rate: [allowed: 60, period: 60]]
]
# config/runtime.exs — values you only know at runtime
config :my_app, MyApp.Belay,
storage: [adapter: :postgres, url: System.fetch_env!("DATABASE_URL")]Then otp_app pulls that config in the supervision tree:
# application.ex
children = [
MyApp.Repo,
{Belay, otp_app: :my_app, name: MyApp.Belay}
]Inline opts on the child spec override the application-env base, so a computed URL or a test override can be passed directly:
{Belay, otp_app: :my_app, name: MyApp.Belay, storage: [adapter: :memory]}Or skip otp_app entirely and pass everything inline — both forms accept
the same keys. Every node running this tree becomes a worker node. There is
no leader: scheduling, cron, and recovery are all any-node operations
deduplicated by the database.
Define work
defmodule MyApp.WelcomeEmail do
use Belay.Worker, queue: :mailers, max_attempts: 5
@impl Belay.Worker
def run(ctx) do
user = MyApp.Users.get!(ctx.job.input["user_id"])
MyApp.Mailer.deliver_welcome(user)
end
endReturn values: :ok or {:ok, result} succeed (the result is stored and
retrievable), {:error, reason} retries with exponential backoff,
{:cancel, reason} stops permanently, {:snooze, seconds} reschedules
without consuming an attempt. Raised exceptions retry.
Enqueue
{:ok, job} = Belay.insert(MyApp.Belay, MyApp.WelcomeEmail.new(%{"user_id" => 42}))
# Options on new/2:
MyApp.WelcomeEmail.new(%{"user_id" => 42},
queue: :mailers, # override the worker default
schedule_in: 300, # run in five minutes
priority: 1, # 0 (highest) .. lower numbers first
max_attempts: 3,
unique: "welcome:42", # at most one incomplete job with this key
budget: [usd: 0.50] # fail after a step's declared cost crosses the limit
)Need the outcome? Belay.await_result/3 gives background work RPC
ergonomics:
{:ok, job} = Belay.insert(MyApp.Belay, MyApp.Summarize.new(%{"url" => url}))
case Belay.await_result(MyApp.Belay, job.id, 30_000) do
{:ok, summary} -> summary
{:error, {:job, :failed}} -> :gave_up
{:error, :timeout} -> :still_running
endRecurring jobs
{Belay,
name: MyApp.Belay,
...,
crons: [
[name: "daily-digest", expr: "0 8 * * 1-5", worker: MyApp.Digest],
[name: "cleanup", expr: "@hourly", worker: MyApp.Cleanup, input: %{"mode" => "fast"}]
]}Any node may fire a cron slot; a unique index guarantees each slot inserts exactly once cluster-wide.
Where to next
- Durable steps — the primitive that makes retries cheap
- Migrating from Oban — porting map + one-command job migration
- Building agents — budgets, human approval, fan-out, streaming
- Operations — leases, shutdown, retention, observability
- Testing — deterministic tests with
drainand a SimClock - Add
{Belay.Dashboard, belay: MyApp.Belay, port: 4004}for a read-only local UI, or configuretoken:/authorizer:to enable operator actions