Skip to main content

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

  1. Each node type has its own actor — isolation prevents slow nodes from blocking others
  2. Execution is asynchronous — the API returns immediately; execution continues in the background
  3. State is fully persisted — every node instance and its variables are written to PostgreSQL
  4. The engine is restartable — if the server restarts mid-execution, pending queue items are replayed from the queue_item table
  5. Thread pools are configurableflow-config.json controls 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" }
]
}
warning

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:

  1. Updates node_instance.status_cd in PostgreSQL
  2. Writes updated variables to node_instance.variables
  3. Publishes a lifecycle event (NODE_STARTED, NODE_COMPLETED, NODE_CANCELLED, NODE_TIMEDOUT)
  4. 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);
Common Mistake

{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:

  1. Engine writes an event_subscription record with message_or_signal_ref and correlation_key
  2. Execution halts — the process_instance remains in RUNNING state with the catch event node in WAITING state
  3. An external system POSTs to /process/event with the matching correlation_key and event type
  4. 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 log
  • nodeName — display name
  • nodeType — type of node
  • message — log message
  • level — INFO, WARN, ERROR
  • timestamp — when it happened
  • variables — 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.