Runtime Workers Reference
Source files:Defines the qtg v3 background worker architecture. Whensrc/qtg/interfaces/workers/,src/qtg/application/services/dispatch.py,observe.py,recover.py
MG_WORKERS_ENABLED=true, the runtime supervises 8 always-on workers plus 9 conditional workers (each gated by its own flag) that handle DAG execution, observation, recovery, callbacks, expiration, template-proposal expiry, audit promotion, health checks, nonce/stranded-message reaping, capital-transfer follow-up, balance snapshots, and registry/CCIP drift detection.
Worker Architecture
loop_runner pattern
All workers run through the sharedrun_loop() runner.
- Calls
func(session)in an infinite loop - The function returns the number of items processed (int)
- On success the
consecutive_errorscounter is reset - Waits for
interval + random.uniform(-jitter, jitter)seconds (minimum 0.1s) - On exception, increments
consecutive_errors
CancelledError: worker terminates (shutdown)- Other exceptions: log and continue execution
- Consecutive errors
>= MAX_CONSECUTIVE_ERRORS(5): exponential backoff applied
AsyncSessionLocal() on every loop iteration and auto-released when the block exits.
Worker registration / supervision
WhenMG_WORKERS_ENABLED=true, the lifespan() context manager in main.py runs a single supervision task via run_worker_groups().
Always-on vs conditional
8 always-on (run wheneverMG_WORKERS_ENABLED=true):
node_dispatcher,node_observer,node_recovery(core group)callback_dispatcher,expiration_checker,template_proposal_expiration,executor_health,audit_outbox_promoter(sidecar group)
Supervision semantics
core groupnode_dispatchernode_observernode_recoveryevm_nonce_reaper(when enabled — joins the core fail-fast group)ccip_stranded_reaper(when enabled — joins the core fail-fast group)
sidecar groupcallback_dispatcherexpiration_checkertemplate_proposal_expirationexecutor_healthaudit_outbox_promotercapital_transfer_followup/balance_snapshot/ccip_drift/verify_drift(when enabled)
- If a core worker exits abnormally outside its loop, the entire core group fails fast. The two conditional reapers (
evm_nonce_reaper,ccip_stranded_reaper) deliberately join this core group so their failure is treated as fatal, not silently swallowed. - If a sidecar worker crashes, it is logged and the remaining sidecar/core workers continue.
- On app shutdown, the supervision task is cancelled and waits for a clean shutdown.
- Retry / backoff / per-iteration error handling inside
run_loop()is preserved as-is.
Worker summary table
1. node_dispatcher
Interval: 2 seconds (MG_NODE_DISPATCH_INTERVAL_SECONDS)
Finds nodes in the READY state and runs the preflight -> prepare -> (sign) -> submit pipeline. Processes one node at a time.
Lookup query (per-candidate claim)
Productiondispatch_ready_nodes() no longer uses a single multi-candidate
FOR UPDATE SKIP LOCKED join. That shape was shown to retain a
speculative movement_request_nodes lock after skipping a request-side miss
while claiming a later FIFO candidate in the same statement.
Claim path:
- Walk unlocked FIFO candidate IDs one at a time (no
FOR UPDATEon the listing query; exclude already-tried IDs after a miss). - For each candidate, run a per-ID joined claim under a miss-only savepoint:
tests/qtg/application/test_dispatch_pickup_concurrency.py.
Observer↔recovery request-lock ownership is tracked as separate residual work.
SKIP LOCKED: prevents duplicate processing in multi-instance environments- FIFO order: older requests come first, ordered by
created_at
Dispatch pipeline
ExecutionContext construction
Thebuild_execution_context() function assembles the context required to execute a node.
provider_context is a dict that flat-merges the provider_refs of the current node’s direct-predecessor nodes in the COMPLETED state. Through this, successor nodes can reference predecessor txids, withdrawal_ids, etc.
node_config is the result of resolving the template’s top-level $ref:node_key.field expressions before handing it to the executor. At runtime a synthetic namespaced ref map is built from every completed node’s provider_refs, but those values are used only for $ref resolution and are not placed in provider_context. Therefore the executor still sees a flat provider_context as before.
If a $ref cannot be resolved during build_execution_context(), ConfigRefResolutionError is raised and the dispatcher transitions the node to FAILED, recording error_code = CONFIG_REF_UNRESOLVED.
SignRequest construction
This is the request passed to the signer for nodes that require signing.2. node_observer
Interval: 5 seconds (MG_NODE_OBSERVE_INTERVAL_SECONDS)
Polls nodes in the SUBMITTED or OBSERVING state to detect state changes. Processes up to 10 nodes per pass.
Lookup query (per-node claim)
The observer works in two phases so a slow provider observation on one node never holds a lock that blocks the rest of the batch: 1. Advisory, unlocked candidate snapshot — list up to 10 due candidate IDs. No rows are locked here.FOR UPDATE ... SKIP LOCKED (locking both rows), still filtered on
the same due predicate so a node another worker already advanced is not
re-claimed.
SKIP LOCKED rejects an already-locked request side, so a miss rolls
the savepoint back and releases those partial locks. A hit keeps the node +
request locks for the whole observe lifetime (through provider observation and
commit).
Observation interval control
next_observe_at is maintained per node to prevent unnecessary polling.
DEFAULT_OBSERVE_INTERVALS):
If the executor returns
retry_after_seconds, that value takes precedence.
Observation timeout enforcement
timeout_policy.observe_seconds is enforced only when MG_OBSERVE_TIMEOUT_ENFORCEMENT_ENABLED=true; the setting defaults to false. When a due node has exceeded its positive observe_seconds since started_at, the observer does not call the executor. It sets the node to UNKNOWN(error_code=OBSERVATION_TIMEOUT), then normal request-state derivation escalates a lane with a completed side effect to MANUAL_INTERVENTION and enqueues the existing request_attention callback. The timeout never marks the node FAILED; its reservation remains preserved for operator handling.
The check is intentionally after the next_observe_at gate. A timeout can therefore be detected up to one backoff interval late; retry hints are clamped to MAX_BACKOFF_SECONDS=600, so the bounded delay is at most 600 seconds. This is acceptable for a 1800–3600-second paging signal, not a precise real-time deadline.
timeout_policy is not included in the graph hash or compiled-plan hash. A post-approval DB edit to timeout_policy.observe_seconds therefore does not trigger hash-drift detection. Its blast radius is paging timing only, but this is still an operational limitation: audit DB template observe_seconds values before globally enabling enforcement. Existing seed templates already declare observe_seconds, so enabling the flag without that audit can activate timeouts on existing live lanes.
Observe pipeline
advance_after_completion() details
When a node reaches COMPLETED, successor nodes are activated along the DAG.derive_request_state() details
Aggregates all node states to derive the request state.3. node_recovery
Interval: 60 seconds (MG_NODE_RECOVERY_INTERVAL_SECONDS)
Recovers nodes in UNKNOWN, plus nodes stuck in SUBMITTING past their lane’s grace
cutoff, via the executor’s recover() method. The batch is MG_NODE_RECOVERY_BATCH_SIZE
(default 10) split across four arms in priority order — CCIP, Stargate, CEX, then everything
else — each taking max(1, batch // 4). Keep the batch at 4 or more, or the later arms
starve.
Lookup query
Each arm selects its own lane, ordered bymovement_request_nodes.updated_at ASC with
FOR UPDATE ... SKIP LOCKED, and every arm requires movement_requests.request_state = 'EXECUTING'.
Recovery pipeline
UNKNOWN is the state of “we sent an execution request but cannot determine the result”. The executor’srecover() method queries the provider to determine the actual state.
4. callback_dispatcher
Interval: 3 seconds (MG_CALLBACK_DISPATCH_INTERVAL_SECONDS)
Pulls callbacks in the pending state from the callback outbox and sends them via HTTP POST. Processes up to 5 per pass.
Lookup query
Send flow
Callback authentication (v3)
The body contains the pure payload; auth metadata is passed via headers. Headers:X-QTG-Callback-TimestampX-QTG-Callback-NonceX-QTG-Callback-Signature-Version: v3X-QTG-Callback-Signature
MovementCallbackOutbox reuses the same callback_timestamp / callback_nonce on retries. As a result the receiver can make a deterministic replay decision for the same callback delivery attempt.
Retry backoff
On a failed delivery,next_attempt_at is set to exponential backoff: now + 2 ** min(attempts, 6) seconds — i.e. 2, 4, 8, 16, 32, then capped at 64s for all later attempts. (This is not a flat 5s retry.) When attempts reaches max_attempts (default 5, MG_CALLBACK_MAX_ATTEMPTS), the row transitions to dlq.
5. expiration_checker
Interval: 30 seconds (MG_EXPIRATION_CHECK_INTERVAL_SECONDS)
Processes expiration of requests in PENDING_APPROVAL whose expires_at has passed. Processes up to 20 per pass.
Lookup query
Processing flow
The approval TTL isMG_APPROVAL_TTL_SECONDS (default 300 seconds = 5 minutes).
6. executor_health
Interval: 60 seconds (MG_EXECUTOR_HEALTH_INTERVAL_SECONDS)
Calls the health() method on every in-memory registered executor and signer and persists health state to the DB.
Processing flow
If the executor/signer does not implementhealth(), health_state = 'unknown' is preserved.
For signer identity, this worker is the sole writer of observed_signer_address: it validates a nonzero EVM health().signer_address value, stores the canonical lowercase observation, and clears the observation when the runtime signer disappears and the DB row becomes orphan. It never writes expected_signer_address; the admin enrollment endpoint PUT /v3/signers/{signer_key}/expected-identity is the sole expected-identity writer. The worker records health separately from identity posture, which is derived when the roster is read.
The signer-identity contract deliberately stops at this expected-versus-observed registry evidence. It does not enforce movement-time or rotation-time drift; that enforcement is a separate follow-up.
7. balance_snapshot (optional)
Interval: 30 seconds (MG_BALANCE_SNAPSHOT_INTERVAL_SECONDS)
Activation conditions:
MG_WORKERS_ENABLED=trueMG_BALANCE_SNAPSHOT_ENABLED=true
balance_fetcher_registry and stores per-venue balances append-only in the balance_snapshots table. The current default implementation is GatewayBalanceFetcher, which records the unified USDC balance read from the Gateway API as a single venue_key="gateway" snapshot.
Processing flow
Operational meaning
- Baseline data source for
GET /v3/balances - Uses the same append-only schema as
fresh=trueon-demand fetches - Stale judgment is computed at lookup time (
balance_stale_threshold_seconds); the worker is responsible for snapshot storage and retention
8. template_proposal_expiration (always-on)
Interval: 30 seconds (MG_TEMPLATE_PROPOSAL_EXPIRATION_INTERVAL_SECONDS)
Expires stale template_proposals rows whose review/approval window has elapsed (expire_template_proposals). Keeps the proposal queue clean so abandoned proposals do not linger as actionable.
9. audit_outbox_promoter (always-on)
Interval: 1 second Copies rows from theaudit_outbox table into audit_events on a tight tick so forensic queries see successful mutations promptly. This is the read-side projection half of the audit outbox pattern: mutating routes write to audit_outbox transactionally, and this worker promotes them to the durable audit_events log.
10. evm_nonce_reaper (conditional, core)
Flag:MG_EVM_NONCE_REAPER_ENABLED (default True)
Interval: 60 seconds (MG_EVM_NONCE_REAPER_INTERVAL_SECONDS)
Reclaims stale EVM nonce reservations (evm_nonce_reservation) older than the age threshold so a worker that died mid-broadcast does not permanently strand a nonce. It is registered only when an EVM RPC client and EVM endpoints are available; otherwise a warning is logged and the worker is skipped. When enabled it joins the core fail-fast group — its failure is treated as fatal to the core group, not silently logged.
11. ccip_stranded_reaper (conditional, core)
Flag:MG_CCIP_STRANDED_REAPER_ENABLED (default True)
Interval: 60 seconds (MG_CCIP_STRANDED_REAPER_INTERVAL_SECONDS)
Reaps stranded CCIP send intents (ccip_send_intent) that have aged past the threshold without resolving. Like the nonce reaper, it joins the core fail-fast group when enabled.
12. capital_transfer_followup (conditional, sidecar)
Flag:MG_CAPITAL_TRANSFER_FOLLOWUP_ENABLED
Interval: 60 seconds (MG_CAPITAL_TRANSFER_FOLLOWUP_INTERVAL_SECONDS)
Follows up on pending capital_transfer_requests (follow_up_pending_capital_transfers) that have gone stale, advancing or surfacing them for operator attention.
13. verify_drift (conditional, sidecar)
Flag:MG_VERIFY_DRIFT_ENABLED
Interval: 21600 seconds / 6 hours (MG_VERIFY_DRIFT_INTERVAL_SECONDS)
Re-verifies on-chain registry baselines against configured values on a slow cadence, flagging drift between what is registered and what the chain reports. Runs per-chain with bounded RPC concurrency and per-lane timeouts.
14. ccip_drift (conditional, sidecar)
Flag:MG_CCIP_ENABLED (and a configured CCIP sidecar client)
Interval: configurable (MG_CCIP_DRIFT_POLL_INTERVAL_SECONDS)
Polls the CCIP sidecar for registry drift (run_drift_worker). Active only when CCIP is enabled and the sidecar client is available.
Worker heartbeats
Theworker_heartbeats table records liveness for the supervised workers. Each process seeds heartbeat rows for its selected network class at startup (iterate_per_network(_seed_worker_heartbeats)), so separate network deployments keep separate heartbeat baselines. This gives operators a DB-visible signal of which workers are expected to be running.
State Progression Flow Through Workers
The ASCII diagram below shows the full flow of one movement request from creation through completion, progressing through the workers.Concurrency guarantees
skip_locked
Each worker usesFOR UPDATE SKIP LOCKED to claim rows so no two workers process the same node.
FOR UPDATE OF ... SKIP LOCKED inside a savepoint, so one node’s long provider observation never holds a lock that blocks the rest of the batch.
In an environment with multiple worker instances (or concurrent execution within the same process) this means:
- Rows already locked by another transaction are skipped
- Concurrent processing is safe without deadlocks
- The same node/request is not processed twice
Limits
Per-worker batch sizes:Ordering basis
Worker interaction
- API approve -> frontier nodes transition to
READY - dispatcher -> drives
READYnodes through toSUBMITTED - observer -> polls
SUBMITTED/OBSERVINGnodes, transitions them toCOMPLETEDorUNKNOWN - observer (advance) -> activates successor nodes of completed nodes to
READY, which the dispatcher then picks up - recovery -> transitions
UNKNOWNnodes toSUBMITTED(re-observed by observer) orFAILED - callback_dispatcher -> sends callbacks generated by every state change to external systems
Related documents
- Data Model Reference — table/state enumeration details
- Bootstrap & Configuration — worker interval settings, executor registration
- V3 API Endpoints — approve/reject/resume/retry/cancel endpoints