Current section

Files

Jump to
zizq CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
## 0.7.0
### Added
- **Budgets** (Pro) — server-side concurrency control and rate
limiting. A budget is a named token bucket that jobs must debit
tokens from, and a job that cannot afford its cost waits rather
than dispatching immediately.
# A rate limit: 100 dispatches per minute.
Zizq.Budget.new!(
key: "emails",
allocation: 100,
strategy: :time_based,
duration: :timer.minutes(1)
)
|> Zizq.define_budget(MyApp.Zizq)
# A concurrency limit: at most 3 running at once.
Zizq.Budget.new!(key: "stripe", allocation: 3, strategy: :while_in_flight)
|> Zizq.define_budget(MyApp.Zizq)
Full details are available at
https://zizq.io/docs/clients/elixir/budgets.html
- **A group-level cron timezone.** `Zizq.Cron` gains `:timezone`, an
IANA name applied to every entry that does not name one of its own:
Zizq.Cron.new("my_app",
timezone: "Australia/Melbourne",
entries: [
[name: "nightly_cleanup", expression: "0 3 * * *", job: MyApp.Cleanup.new(%{})],
[name: "eu_digest", expression: "0 9 * * *", timezone: "Europe/Rome", job: ...]
]
)
|> Zizq.replace_cron(MyApp.Zizq)
An entry naming its own timezone still wins, so one schedule can
hold entries in several zones. With neither set, the server
evaluates the expression in its own local timezone, as before.
It is stored on the server as the group's timezone rather than
copied onto each entry, so a schedule read back with
`Zizq.get_cron/2` still carries it and can be replaced unchanged.
Needs Zizq 0.7.0 or newer on the server. Against an older server the
field is ignored and entries relying on it fall back to the server's
local timezone.
### Fixed
- **An empty filter selected everything rather than nothing.** A set
filter given `[]` matches no jobs, but it encoded to no query
parameter at all — which the server reads as *unfiltered*:
# before: deleted every job on the server
# now: {:ok, 0}, and no request is made
Zizq.delete_all_jobs([queue: []], MyApp.Zizq)
The distinction matters because the list is usually computed. A
`queue: Enum.filter(...)` that comes back empty has to delete
nothing, not everything.
Affects `Zizq.list_jobs/2`, `Zizq.count_jobs/2`,
`Zizq.delete_all_jobs/2` and `Zizq.update_all_jobs/2`, along with
their `!` twins and the `Zizq.Query` forms that use them. Each now
answers its own zero — an empty page, `0`, or an empty
`Zizq.BudgetChange` — without sending a request. An empty string
counts the same way, since it encodes to the same absent parameter.
An *absent* filter still matches everything, as before: `queue: nil`
and an omitted `:queue` are unchanged. Only "present but empty" moved.
`Zizq.Filter.matches_nothing?/1` exposes the test, for code that
builds filters and wants to decide for itself.
## 0.6.1
### Added
- **TLS configuration, including mutual TLS.** A new `:tls` option on
the client:
{Zizq,
name: MyApp.Zizq,
url: "https://zizq.internal:7890",
tls: [
ca: "/etc/zizq/ca.pem",
client_cert: "/etc/zizq/client.pem",
client_key: "/etc/zizq/client-key.pem"
]}
`:ca` verifies the server against a private authority instead of the
system trust store; `:client_cert` and `:client_key` present a client
identity for mutual TLS, which needs a Zizq Pro licence on the
server. An `https://` URL still needs no configuration at all —
connections already verify against the system trust store.
Each value may be the PEM contents or a path to a file holding them,
matching the Ruby client so the same material is configured the same
way from either.
The options apply to **both** connections a client opens: the pooled
HTTP/2 one carrying requests, and the separate HTTP/1.1 one a
worker's take stream owns. A worker is not quietly exempt from the
verification the rest of the client does.
Configuration is checked when the client starts, so a missing file, a
certificate without its key, or `:tls` on an `http://` URL fails at
boot rather than as a handshake error on the first request.
This closes the one capability the Elixir client was missing that the
Ruby, Node and Rust clients all advertise.
## 0.6.0
First stable release. No API changes since `0.6.0-alpha.9` — this
promotes the alpha series, summarised below for anyone arriving
without having followed it. Requires Elixir 1.18 or newer.
### Producing
- **`Zizq.enqueue/2`** and **`Zizq.enqueue_all/2`** — one job, or many
in a single atomic request. Every function has a `!` variant that
raises rather than returning `{:error, _}`
- **`Zizq.JobKind`** — declare a job's type, its enqueue defaults and
what running it does, in one module. Optional: a keyword list
enqueues just as well, which is all a producer needs
- **Unique jobs** — deduplicate at enqueue time by a literal key or one
derived from the payload, scoped to `:queued`, `:active` or `:exists`
- **Batched jobs** — fold many enqueues into one job, with the
`when`/`fold` expressions generated from a `:limit` and a `:path`
### Consuming
- **`Zizq.Worker`** — a supervised process group that streams jobs,
runs each in its own task, batches acknowledgements and drains
cleanly on shutdown
- **`Zizq.Router`** — dispatch several kinds of job through one worker,
by type. An unrecognised type raises rather than being swallowed,
unless a fallback is given
- **Outcomes** — a handler's return value decides the job's fate:
`:ok`, `{:error, _}`, `{:cancel, _}` or `{:snooze, _}`
### Reading and managing
- **`Zizq.Query`** — a composable, enumerable query that paginates for
you. `Enum.count/1` asks the server rather than walking pages, and
`update_all/2` / `delete_all/1` can work a bounded page at a time
- **Direct functions**`list_jobs/2`, `count_jobs/2`, `get_job/2`,
`update_job/3`, `delete_job/2`, their bulk equivalents,
`list_queues/1`, `list_errors/3` and `erase_all_data/1`
- **`Zizq.Cron`** — install a schedule as a whole, atomically and
idempotently, or amend a single entry in one request
### Observability and testing
- **`Zizq.Telemetry`**`:telemetry` spans for jobs and enqueues, plus
take-stream connect/disconnect events
- **`Zizq.Testing`** — assert on what your code enqueued and run
handlers directly, with no server and no network
### Notes
- Unique jobs, batched jobs and cron scheduling need a Zizq **Pro**
licence on the server. Without one the server answers 403, which
arrives as `%Zizq.Error{reason: :forbidden}`
- Durations are integer milliseconds throughout, and payloads are
always string-keyed by the time a handler sees them
- Guide at <https://zizq.io/docs/clients/elixir/>, API reference on
HexDocs
## 0.6.0-alpha.9
### Added
- **`Zizq.Testing.clear_enqueued/0`.** Forgets what a test has
recorded so far, so a test that acts twice can assert on the second
action alone:
MyApp.Sitemap.scan(sitemap)
assert_enqueued(type: "check_url")
clear_enqueued()
# The second scan should find nothing new to do.
MyApp.Sitemap.scan(sitemap)
refute_enqueued(type: "check_url")
Without it a refutation cannot tell the two actions apart, since the
first one's enqueues are still recorded. Clearing is scoped to the
test that calls it, so it stays safe under `async: true`.
Found while writing the `uptime_monitor` example, where a sitemap is
rescanned and the second pass should enqueue nothing new.
## 0.6.0-alpha.8
Adds a composable query over the listing endpoints, so paging is
something the client does rather than something you write.
### Added
- **`Zizq.query/1` and `Zizq.Query`.** A query is enumerable, so
`Enum` and `Stream` work on it directly and pages are fetched only
as they are needed:
Zizq.query(MyApp.Zizq)
|> Zizq.Query.where(queue: "emails", status: [:ready])
|> Enum.take(10)
That stops after the first page or two, because `Enum.take/2` stops
asking. Run to completion, the same query walks every page. Building
one sends nothing; a request happens when it is enumerated, counted,
or acted on.
`where/2` takes the filters `Zizq.Filter` already documents and can
be called repeatedly, later calls winning per key — one function
rather than a `by_queue`, `by_status` and so on for each field,
since the filters are already a keyword list. It validates as the
query is built, so a typo fails at the line that made it rather than
wherever the query is eventually run.
`order/2` picks the direction, `limit/2` caps the total, and
`in_pages_of/2` sets how many are fetched per request — the last two
are independent, being *what you want* and *how eagerly it is
fetched*.
`Enum.count/1` asks the server to count rather than walking pages,
so it costs one request whatever the total, and agrees with what
enumerating the same query yields.
`pages/1` streams whole `Zizq.JobPage`s for when a page is the
useful unit.
- **Acting on everything a query matches.** `Zizq.Query.update_all/2`
and `Zizq.Query.delete_all/1` send one request and let the server do
the work from the filters:
Zizq.query(MyApp.Zizq)
|> Zizq.Query.where(queue: "emails", status: :scheduled)
|> Zizq.Query.update_all(ready_at: nil)
Give the query a `limit/2` or an `in_pages_of/2` and they work a
page at a time instead, acting on each page by id — so ten million
jobs become a run of bounded requests rather than one enormous one:
Zizq.query(MyApp.Zizq)
|> Zizq.Query.where(queue: "emails", status: :dead)
|> Zizq.Query.in_pages_of(1_000)
|> Zizq.Query.delete_all()
Each page's ids are sent *with* the original filters rather than
instead of them, so a job that stopped matching between being listed
and being acted on is left alone. The count returned is the total
across every batch either way.
## 0.6.0-alpha.7
Completes the API: jobs can now be read, changed and deleted — one at
a time or in bulk — listed and counted, their failure history read,
and their cron schedules managed.
### Added
- **Reading, changing and deleting one job.** `Zizq.get_job/2`,
`Zizq.update_job/3` and `Zizq.delete_job/2`, each taking a
`Zizq.Job` or an id:
Zizq.update_job(job, MyApp.Zizq, queue: "urgent", priority: 0)
Updates are a merge patch, so the three states are distinct: an
option left out leaves that field alone, `nil` clears it to the
server's default, and a value sets it.
# Retry with whatever the server's default limit now is.
Zizq.update_job(job, MyApp.Zizq, retry_limit: nil)
`:queue` and `:priority` have no default to clear to, so `nil` there
is rejected with a message saying to omit the option instead.
`:retention` merges field by field, so naming `:completed` leaves
`:dead` as it was.
- **Listing and counting jobs.** `Zizq.list_jobs/2` returns a
`Zizq.JobPage`; follow it with `Zizq.next_page/2` and
`Zizq.prev_page/2`. `Zizq.count_jobs/2` counts without listing, and
`Zizq.list_queues/1` reports the queues jobs have named.
Zizq.list_jobs([queue: "emails", status: [:ready]], MyApp.Zizq)
`Zizq.Filter` documents the filters, which every selecting endpoint
shares. Ranges are inclusive and take a number, a `Range`, or
`[min: _, max: _]` — Elixir has no open-ended `Range`, so one-sided
bounds are given as keywords:
priority: 5 # exactly 5
priority: 1..10 # 1 to 10
priority: [min: 5] # 5 and above
Pages are followed by the links the server supplies.
- **Changing and deleting jobs in bulk.** `Zizq.update_all_jobs/2` and
`Zizq.delete_all_jobs/2`, selecting with the same filters and
returning how many were affected:
Zizq.update_all_jobs(
[where: [queue: "emails", status: :scheduled], apply: [ready_at: nil]],
MyApp.Zizq
)
The halves are named because both are keyword lists sharing keys —
`queue:` and `priority:` mean something on each side — so a
transposition could otherwise change the wrong jobs rather than
fail. Filters restrict the operation the way a `WHERE` clause does,
and are optional for the same reason.
- **Reading why a job failed.** `Zizq.list_errors/3` returns a
`Zizq.ErrorPage` of `Zizq.ErrorRecord`s, one per failed attempt, and
`Zizq.get_error/3` reads a single attempt.
Zizq.list_errors(job, MyApp.Zizq)
Note that a record's `:attempt` numbers the attempt it belongs to,
counting from 1, while a `Zizq.Job`'s `:attempts` counts attempts
already finished — so a handler on its first run sees `0`, and its
failure is recorded as attempt `1`.
- **Cron schedules.** `Zizq.Cron` is both what you build and what the
server returns, so installing and amending are the same shape:
Zizq.Cron.new("my_app",
entries: [
[name: "nightly_cleanup",
expression: "0 3 * * *",
job: MyApp.Cleanup.new(%{})]
]
)
|> Zizq.replace_cron(MyApp.Zizq)
Installing is atomic and idempotent, so every instance of an
application can do it on boot without coordinating. Entries left out
are removed, so a `Zizq.Cron` is the whole schedule rather than an
addition to it — which is what makes running it on every boot
converge rather than accumulate.
An entry's job is an ordinary `Zizq.Enqueue`, so anything that can
be enqueued can be scheduled, including a job built by a
`Zizq.JobKind` module.
Also `Zizq.get_cron/2`, `Zizq.list_crons/1`, `Zizq.delete_cron/2`,
`Zizq.delete_all_crons/1`, and `Zizq.pause_cron/2` /
`Zizq.resume_cron/2`. A schedule can be amended with
`Zizq.Cron.put_entry/2` and `Zizq.Cron.delete_entry/2` and put back,
which suits one-off changes; `Zizq.pause_cron_entry/2` and
`Zizq.delete_cron_entry/2` change a single entry in one request.
- **`Zizq.erase_all_data/1`.** Deletes every job and every cron
schedule in one request, for tests and development that want a
known-empty server between scenarios:
Zizq.erase_all_data(MyApp.Zizq)
### Notes
- Cron, unique keys and batching are Pro-licensed features. Without a
licence the server responds 403, which is surfaced as
`%Zizq.Error{reason: :forbidden}`.
## 0.6.0-alpha.6
Adds observability and test support: telemetry events for jobs,
enqueues and the worker's take stream, and helpers for asserting on
what was enqueued without a server. The query and cron endpoints are
still to come.
### Added
- **Telemetry events.** Attach with `:telemetry.attach_many/4`, or
point `Telemetry.Metrics` at them. Nothing needs configuring, and an
event with no handlers costs only the emit:
| Event | Shape |
| --- | --- |
| `[:zizq, :job, :start \| :stop \| :exception]` | span, one per job |
| `[:zizq, :enqueue, :start \| :stop \| :exception]` | span, one per call |
| `[:zizq, :stream, :connect \| :disconnect]` | single event |
Job events carry `:worker`, `:id`, `:type`, `:queue` and
`:attempts`, and `:stop` adds an `:outcome` of `:ok`, `:error`,
`:cancel`, `:snooze` or `:unknown` — so a counter does not have to
work out for itself which returns were failures. A handler that
raises produces `:exception` instead of `:stop`, meaning a count of
`:stop` alone undercounts.
Enqueue events are one span per *call*, not per job: a bulk enqueue
of a thousand jobs is one request and one span, with `:count` set
and `:type`/`:queue` `nil`. A rejected enqueue returns an error
rather than raising, so it arrives as a `:stop` carrying
`outcome: :error` and the `Zizq.Error` — count both that and
`:exception` to catch every failure.
`Zizq.Telemetry` documents every event and its metadata.
- **`Zizq.Testing`.** Assert on what your code enqueued, and run
handlers, without a server:
defmodule MyApp.SignupTest do
use ExUnit.Case, async: true
use Zizq.Testing, client: MyApp.Zizq
test "signing up sends a welcome email" do
MyApp.Signup.run("ada@example.com")
assert_enqueued(type: "send_email", payload: %{"template" => "welcome"})
end
end
Start the recorder once in `test/test_helper.exs` with
`Zizq.Testing.start_link()`.
A client set up this way records enqueues instead of sending them,
and answers with a `Zizq.Job` as the server would. The diversion
happens at the request itself, so everything above it runs
unchanged — option validation, payload-derived unique and batch
keys, and telemetry all behave as they do in production.
Recordings belong to the test that made them rather than to the
client, so a fixed client name is safe under `async: true`.
Enqueues from a `Task` count too: `$callers` is followed to find the
test that started it.
Alongside `assert_enqueued/1`, `refute_enqueued/1` and
`all_enqueued/1`:
* `perform_job/3` runs one handler and returns what it returned,
including `{:error, reason}`, so a test asserts on the outcome
rather than on what a worker would have made of it.
* `drain_enqueued/2` runs every enqueued job through a handler or
a `Zizq.Router` and returns how many ran. `recursive: true`
keeps going until nothing new is enqueued, for handlers that
enqueue further work, bounded by `:max_iterations`.
Payloads are normalised through JSON on the way in, exactly as they
are on the way to a real server, so a payload enqueued as
`%{user_id: 1}` records and matches as `%{"user_id" => 1}` — and a
handler under `perform_job/3` receives the string keys it would
receive in production.
Both `perform_job/3` and `drain_enqueued/2` fail the test if a
handler returns something Zizq does not recognise. A worker
acknowledges such a job and logs a warning, since by then the work
is done; a test is where that is cheapest to catch.
## 0.6.0-alpha.5
Adds the declarative layer: jobs as modules, dispatch by type, and
keys derived from the payload for uniqueness and batching. The query
and cron endpoints are still to come.
### Added
- **`Zizq.JobKind`.** Declares a kind of job — its name on the server,
the enqueue options it defaults to, and what running it does:
defmodule MyApp.SendEmail do
use Zizq.JobKind, type: "send_email", queue: "emails"
@impl Zizq.JobKind
def perform(%{"user_id" => id}), do: MyApp.Mailer.deliver(id)
end
MyApp.SendEmail.new(%{"user_id" => 42})
|> Zizq.enqueue(MyApp.Zizq)
`type:` is required and never inferred from the module name, so
renaming the module cannot silently change the wire contract other
languages enqueue against. Everything else is any option
`Zizq.Enqueue` takes, validated while the module compiles, so a
malformed `:backoff` is a build failure rather than a surprise at
the first enqueue.
Define `perform/1`, or `perform/2` to also receive the `Zizq.Job`.
Defining both prefers `/2`; defining neither fails at compile time.
The choice is resolved during compilation, so dispatch costs nothing
at runtime.
- **`Zizq.Router`.** Dispatches jobs to the module that defines them,
and is accepted directly as a worker's `:handler`:
{Zizq.Worker,
client: MyApp.Zizq,
queues: ["emails"],
handler: Zizq.Router.new([MyApp.SendEmail, MyApp.GenerateReport])}
Routes can also be added one at a time, which suits building them
conditionally, and plain functions can be registered for jobs not
worth a module:
Zizq.Router.new()
|> Zizq.Router.route(MyApp.SendEmail)
|> Zizq.Router.route("ping", fn _payload -> :ok end)
|> Zizq.Router.fallback(&MyApp.unknown_job/1)
A type in no route raises `Zizq.Router.UnknownJobType`, which the
worker reports as a failure, so the job retries — usually right,
since a rolling deploy can enqueue from new code onto a worker
running old code. A `:fallback` handles them instead.
Types are registered, never resolved: turning a wire type into a
module at runtime would mean `String.to_existing_atom/1` on data
from the queue.
- **Unique keys derived from the payload.** Deduplicate on the fields
that decide identity, ignoring the ones that do not:
use Zizq.JobKind,
type: "send_email",
unique_key: {:payload, only: [".user_id", ".template"]},
unique_while: :queued
`{:payload, except: [...]}` and `:payload` (the whole payload) are
the other forms, and all three work on a plain `Zizq.enqueue/2` as
well. Paths are jq-flavoured, and are parsed while the job module
compiles, so a malformed one is a build failure and no enqueue pays
to parse it.
`Zizq.PayloadHasher` builds the keys, hashing canonical JSON into
SHA-256 so that key order does not matter but structure does. Keys
are prefixed with the job type by default, since two kinds of job
with identical payloads are still different jobs.
- **Batch configuration generated from a path and a limit.** Say where
the batch accumulates and how large it may get; the jq expressions
follow:
use Zizq.JobKind,
type: "push",
batch: [limit: 100, path: ".device_ids"]
The key defaults to hashing everything *except* the batch path, so
enqueues alike in every respect but what they contribute belong to
the same batch — no key needs naming. `:dedup` and `:sorted` fold
through jq's `unique` and `sort`. `:key`, `:when` and `:fold` can
still be written by hand for folds the template does not cover.
### Fixed
- **An enqueue that matched an existing job crashed instead of
returning it.** The server answers 200 rather than 201 when nothing
new was created — a unique job already queued, or one folded into a
batch — and `Zizq.enqueue/2` treated only 201 as success. The 200
fell through to the error path, where the status mapping had no
clause for it and raised `FunctionClauseError`. Both statuses are
now success, and the returned job carries `:duplicate` or `:folded`.
`Zizq.enqueue_all/2` was already correct.
- **An unrecognised status now arrives as an error rather than a
crash.** `Zizq.Error` gains an `:unexpected_status` reason, so a
status an endpoint does not know how to read still surfaces as a
`%Zizq.Error{}` naming it, which is the whole point of there being
one error type.
## 0.6.0-alpha.4
Adds job consumption: a supervised worker, the `/jobs/take` stream and
the acknowledgement endpoints underneath it. The query and cron
endpoints are still to come.
### Added
- **`Zizq.Worker`.** Takes jobs from one or more queues and runs them.
Add one to your supervision tree alongside a client:
children = [
{Zizq, name: MyApp.Zizq, url: "http://localhost:7890"},
{Zizq.Worker,
client: MyApp.Zizq,
queues: ["emails"],
concurrency: 25,
handler: &MyApp.handle_job/1}
]
The order matters: children are stopped in reverse, so the worker
drains while the client it acknowledges through is still alive.
Each job runs in its own supervised task. A handler that raises,
exits or is killed outright is reported to the server as a failure
with its exception and stacktrace, and cannot take the worker down;
a slow one cannot block the stream from being read. What a handler
returns decides the job's fate — `:ok` or `{:ok, term}` completes
it, `{:error, reason}` fails it for retry under the backoff policy,
`{:cancel, reason}` kills it whatever attempts remain, and
`{:snooze, milliseconds}` or `{:snooze, %DateTime{}}` defers it.
Anything else is acknowledged as complete and logged as a warning
naming what came back, since a handler that ended on the wrong value
has most likely already done its work.
**Snooze durations are milliseconds**, as every duration in this
client is.
On shutdown the worker stops taking new work, lets running jobs
finish, flushes outstanding acknowledgements, and only then closes
the connection — in that order, so the server keeps the in-flight
jobs until the acknowledgements arrive. `:drain_timeout` (30s by
default) is the whole budget rather than a per-step one, so it can be
set from whatever deadline a deployment or orchestrator imposes;
anything still running when it expires is abandoned and redelivered.
- **`Zizq.report_success/2`, `Zizq.report_success_all/2` and
`Zizq.report_failure/3`.** Acknowledge jobs directly, for consumers
not built on `Zizq.Worker`. `report_failure/3` takes `:message`
(required), `:error_type`, `:backtrace`, `:kill` to declare a job
dead now, and `:retry_at` to reschedule it bypassing the backoff
policy.
- **`:stream_idle_timeout` client option**, 30s by default. How long a
streaming connection may go without any data before it is treated as
dead and reconnected. The server heartbeats every three seconds by
default, so raise this if yours is configured to heartbeat less
often.
## 0.6.0-alpha.3
Adds bulk enqueue. Consuming jobs is still to come, as are the query
and cron endpoints.
### Changed
- **Breaking: `enqueue/2`, `enqueue!/2`, `enqueue_all/2` and
`enqueue_all!/2` now take the job first and the client second.**
# before
Zizq.enqueue(MyApp.Zizq, type: "send_email")
# now
Zizq.Enqueue.new!(type: "send_email") |> Zizq.enqueue(MyApp.Zizq)
`|>` passes its left-hand value as the first argument, so the old
order could not be piped without wrapping every call in `then/2`.
Elixir's convention is that the subject comes first, and here the
subject is the job — which matters more once job modules are
building enqueues and pipelines become the normal way to write them.
- **`:unique_key` and `:batch` together are now rejected locally.** The
server refuses the combination on both endpoints; catching it in
`Zizq.Enqueue` turns a round trip into an immediate error at the call
site that got it wrong.
### Added
- **`Zizq.enqueue_all/2` and `Zizq.enqueue_all!/2`.** Enqueue many jobs
in a single request:
users
|> Enum.map(&Zizq.Enqueue.new!(type: "send_email", payload: %{"user_id" => &1.id}))
|> Zizq.enqueue_all(MyApp.Zizq)
Elements may be `Zizq.Enqueue` structs, keyword lists or maps, mixed
freely. Jobs are returned in the order they were sent, and an empty
list short-circuits immediately without contacting the server.
## 0.6.0-alpha.2
First release with a usable API. Jobs can be enqueued; consuming them
cannot — the take stream and worker are still to come, as are the
query, cron and bulk endpoints.
### Added
- **`Zizq` client supervisor.** Add one to your application's
supervision tree and refer to it by name:
children = [
{Zizq, name: MyApp.Zizq, url: "http://localhost:7890"}
]
Several named clients can run side by side. API functions are
stateless — they resolve configuration by name and issue the request
directly, so no Zizq process sits in the request path. Options are
validated at startup, so a malformed URL or unknown format fails at
boot rather than on the first request.
- **`Zizq.enqueue/2` and `Zizq.enqueue!/2`.** Accepts a keyword list,
a map, or a `Zizq.Enqueue` struct. Only `:type` is required;
`:queue` defaults to `"default"`, and anything left unset is omitted
from the request so the server's own defaults apply and keep
tracking its configuration. Unknown keys are rejected rather than
ignored, so a typo cannot quietly enqueue the wrong job.
- **`Zizq.Job`.** The job record the server returns. Timestamps are
`DateTime` in UTC; status is an atom (`:ready`, `:in_flight`, and so
on). A status an older client does not recognise is preserved as a
string rather than raising.
- **`Zizq.Backoff`, `Zizq.Retention`, `Zizq.BatchConfig`.** Per-job
policy overrides, accepted as keyword lists or structs. Durations
are integer milliseconds throughout, so `:timer.seconds/1` and
friends can be used directly.
- **`Zizq.Codec` behaviour, with `Zizq.Codec.JSON` and
`Zizq.Codec.MessagePack`.** MessagePack is the default. The two are
interchangeable on every endpoint, so a producer and a consumer need
not agree on a format, or even be written in the same language.
Select with `format: :json`.
- **`Zizq.Error`.** One error type for every failure, carrying a
`:reason` atom, the HTTP status and body where there was one, and
the underlying exception behind transport and codec failures.
`Zizq.Error.retryable?/1` says whether retrying could plausibly
succeed.
- **`Zizq.server_version/1`.** Reports the server's version; the
cheapest way to confirm a connection works end to end.
### Notes
- Request and response traffic uses HTTP/2 over cleartext (h2c) with
connection multiplexing. The forthcoming take stream will use
HTTP/1.1 instead, where HTTP/2 framing overhead makes it slower.
- Requires Elixir 1.18 or later and Erlang/OTP 27 or later.
- Pre-release versions are opt-in: a requirement must name one (for
example `~> 0.6.0-alpha`) to resolve them.
## 0.6.0-alpha.1
Package skeleton only. No client API yet. Published purely to exercise
the release pipeline.