Axon Docs
GitHub Quickstart

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

  1. IAxonClient.EnqueueAsync builds a JobInfo (method reference + JSON arguments) and sends it to Axon.Server, which stores it with State = Enqueued.
  2. AxonJobProcessor, a background service, polls the store every 5 seconds for due jobs.
  3. For each due job, it calls TryClaimJob(jobId, deadline) — an atomic conditional update: State flips from Enqueued/Scheduled to Processing only 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-Succeeded job back to Processing.
  4. 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.
  5. 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:

TryClaimJob.sql
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:

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:

  1. Disconnect-triggered (fast) — when a client's SignalR connection drops, the server immediately reclaims every job that device was Processing.
  2. Deadline sweep (crash-safe)AxonJobProcessor's poll loop also checks for any Processing job past its ProcessingDeadline, 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

states
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.