Docs / Guides
Architecture
How a job actually gets from EnqueueAsync to running code, and how Axon keeps
that safe with three servers instead of one.
Job dispatch
IAxonClient.EnqueueAsyncbuilds aJobInfo(method reference + JSON arguments) and sends it toAxon.Server, which stores it withState = Enqueued.AxonJobProcessor, a background service, polls the store every 5 seconds for due jobs.- For each due job, it calls
TryClaimJob(jobId, deadline)— an atomic conditional update:Stateflips fromEnqueued/ScheduledtoProcessingonly if it's still in one of those states. This runs before dispatch, not after: a fast client ack racing ahead of the claim could otherwise clobber an already-Succeededjob back toProcessing. - If the claim succeeds, the job is pushed to the target device over its SignalR connection. If it fails — another instance already claimed it — this instance backs off silently.
- The client executes the method body and calls back
OnSuccess/OnFail.
Multi-instance dispatch safety
Every Axon.Server instance polls independently against the same shared store.
When two instances see the same due job in the same poll cycle, TryClaimJob's
single atomic statement guarantees only one of them affects a row:
UPDATE Jobs
SET State = @Processing, ProcessingDeadline = @deadline
WHERE JobId = @id AND State IN (@Enqueued, @Scheduled)
-- 1 row affected → this instance dispatches
-- 0 rows affected → another instance won; back off
One instance's statement matches and affects a row; the other's matches zero rows and must
not dispatch. Each storage backend enforces the same guarantee for the additional
ConcurrencyKey/MaxConcurrent check with its own locking primitive:
- SQL Server —
WITH (UPDLOCK, HOLDLOCK)on the count subquery holds the scanned key-range's locks for the rest of the transaction. - PostgreSQL —
pg_advisory_xact_lockkeyed on theConcurrencyKey. A plainSELECT ... FOR UPDATEdoesn't work here: when several claims race to be the first job with a key to becomeProcessing, there's no existing row yet to lock. - MySQL — a plain
SELECT ... FOR UPDATE, relying on InnoDB'sREPEATABLE READnext-key locking to cover the same not-yet-existing-row case Postgres needs an advisory lock for. - MongoDB — a dedicated lock collection, written to by every claim against a key, so MongoDB's per-document write-conflict detection actually catches the race (two claims that only read the same count and write to different job documents wouldn't otherwise collide).
- SQLite — no per-key locking:
BEGIN IMMEDIATEtakes the single process-wide write lock for the whole transaction, which is also why SQLite doesn't support the multi-instance scenario at all.
Job priority
Dispatch order isn't raw age — it's age − boost[priority], where
boost is a fixed per-priority offset (Low 0, Medium
5 min, High 15 min, Critical 60 min). See
Priority and queues for the full
mechanics and a worked example.
Orphan reclaim
A job dispatched but never acknowledged (client crashed, disconnected, or hung) doesn't stay
Processing forever. Two independent paths reclaim it:
- Disconnect-triggered (fast) — when a client's SignalR connection drops, the server immediately reclaims every job that device was
Processing. - Deadline sweep (crash-safe) —
AxonJobProcessor's poll loop also checks for anyProcessingjob past itsProcessingDeadline, regardless of whether a clean disconnect ever happened. This is what catches a client that died without closing its connection.
Either path pushes the job back to Scheduled for retry (or Failed,
if retries are exhausted) using the same backoff/retry-policy logic as a job that failed
normally.
Job state machine
Enqueued ──────┐
├──> Processing ──> Succeeded
Scheduled ─────┘ │
└──> Failed ──> Scheduled (retry) / Failed (exhausted)
AwaitingParent ──> Enqueued (parent succeeded)
└──> Skipped (parent failed, ContinueOnParentFailure = false)
A continuation job (ContinueWithAsync) starts in AwaitingParent,
not Enqueued — the poll loop never dispatches it until its parent reaches a
terminal state.