Summary
The Process Engine API currently returns CompletableFutures for all engine interactions, making it fully asynchronous. While this is a clean and engine-agnostic design, it does not address a critical concern in real-world process applications: consistency between application (domain) state and process engine state within a single transaction.
This feature request proposes an ExecutionMode parameter on all command methods that enables transaction-aware execution without changing the API surface — no method duplication, no new interfaces, just an additional optional parameter with a sensible default.
When discussing the need for the Process Engine API on founding the BPM Crafters, we had in mind to migrate VanillaBP once, to work on top of the Process Engine API, as VanillaBP provides a higher level of abstraction. To start migration, kind of TX-support needs to be available at the Process Engine API level — either as first-class features or as well-defined extension points.
Context & Motivation
The Problem
When a @Transactional service method completes a user task, starts a process, or correlates a message, two state changes must be consistent:
- Domain state — the business entity is updated in the application database.
- Process engine state — the engine advances to the next step.
If the domain transaction rolls back but the engine command has already been dispatched (or vice versa), the system ends up in an inconsistent state. This is exactly the bug described in process-engine-adapters-camunda-7#156 and process-engine-adapters-cib-seven#30.
Design Principles
- Composable building blocks. Direct users get a convenient default mode. Higher-order frameworks (like VanillaBP or
process-engine-worker) can compose the lower-level modes to implement advanced patterns.
- Full backward compatibility. Existing code continues to work without changes.
- Adapter-type transparency. The application code does not need to know whether the adapter is embedded or remote — the adapter interprets each mode according to its own capabilities.
- Separation of concerns. Transaction awareness is platform-specific and has to be
done by higher-order implementations (e.g. process-engine-worker or VanillaPB).
Proposed API Change: ExecutionMode
The Enum
enum class ExecutionMode {
DEFAULT,
// Placeholder that adapters interpret as ASYNC.
// Higher-order adapters (e.g. process-engine-worker) can intercept DEFAULT and
// substitute the appropriate mode based on their own configuration.
PREFLIGHT_CHECK,
// Performs only a pre-flight feasibility check, asynchronously.
// No engine command is executed. No transaction hook is registered.
// The returned Future completes with success if the command would be
// feasible, or with an exception if not.
//
// Intended as a building block for higher-order frameworks that need
// to orchestrate multiple adapters or custom transaction strategies.
ASYNC,
// Current behaviour. Command is executed asynchronously. No transaction
// awareness. The returned Future completes when the engine has processed
// the command.
//
// This mode does NOT perform a pre-flight check. The caller is responsible
// for knowing that the command is likely to succeed (e.g. after a prior
// PREFLIGHT_CHECK call).
SYNC,
// Execute synchronously in the caller's thread and transaction context.
// The returned Future completes immediately (already resolved).
//
// What "execute" means depends on the adapter type:
// - Embedded engine: runs the engine command in the current DB transaction.
// - Remote engine: writes an outbox entry in the current DB transaction.
// Actual dispatch happens asynchronously after commit.
//
// This mode does NOT perform a pre-flight check. The caller is responsible
// for knowing that the command is likely to succeed (e.g. after a prior
// PREFLIGHT_CHECK call). If the command fails, the exception propagates
// synchronously — allowing the caller's transaction to roll back.
}
Method Signature (example)
fun completeTask(
cmd: CompleteTaskCmd,
mode: ExecutionMode = ExecutionMode.DEFAULT
): CompletableFuture<Empty>
All existing command methods (startProcess, completeTask, correlateMessage, sendSignal, etc.) gain this optional parameter. Existing callers are unaffected because DEFAULT resolves to ASYNC, preserving current behavior.
Usage Patterns
Pattern 1: The DEFAULT Mode and Higher-Order Adapters
The developer writes business code; the API handles the rest. If transaction support is needed, the user has
to choose an higher-level adapter which is aware of this (e.g. process-engine-worker).
// Application code
processService.completeTask(cmd); // mode is DEFAULT (implicit)
// process-engine-worker internally:
// → interprets DEFAULT according to configuration or @Transactional annotations
// if transactional supported:
// 1. Starts an async pre-flight check (own TX / own connection for
// embedded engines, remote call for remote engines).
// 2. Registers a beforeCommit hook on the caller's transaction.
// 3. Returns a Future that completes when the pre-flight check completes.
// 4. At beforeCommit: waits for the pre-flight result. On failure → rollback.
// On success → executes the command synchronously (same semantics as
// CURRENT_THREAD).
This means DEFAULT is a delegation marker: adapters treat it as ASYNC, but higher-order wrappers can intercept and reinterpret it. The application code doesn't need to choose a mode — the framework decides.
Adapter Responsibilities per Mode:
| Mode |
Embedded Adapter |
Remote Adapter |
process-engine-worker |
ASYNC |
Async engine command (current behaviour) |
Async remote call (current behaviour) |
Delegate to adapter |
SYNC |
Engine command in caller's thread/TX |
Outbox write in caller's TX, dispatch after commit |
Delegate to adapter |
PREFLIGHT_CHECK |
Like ASYNC but feasibility check in own TX (read-only, separate connection) |
Like ASYNC but feasibility check via remote API |
Delegate to adapter |
DEFAULT |
Same as ASYNC |
Same as ASYNC |
e.g. PREFLIGHT_CHECK + beforeCommit-Hook using SYNC |
Key point: SYNC does not perform a pre-flight check. It assumes the caller knows what it's doing (e.g. because it already did a PREFLIGHT_CHECK call). If the command fails, the exception propagates synchronously, which is the desired behaviour — it causes the transaction to roll back.
Implementation Notes
Pre-flight Check Semantics
The pre-flight check is an optimistic fast-fail mechanism, not a guarantee. Between the pre-flight check and the actual execution (in beforeCommit), the engine state can change due to concurrent requests. This is acceptable because:
- For embedded engines, the synchronous execution in
beforeCommit will detect conflicts (e.g. optimistic locking) and trigger a rollback.
- For remote engines, eventual consistency is inherent — the outbox entry will be processed, and the remote engine may reject it, requiring compensation.
Connection Pool Considerations
PREFLIGHT_CHECK uses a separate database connection (embedded) or a separate network call (remote). For embedded adapters, this means two connections are held simultaneously during the pre-flight phase. In practice, the pre-flight connection is short-lived and read-mostly. Under extreme connection pool pressure, this should be documented as a known trade-off.
Timeout Configuration
A configurable timeout for waiting on pre-flight results is needed. On timeout, the behaviour should be fail — it's better to fail than to hold a transaction open indefinitely.
Relationship to Existing Work
Acceptance Criteria
Summary
The Process Engine API currently returns
CompletableFutures for all engine interactions, making it fully asynchronous. While this is a clean and engine-agnostic design, it does not address a critical concern in real-world process applications: consistency between application (domain) state and process engine state within a single transaction.This feature request proposes an
ExecutionModeparameter on all command methods that enables transaction-aware execution without changing the API surface — no method duplication, no new interfaces, just an additional optional parameter with a sensible default.When discussing the need for the Process Engine API on founding the BPM Crafters, we had in mind to migrate VanillaBP once, to work on top of the Process Engine API, as VanillaBP provides a higher level of abstraction. To start migration, kind of TX-support needs to be available at the Process Engine API level — either as first-class features or as well-defined extension points.
Context & Motivation
The Problem
When a
@Transactionalservice method completes a user task, starts a process, or correlates a message, two state changes must be consistent:If the domain transaction rolls back but the engine command has already been dispatched (or vice versa), the system ends up in an inconsistent state. This is exactly the bug described in process-engine-adapters-camunda-7#156 and process-engine-adapters-cib-seven#30.
Design Principles
process-engine-worker) can compose the lower-level modes to implement advanced patterns.done by higher-order implementations (e.g.
process-engine-workeror VanillaPB).Proposed API Change:
ExecutionModeThe Enum
Method Signature (example)
All existing command methods (
startProcess,completeTask,correlateMessage,sendSignal, etc.) gain this optional parameter. Existing callers are unaffected becauseDEFAULTresolves toASYNC, preserving current behavior.Usage Patterns
Pattern 1: The DEFAULT Mode and Higher-Order Adapters
The developer writes business code; the API handles the rest. If transaction support is needed, the user has
to choose an higher-level adapter which is aware of this (e.g.
process-engine-worker).This means
DEFAULTis a delegation marker: adapters treat it asASYNC, but higher-order wrappers can intercept and reinterpret it. The application code doesn't need to choose a mode — the framework decides.Adapter Responsibilities per Mode:
ASYNCSYNCPREFLIGHT_CHECKDEFAULTASYNCASYNCKey point:
SYNCdoes not perform a pre-flight check. It assumes the caller knows what it's doing (e.g. because it already did aPREFLIGHT_CHECKcall). If the command fails, the exception propagates synchronously, which is the desired behaviour — it causes the transaction to roll back.Implementation Notes
Pre-flight Check Semantics
The pre-flight check is an optimistic fast-fail mechanism, not a guarantee. Between the pre-flight check and the actual execution (in
beforeCommit), the engine state can change due to concurrent requests. This is acceptable because:beforeCommitwill detect conflicts (e.g. optimistic locking) and trigger a rollback.Connection Pool Considerations
PREFLIGHT_CHECKuses a separate database connection (embedded) or a separate network call (remote). For embedded adapters, this means two connections are held simultaneously during the pre-flight phase. In practice, the pre-flight connection is short-lived and read-mostly. Under extreme connection pool pressure, this should be documented as a known trade-off.Timeout Configuration
A configurable timeout for waiting on pre-flight results is needed. On timeout, the behaviour should be fail — it's better to fail than to hold a transaction open indefinitely.
Relationship to Existing Work
TRANSACTION_AWAREmode solves this directly.EngineCommandExecutorfor pluggable command dispatch. TheExecutionModeapproach is a more general solution at the API level that subsumes this.Acceptance Criteria
ExecutionModeparameter (default:DEFAULT).ASYNCpreserves current behaviour (backward compatible).SYNCexecutes synchronously in the caller's thread. For embedded adapters, this participates in the caller's DB transaction. For remote adapters, this may write to an outbox within the caller's transaction.PREFLIGHT_CHECKperforms an async feasibility check without executing the command.DEFAULTbehaves asASYNCat the adapter level and is available for reinterpretation by higher-order frameworks.