Fleet
High-throughput, zero-bloat background job processing engine for Elixir, powered by PgQue and Broadway.
Key Highlights
- Zero-Bloat Architecture: Powered by PgQue using snapshot-based batching and rotating tables. Reclaiming disk space happens via instant
TRUNCATE, completely eliminating table and index dead-tuple bloat under heavy workloads. - Physical Queue Isolation: Each queue is provisioned with its own physical partition tables in PostgreSQL. A traffic surge on an analytics queue will never contend for row locks or degrade a critical mailer queue.
- Demand-Driven Concurrency: Backed by Broadway and GenStage, providing native backpressure, graceful shutdown, and memory protection.
- Transactional Enqueueing (Outbox): Enqueue jobs within the same Ecto transaction as your business records (
Ecto.Multisupport included). - Native Batching (Chunks): Built-in support for chunked batch execution (
perform_batch/1) for bulk external operations like API requests or data ingestion. - Configurable History & Retention: Configurable ring-buffer partitions (
history_tables: 7, rotation_period: "1 day") allow querying completed jobs for days without extra audit tables. - Internal BEAM Ticker: Built-in scheduler process allows running test suites and local development without mandatory
pg_cronsetup. - Declarative Cron: Built-in recurring job scheduler supporting cron expressions and interval statements (
@every 30s,@hourly, etc.).
Installation
Add fleet to your list of dependencies in mix.exs:
def deps do
[
{:fleet, "~> 0.1.0"}
]
end
1. Run the Migration
Generate a migration in your application:
mix ecto.gen.migration add_fleet
And invoke Fleet.Migration:
defmodule MyApp.Repo.Migrations.AddFleet do
use Ecto.Migration
def up do
Fleet.Migration.up()
end
def down do
Fleet.Migration.down()
end
end
Run mix ecto.migrate to create the schema and functions.
Defining Workers
Define background workers by using Fleet.Worker:
defmodule MyApp.Workers.SendEmail do
use Fleet.Worker, queue: :mailers, max_attempts: 5
@impl Fleet.Worker
def perform(%Fleet.Job{args: %{"to" => to, "subject" => subject}}) do
MyApp.Mailer.send(to, subject)
end
end
Strongly Typed Workers with Fleet.Schema
For compile-time type safety, automatic runtime casting, and changeset validation, use Fleet.Schema:
defmodule MyApp.Workers.SendInvoice do
use Fleet.Worker, queue: :billing, max_attempts: 3
use Fleet.Schema
embedded_schema do
field :invoice_id, :integer
field :amount, :decimal
field :currency, :string, default: "USD"
end
# Optional: custom validation rules
def changeset(schema, attrs) do
schema
|> cast(attrs, [:invoice_id, :amount, :currency])
|> validate_required([:invoice_id, :amount])
end
@impl Fleet.Worker
def perform(%Fleet.Job{args: %__MODULE__{invoice_id: id, amount: amount, currency: curr}}) do
# 100% typed: `amount` is Decimal, `id` is integer, `curr` defaults to "USD"
MyApp.Billing.charge(id, amount, curr)
end
end
Enqueueing Typed Jobs
# Auto-casts strings to Decimals, integers, DateTimes, etc. Raises on invalid changeset
job = MyApp.Workers.SendInvoice.new(%{invoice_id: 101, amount: "49.99"})
Fleet.insert(MyApp.Repo, job)
# Safe constructor returning {:ok, job} | {:error, changeset} (ideal for web controllers)
case MyApp.Workers.SendInvoice.build(params) do
{:ok, job} -> Fleet.insert(MyApp.Repo, job)
{:error, %Ecto.Changeset{} = cs} -> {:error, cs}
end
Snoozing and Retries
- Retries: If a worker raises an error or returns
{:error, reason}, Fleet automatically schedules a retry using exponential backoff with jitter (backoff/1). Once attempts exceedmax_attempts, the job is automatically moved to the Dead-Letter Queue (DLQ). - Snoozing: To delay execution dynamically, return
{:snooze, seconds}:
@impl Fleet.Worker
def perform(%Fleet.Job{args: %{"task_id" => id}}) do
if MyApp.TaskService.ready?(id) do
MyApp.TaskService.run(id)
else
# Delay for 60 seconds
{:snooze, 60}
end
end
Batch Workers (Chunks)
Process multiple jobs together in a single batch, automatically buffered by Broadway according to batch_size and batch_timeout:
defmodule MyApp.Workers.BulkEvents do
use Fleet.Worker, queue: :events
@impl Fleet.Worker
def perform(_job), do: :ok
@impl Fleet.Worker
def perform_batch(jobs) do
payloads = Enum.map(jobs, fn job -> job.args end)
MyApp.Analytics.send_bulk(payloads)
:ok
end
end
High-Performance Database Ingestion (with ecto_unnest)
When your batch worker needs to bulk-insert processed jobs into your application's Ecto schemas, pairing perform_batch/1 with ecto_unnest produces a zero-bloat, constant-SQL ingestion pipeline:
defmodule MyApp.Workers.ImportUsers do
use Fleet.Worker, queue: :imports
@impl Fleet.Worker
def perform_batch(jobs) do
# Extract records buffered by Broadway
user_ids = Enum.map(jobs, & &1.args["user_id"])
emails = Enum.map(jobs, & &1.args["email"])
names = Enum.map(jobs, & &1.args["name"])
# Constant SQL bulk insert using PostgreSQL unnest()
EctoUnnest.insert_all(
MyApp.Repo,
MyApp.User,
%{user_id: user_ids, email: emails, name: names},
placeholders: %{inserted_at: DateTime.utc_now()}
)
:ok
end
end
Broadway provides the memory protection and backpressure, while unnest() avoids prepared statement proliferation and eliminates SQL parameter limit bottlenecks.
Enqueueing Jobs
Direct Insertion
%{to: "jane@example.com", subject: "Welcome"}
|> MyApp.Workers.SendEmail.new()
|> Fleet.insert(MyApp.Repo)
Transactional Insertion with Ecto.Multi
Ensure jobs are only enqueued if your business transaction commits:
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Fleet.insert(:welcome_email, fn %{user: user} ->
MyApp.Workers.SendEmail.new(%{"to" => user.email, "subject" => "Welcome!"})
end)
|> MyApp.Repo.transaction()
Bulk Enqueueing (Native PostgreSQL unnest Batching)
Enqueue hundreds or thousands of jobs in a single round-trip:
jobs = Enum.map(users, fn user ->
MyApp.Workers.SendEmail.new(%{"to" => user.email})
end)
Fleet.insert_all(MyApp.Repo, jobs)
Tip
Zero Statement Bloat: Under the hood, Fleet.insert_all/2 delegates to pgque.send_batch/3, which uses PostgreSQL's native unnest($2::text[]) WITH ORDINALITY. The query text remains 100% constant regardless of batch size, making it fully compatible with transaction-mode connection poolers like PgBouncer and completely immune to PostgreSQL's 65,535 query parameter cap.
Configuration and Supervision
Add Fleet to your application supervision tree in lib/my_app/application.ex:
def start(_type, _args) do
children = [
MyApp.Repo,
{Fleet,
repo: MyApp.Repo,
run_ticker: true,
queues: [
default: [concurrency: 10],
mailers: [concurrency: 20, poll_interval: 50],
events: [concurrency: 5, batch_size: 50, batch_timeout: 1000]
],
cron: [
{"@hourly", MyApp.Workers.CleanupWorker},
{"@every 30s", MyApp.Workers.HeartbeatWorker, args: %{"scope" => "all"}}
]}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
Recipes & Architectural Patterns
Recipe 1: Zero-Bloat Recurring & Mid-Month Billing (The Reconciliation Sweep)
A common challenge in subscription systems is billing customers periodically (e.g., monthly) while supporting signups that occur on arbitrary days throughout the month.
Warning
Anti-Pattern: 30-Day Sleeping Queue Jobs Enqueueing a background job scheduled 30 days in the future for every subscriber is an architectural anti-pattern:
- Dead-Tuple Bloat: Delayed jobs sitting in database tables cannot leverage rotating table
TRUNCATEoperations; they accumulate dead tuples onDELETEand cause index bloat on scheduled timestamps. - Stale / Zombie State: If a subscriber cancels, upgrades, or updates their credit card on day 15, an enqueued job becomes a "zombie" that must either be manually searched and deleted or defended with complex pre-execution checks.
The Recommended Pattern: State in Postgres + Scheduled Sweep
Instead of storing scheduling state in queue tables, store the next renewal timestamp in your application database (subscriptions.next_billing_at), index it with a partial index, and run an @hourly or @daily reconciliation sweep using Fleet.Cron.
1. Database Schema with Partial Index
In your PostgreSQL migration:
create table(:subscriptions) do
add :user_id, references(:users), null: false
add :status, :string, default: "active", null: false
add :next_billing_at, :utc_datetime, null: false
add :amount, :decimal, precision: 10, scale: 2, null: false
timestamps()
end
# Ultra-compact, fast partial index:
create index(:subscriptions, [:next_billing_at], where: "status = 'active'")
2. Handling Mid-Month Signups
When a user signs up on any day of the month (e.g. October 14th), charge their initial invoice immediately and schedule their next_billing_at 30 days into the future within the same transaction:
def register_and_subscribe(user_params, card_token) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.changeset(user_params))
|> Ecto.Multi.insert(:subscription, fn %{user: user} ->
Subscription.changeset(%{
user_id: user.id,
status: "active",
next_billing_at: DateTime.add(DateTime.utc_now(), 30, :day)
})
end)
# Enqueue immediate first charge (or process synchronously):
|> Fleet.insert(:initial_charge, fn %{subscription: sub} ->
MyApp.Workers.ChargeSubscription.new(%{"subscription_id" => sub.id, "initial" => true})
end)
|> MyApp.Repo.transaction()
end
3. The Hourly Reconciliation Sweep Worker
Use Fleet.Cron in your application supervision tree:
# lib/my_app/application.ex
{Fleet,
repo: MyApp.Repo,
cron: [
{"@hourly", MyApp.Workers.BillingSweepWorker}
]}
The sweep queries only active subscriptions due right now and enqueues instant jobs in bulk:
defmodule MyApp.Workers.BillingSweepWorker do
use Fleet.Worker, queue: :billing
import Ecto.Query
@impl Fleet.Worker
def perform(_job) do
now = DateTime.utc_now()
# Query only active subscriptions that are due
due_subs =
MyApp.Repo.all(
from s in MyApp.Subscription,
where: s.status == "active" and s.next_billing_at <= ^now,
limit: 1000
)
# Bulk-enqueue immediate jobs with constant SQL unnest
jobs =
Enum.map(due_subs, fn sub ->
MyApp.Workers.ChargeSubscription.new(%{"subscription_id" => sub.id})
end)
{:ok, _} = Fleet.insert_all(MyApp.Repo, jobs)
:ok
end
end
4. The Charge Worker
The worker executes under Broadway's concurrency control, charges the payment gateway, and advances next_billing_at:
defmodule MyApp.Workers.ChargeSubscription do
use Fleet.Worker, queue: :billing, max_attempts: 3
@impl Fleet.Worker
def perform(%Fleet.Job{args: %{"subscription_id" => sub_id}}) do
case MyApp.Billing.charge_and_renew(sub_id) do
:ok ->
:ok
{:error, :card_declined} ->
# Mark subscription past_due; do not retry
MyApp.Billing.mark_past_due(sub_id)
:ok
{:error, :gateway_timeout} ->
# Transient failure: Fleet automatically retries with exponential backoff
{:error, :gateway_timeout}
end
end
end
Why this preserves Fleet's Zero-Bloat guarantees:
- Instant Reclaim: Jobs exist in queue tables only during active execution (milliseconds to seconds); finished jobs cycle through rotating tables and are cleaned up with zero-bloat
TRUNCATE. - Zero Zombie Jobs: Cancellations are as simple as setting
status: "cancelled"in thesubscriptionstable. No dangling jobs to find and cancel in queue tables. - Extreme Scale: Even with millions of users, PostgreSQL's partial index (
WHERE status = 'active') returns due records in microseconds with minimal RAM overhead.
Ring-Buffer Retention & Historical Queries
Fleet retains completed jobs in circular partition tables according to each queue's configured retention. You can query past jobs directly with Fleet.History:
# Count jobs created in the last 24 hours
since = DateTime.add(DateTime.utc_now(), -86400, :second)
{:ok, count} = Fleet.History.count_jobs(MyApp.Repo, :mailers, since: since)
# Paginate recent historical events
{:ok, recent_jobs} = Fleet.History.list_jobs(MyApp.Repo, :mailers, limit: 25, offset: 0)
# Fetch a single historical job by ID
{:ok, job} = Fleet.History.get_job(MyApp.Repo, :mailers, 1024)
Telemetry Events
Fleet emits standard :telemetry events:
[:fleet, :job, :start]—%{system_time: integer()},%{job: Fleet.Job.t()}[:fleet, :job, :stop]—%{duration: integer()},%{job: Fleet.Job.t(), status: :ok | :error | :snoozed}[:fleet, :job, :exception]—%{duration: integer()},%{job: Fleet.Job.t(), kind: atom(), reason: term(), stacktrace: list()}[:fleet, :cron, :trigger]—%{system_time: integer()},%{worker: module(), job: Fleet.Job.t()}
License
Apache License 2.0.