Operator API for replay jobs (Ankusa.Dispatch.Replayer).
A replay re-sends dead delivery rows (kind: :dlq) or archived hooks over a
time window (kind: :archive) through the normal dispatch pipeline, keeping
each hook's original id and dedupe_key and stamping the delivery with the
job's replay_id. A third kind, kind: :quarantine, re-verifies hooks held
in the quarantine pen (Ankusa.Edge.Quarantine) against the source's
current verifier and commits the ones that now pass, as new hooks with their
original id; the rest stay in the pen. Every kind only ever uses dispatch
capacity live traffic leaves free: rows are dripped in at rate items per
second, and only while the Pipeline's oldest-due lag is at most max_lag_ms
and its in-flight window is not full.
A fourth kind, kind: :lifecycle, re-emits lifecycle events
(Ankusa.Lifecycle) from the audit trail over a since/until window of
the change's time, optionally for some tenants (route events belong to
"default"). It re-delivers to the node's lifecycle sinks through the
lifecycle publisher, not through dispatch: each event keeps the id it was
first emitted with (the id is derived from the audit row) and carries the
job's id as replayid, so a consumer that dedupes on id drops what it
already has. rate is rows per second; max_lag_ms is not used.
Jobs are durable and resumable: every job is a store record, its cursor is committed in the same batch as the rows it moved, and a restart resumes from the last committed page. A job that keeps dead-lettering its deliveries pauses itself (auto-pause) instead of feeding a sink that is still down.
Runbook for a Salesforce-sized incident
- One job per node — a fleet replay is one
POST /v1/replaysper node, with the samejob_id:docker-entrypoint replay --nodes … --filter …(the server image) does that and aggregates the status. - Start low:
rate: 100or so, and watch the node's metrics. - PATCH
rateupward while live latency stays flat. The replay takes only spare capacity, so it is always safe to leave running; the only knob is how long it takes. - A
kind: :dlqjob only touches rows dead-lettered at or before its own creation, so rows that die again during the replay are never picked up a second time. To re-send those, start another job. - A
kind: :archivejob covers only what the compactor has already archived:tomust be at leaststorage.roll_ms + storage.interval_msin the past. - A
kind: :quarantinejob, like:dlq, only touches entries quarantined at or before its own creation. Fix the source's secret first (or add the new one:secret: [new, old]), then start the job; an entry that still fails verification stays in the pen and counts asskipped. - A
kind: :lifecyclejob covers the events the trail held when it was created, insince/until. It is the way back for a lifecycle sink that was down for longer thandispatch.retrywaits, or that lost events: start it at the time the outage began.
Summary
Functions
One job by id; a job outside tenants (see list/2) is :not_found.
Every job, newest first. tenants limits the list to the jobs scoped to
tenants inside that list (nil is every job, including unscoped ones).
The earliest key (:since of a quarantine job, :from of an archive job)
among the live (running or paused) jobs of kind: what retention must not
delete past, because a job still reads from there. A job without the key needs
everything (0). :infinity when this node runs no replayer, so no job can
exist; {:error, :store_unavailable} when the replayer cannot say, and
retention then deletes nothing.
Create a replay job. spec (map or keyword) takes the admin API's keys as
atoms: for kind: :dlq and kind: :quarantine the optional :source_id,
:id, :since, :until; for kind: :dlq also :sink (the sink's index in
the source) and :error_class (Ankusa.Sink.error_class/0: only rows
dead-lettered as that class are revived); for kind: :archive the required
:from, :to, and the optional :source_id and :sinks; for
kind: :lifecycle the optional :since and :until; plus :rate and
:max_lag_ms. Every kind also takes :tenants, a non-empty list of tenant
ids: the job only touches hooks (a lifecycle job, events) owned by those
tenants (the admin API sets it from the caller's tenant: scopes), and
:job_id, a lowercase UUID that names the job: starting again with the same
:job_id, kind and filter answers :existing whatever the job's state, which
is how one job is fanned out to every node of a fleet and started again after
a failure (docker-entrypoint replay).
The admin API's Replay object for a job map (atoms to strings).
Patch a job: :state (:running | :paused | :cancelled), :rate and/or
:max_lag_ms. {:error, :finished} for a done, cancelled or failed
job; a job outside the tenants: option (see list/2) is :not_found. Resuming clears
error and resets the auto-pause window.
Functions
@spec get(atom(), String.t(), [String.t()] | nil) :: {:ok, map()} | {:error, :not_found | :store_unavailable}
One job by id; a job outside tenants (see list/2) is :not_found.
Every job, newest first. tenants limits the list to the jobs scoped to
tenants inside that list (nil is every job, including unscoped ones).
@spec live_floor(atom(), :quarantine | :archive, :since | :from) :: {:ok, non_neg_integer() | :infinity} | {:error, :store_unavailable}
The earliest key (:since of a quarantine job, :from of an archive job)
among the live (running or paused) jobs of kind: what retention must not
delete past, because a job still reads from there. A job without the key needs
everything (0). :infinity when this node runs no replayer, so no job can
exist; {:error, :store_unavailable} when the replayer cannot say, and
retention then deletes nothing.
@spec start(atom(), map() | keyword(), keyword()) :: {:ok, :created | :existing, map()} | {:error, {:invalid, String.t()} | :too_many_replays | {:role_not_enabled, atom()} | :store_unavailable}
Create a replay job. spec (map or keyword) takes the admin API's keys as
atoms: for kind: :dlq and kind: :quarantine the optional :source_id,
:id, :since, :until; for kind: :dlq also :sink (the sink's index in
the source) and :error_class (Ankusa.Sink.error_class/0: only rows
dead-lettered as that class are revived); for kind: :archive the required
:from, :to, and the optional :source_id and :sinks; for
kind: :lifecycle the optional :since and :until; plus :rate and
:max_lag_ms. Every kind also takes :tenants, a non-empty list of tenant
ids: the job only touches hooks (a lifecycle job, events) owned by those
tenants (the admin API sets it from the caller's tenant: scopes), and
:job_id, a lowercase UUID that names the job: starting again with the same
:job_id, kind and filter answers :existing whatever the job's state, which
is how one job is fanned out to every node of a fleet and started again after
a failure (docker-entrypoint replay).
Returns {:ok, :created, job}, or {:ok, :existing, job} when a running
or paused job with the same kind and filter already exists (a proxy retry
is idempotent). {:error, {:invalid, field}} names a bad field ("job_id"
also when that id belongs to a job with another kind or filter),
{:error, :too_many_replays} the 16-job cap, and
{:error, {:role_not_enabled, :edge}} an archive or quarantine job on a
node without a queue writer ({:role_not_enabled, :lifecycle}: a lifecycle
job on a node with no lifecycle sinks).
The admin API's Replay object for a job map (atoms to strings).
@spec update(atom(), String.t(), map() | keyword(), keyword()) :: {:ok, map()} | {:error, :not_found | :finished | {:invalid, String.t()} | :store_unavailable}
Patch a job: :state (:running | :paused | :cancelled), :rate and/or
:max_lag_ms. {:error, :finished} for a done, cancelled or failed
job; a job outside the tenants: option (see list/2) is :not_found. Resuming clears
error and resets the auto-pause window.