ExecutorService inside Workflow
- Engine
- Temporal Java SDK
- Since
- 0.3.0
- Auto Fix
- Not Available
- Category Range
- WG001–WG099
Description
A Temporal workflow implementation — or any code reachable from a workflow's entry point, however many method calls away — creates its own thread or thread pool via any of:
Executors.newFixedThreadPool(...)Executors.newCachedThreadPool(...)Executors.newSingleThreadExecutor(...)new Thread(...)CompletableFuture.supplyAsync(...)ForkJoinPool.commonPool()
Why This Matters
A Temporal workflow's code must run entirely on the workflow's single logical thread so that its execution can be replayed deterministically from event history. Creating your own thread or thread pool escapes that guarantee: work done on a thread the workflow doesn't control isn't tracked in workflow history, its scheduling and completion order isn't guaranteed to match between the original execution and replay, and a worker restart can lose that work entirely with no way to recover it from history. In practice this can cause workflow execution to diverge from recorded history, or simply never resolve during replay.
Violation Example
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class PaymentWorkflowImpl implements PaymentWorkflow {
@Override
public void processPayment() {
ExecutorService pool = Executors.newFixedThreadPool(4);
}
}This is flagged even when the call is several methods away from the workflow entry point:
public class PaymentWorkflowImpl implements PaymentWorkflow {
private final PaymentService paymentService = new PaymentService();
@Override
public void processPayment() {
// WG010 still fires: WoGu's call-graph analysis follows this call into
// PaymentService.chargeAsync() and finds CompletableFuture.supplyAsync() inside it.
paymentService.chargeAsync();
}
}
class PaymentService {
void chargeAsync() {
CompletableFuture.supplyAsync(() -> charge());
}
}Compliant Example
import io.temporal.workflow.Async;
public class PaymentWorkflowImpl implements PaymentWorkflow {
private final ChargeActivity chargeActivity;
@Override
public void processPayment() {
Async.function(chargeActivity::charge);
}
}Recommended Fix
Use Temporal's Async APIs (Async.function/Async.procedure) to run work concurrently
within a workflow, or move the work into an Activity, instead of creating your own
threads, executors, or async pools. Temporal's own concurrency primitives are tracked in
workflow history and replay correctly; raw Java threads and executors are not.
False Positives
WG010 uses the same call-graph analysis as WG001–WG009, matching
both static method calls (the Executors/CompletableFuture/ForkJoinPool APIs above)
and a matching constructor call (new Thread(...)), at every call site reachable from a
workflow's entry-point method(s). Matching is by class and method (or constructor) name
only, so every overload of a listed method — e.g. every Executors.newFixedThreadPool
overload, with or without a ThreadFactory — is flagged identically.
As with the other determinism rules, this analysis intentionally stops, without
reporting anything past that point, at two kinds of boundary: a call it cannot resolve to
source it can see, and a call that resolves into a Temporal Activity implementation
(recognized by its @ActivityInterface/@ActivityMethod annotations, or by resolving
only as far as the activity interface's bodyless method) — activities are expected to
manage their own concurrency and are not replayed, so they are exempt from this rule by
design.
This means WG010 can produce false negatives (a real violation hidden behind a call it can't see into) but is designed to avoid false positives. If you believe WG010 is flagging or missing something incorrectly, please open an issue with a minimal reproduction.
Found an issue with this rule's detection? Open an issue with a minimal reproduction.