Execution Engine
The apptor flow execution engine is the core of the platform. It is built on a custom actor framework and is responsible for managing workflow execution from start to finish.
Design Principles
- Each node type has its own actor — isolation prevents slow nodes from blocking others
- Execution is asynchronous — the API returns immediately; execution continues in the background
- State is fully persisted — every node instance and its variables are written to PostgreSQL
- The engine is restartable — if the server restarts mid-execution, pending queue items are replayed from the
queue_itemtable - Thread pools are configurable —
flow-config.jsoncontrols threads per actor type
Actor Model
When the engine needs to execute a node, it calls ActorFactoryImpl.createActor(nodeType). This returns the actor for that node type. The actor enqueues a message to the appropriate thread pool. The handler executes on an available thread.
Process Actor Configuration
All actors are defined in apptor-flow-api/src/main/resources/flow-config.json:
{
"cacheType": "hazelcast",
"engineTopic": "engine-topic",
"engineTopicType": "in-memory-distributed",
"eventEmitter": {
"topicName": "test-topic",
"topicType": "in-memory-distributed"
},
"processActors": [
{ "name": "startEvent", "maxThreads": 5, "queueType": "in-memory-distributed" },
{ "name": "endEvent", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "serviceTask", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "userTask", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "task", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "scriptTask", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "exclusiveGateway", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "inclusiveGateway", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "parallelGateway", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "boundaryEvent", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "intermediateThrowEvent", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "intermediateCatchEvent", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "subProcess", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "loopNode", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "aiTask", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "voiceTask", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "ifElse", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "callProcess", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "inputNode", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "outputNode", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "domainTask", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "memoryAction", "maxThreads": 1, "queueType": "in-memory-distributed" },
{ "name": "callAgent", "maxThreads": 2, "queueType": "in-memory-distributed" },
{ "name": "setVariable", "maxThreads": 2, "queueType": "in-memory-distributed" }
],
"simpleActors": [
{ "name": "engineMessageProcessor", "maxThreads": 1, "queueType": "in-memory-distributed" }
]
}
If a new node type is added and its actor entry is missing from flow-config.json, the engine will have no thread pool for that node type and executions will silently fail or queue indefinitely.
Node Handler Lifecycle
Every node execution follows this sequence:
For each transition, the engine:
- Updates
node_instance.status_cdin PostgreSQL - Writes updated variables to
node_instance.variables - Publishes a lifecycle event (NODE_STARTED, NODE_COMPLETED, NODE_CANCELLED, NODE_TIMEDOUT)
- Dispatches the next node message to the appropriate actor queue
Variable Resolution
Before each node executes, the engine resolves all template variable references in node properties using TemplateVariableResolver:
// Pattern matched by TemplateVariableResolver:
// \{([a-zA-Z_][a-zA-Z0-9_]*)\} → resolves {varName}
// Nested: {customer.address.city}
// Environment: {env.SECRET_KEY}
String resolved = templateVariableResolver.resolve(
"Hello {firstName}, order {orderId} is ready",
executionContext.getVariables()
);
// → "Hello Jane, order ORD-1234 is ready"
JUEL expressions (used in If/Else and Loop conditions) are evaluated separately by the JUEL engine:
// Only evaluated in condition.expression fields:
boolean result = juelEngine.evaluate("${status == 'approved' && amount > 100}", variables);
{varName} → correct for node properties (prompts, subjects, bodies, etc.)
${varName} → WRONG in node properties (the literal ${varName} text will appear in output)
${expression} → correct ONLY in If/Else and Loop condition expressions
Subprocess and Call Process
Subprocess (Embedded)
A subprocess node contains a child workflow definition embedded within the parent. The engine creates a child process instance linked to the parent via parent_instance_id. Variables can be passed in (inputVariables) and out (outputVariables). The parent waits for the child to complete before continuing.
Call Process (Async)
A Call Process node triggers a separate workflow by processId without waiting for it to complete. Execution of the parent continues immediately after dispatching. This is fire-and-forget.
Event-Driven Workflows
An Intermediate Catch Event node suspends workflow execution and waits for an external event:
- Engine writes an
event_subscriptionrecord withmessage_or_signal_refandcorrelation_key - Execution halts — the
process_instanceremains in RUNNING state with the catch event node in WAITING state - An external system POSTs to
/process/eventwith the matchingcorrelation_keyand event type - Engine finds the matching
event_subscription, resumes the workflow from that node
This enables long-running workflows that wait hours, days, or indefinitely for external signals.
Boundary Events
A Boundary Event attaches to another node (typically a subprocess or user task) and fires when a timeout or error condition occurs on that node. The cancel_activity flag controls whether the attached node is cancelled when the boundary event fires.
Real-Time Log Streaming
Execution logs are streamed to the browser via Server-Sent Events:
GET /process/instance/{processInstanceId}/logs
Content-Type: text/event-stream
Each SSE event is an ExecutionLogEntry containing:
nodeId— which node produced this lognodeName— display namenodeType— type of nodemessage— log messagelevel— INFO, WARN, ERRORtimestamp— when it happenedvariables— variable state at this point (optional)
The Angular ExecutionConsoleComponent subscribes to this stream and renders logs in real time, with the GoJS diagram highlighting each node as it runs.