Skip to main content

9. Workers

Role in the architecture

The engine runs inside Azure with one identity and a fixed set of activity types. Workers are the part of the architecture that runs outside those limits. A worker is a service that implements one or more step types and runs wherever the step needs to run: in Container Apps, on a VM, or on an on-premises server. Workers are built and maintained by the platform team. A workflow uses one by naming its step type, for example parse_pdf. The definition carries no information about where the step runs.

A worker:

  1. Polls the Task API for tasks of the types it implements.
  2. Performs the task.
  3. Posts the result to the Task API.

The engine does not call the worker. The worker calls the engine, and requires only outbound HTTPS to the gateway and an identity. A step can therefore run in a location the engine cannot reach, using a credential the engine does not hold.

Workers are grouped by code area rather than by workflow. An illustrative grouping:

WorkerExample step typesReason for a worker
Document workerparse_pdf, extract_excel, classify_documentExtraction rules specific to the organisation, with no API
Data platform workerreconcile_ledger, bulk_exportRuns for minutes over the data platform
Connector workerfetch_bank_feed, lodge_returnThe credential must stay in its own service

When a step becomes a worker step

A step is an HTTP, CALL_TOOL, Logic Apps connector, or Document Intelligence call whenever it can be. It becomes a worker step type only when one of these holds:

ConditionExample
No API exists for the logicSpreadsheet extraction with organisation-specific rules
The system cannot be reached from AzureAn on-premises file share or desktop-only software
The step runs for minutesA month-end reconciliation across all clients
The credential must not be held by the engineA bank connection whose secret stays in its own service

A step that only calls an HTTP API is an HTTP task, not a worker. A step that contains business rules is a tool on the action service, so the rule exists once and every caller shares it.

Task definition

Every worker task type needs a task definition, registered through POST /api/metadata/taskdefs:

{ "name": "parse_pdf",
"timeoutSeconds": 300, "responseTimeoutSeconds": 120, "pollTimeoutSeconds": 3600,
"timeoutPolicy": "RETRY",
"retryCount": 2, "retryLogic": "EXPONENTIAL_BACKOFF", "retryDelaySeconds": 30,
"concurrentExecLimit": 10,
"rateLimitPerFrequency": 100, "rateLimitFrequencyInSeconds": 60,
"inputSchema": { }, "outputSchema": { } }
FieldMeaning
timeoutSecondsFrom queueing to completion.
responseTimeoutSecondsFrom a worker taking the task to posting a result. Also the queue lock duration.
pollTimeoutSecondsFrom queueing to the first poll. Fires when no worker is running.
totalTimeoutSecondsAcross all attempts.
timeoutPolicyRETRY (try again if attempts remain), TIME_OUT_WF (fail the run), ALERT_ONLY (record a metric, keep waiting).
retryCount, retryLogic, retryDelaySeconds, backoffScaleFactorThe retry schedule. Logic: FIXED, LINEAR_BACKOFF, EXPONENTIAL_BACKOFF.
concurrentExecLimitMaximum tasks of this type in progress at once.
rateLimitPerFrequency, rateLimitFrequencyInSecondsMaximum tasks handed out per window.
inputSchema, outputSchemaJSON Schema, checked when set.

Queues

Each worker task type has a Service Bus queue named after it. The pipeline creates the queue when the task definition is registered. When the interpreter reaches a WORKER task, it sends a message {taskId, workflowId, taskRef, input, attempt} to that queue and waits.

SituationWhat Service Bus does
A worker takes a taskThe message is locked for responseTimeoutSeconds. Other workers cannot see it.
The worker posts a resultThe message is completed and removed.
The worker dies mid-taskThe lock expires and the message returns to the queue.
A retry with delay is neededThe message is re-sent with a scheduled delivery time.
Retries are exhaustedThe message moves to the dead-letter queue. The engine marks the task FAILED.
A worker asks to be called back later (callbackAfterSeconds)The message is re-sent with that delay.
A start request maps the type to a domainThe message goes to queue {domain}-{type} instead. Workers polling with that domain take it.
The run has high priorityThe message goes to queue {type}-high. Workers poll it first.

The Task API enforces concurrentExecLimit and rate limits when handing out tasks: at a limit, it returns the message to the queue with a 60-second delay.

Worker loop

forever:
tasks = GET /api/tasks/poll/batch/{type}?count={free slots}&timeout=5000
if none: wait (1 ms, doubling up to the poll interval); continue
for each task:
result = handler(task.inputData)
POST /api/tasks {taskId, workflowId, status: COMPLETED, outputData: result}

Two packages implement the loop; a worker adds only the handler per step type:

C#:

[WorkerTask("parse_pdf")]
public ParseResult ParsePdf(ParseInput input) => ...;
// WorkerHost.Run() polls forever

Python:

@worker_task("parse_pdf")
def parse_pdf(url: str) -> dict:
return {"text": ..., "pages": ...}
run() # polls forever

Both packages support a thread count, a poll interval, callbackAfterSeconds, and posting log lines. A Functions app with a Service Bus trigger is an alternative worker shape with the same result contract.

Hosting

Default: Azure Container Apps, one app per code area, not per task type. A document worker, for example, would implement parse_pdf, extract_excel, and classify_document in one app. The app runs at least two replicas and scales on queue depth, down to zero when idle. Container Apps Jobs host batch workers that run to completion.

Any other host works if it has outbound HTTPS and an identity with the worker role listing its task types (page 15).

Liveness

Every poll is recorded with the worker id and time. GET /api/tasks/queue/polldata?taskType= shows the last poll per type. A type with no poll in the last 10 seconds is reported as having no active worker, and the engine records a warning when it queues work for that type.