Packages
High-throughput, zero-bloat background job processing engine for Elixir powered by PgQue and Broadway.
Current section
Files
Jump to
Current section
Files
README.md
# Fleet
High-throughput, zero-bloat background job processing engine for Elixir, powered by **PgQue** and **Broadway**.
---
## Key Highlights
- **Zero-Bloat Architecture:** Powered by [PgQue](https://pgque.dev/) 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.Multi` support 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_cron` setup.
- **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`:
```elixir
def deps do
[
{:fleet, "~> 0.1.0"}
]
end
```
### 1. Run the Migration
Generate a migration in your application:
```bash
mix ecto.gen.migration add_fleet
```
And invoke `Fleet.Migration`:
```elixir
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`:
```elixir
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`:
```elixir
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
```elixir
# 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 exceed `max_attempts`, the job is automatically moved to the Dead-Letter Queue (DLQ).
- **Snoozing:** To delay execution dynamically, return `{:snooze, seconds}`:
```elixir
@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`:
```elixir
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`](https://hex.pm/packages/ecto_unnest) produces a zero-bloat, constant-SQL ingestion pipeline:
```elixir
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
```elixir
%{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:
```elixir
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:
```elixir
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`:
```elixir
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:
> 1. **Dead-Tuple Bloat:** Delayed jobs sitting in database tables cannot leverage rotating table `TRUNCATE` operations; they accumulate dead tuples on `DELETE` and cause index bloat on scheduled timestamps.
> 2. **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:
```elixir
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:
```elixir
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:
```elixir
# 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:
```elixir
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`:
```elixir
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 the `subscriptions` table. 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`:
```elixir
# 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.