WoGu
WG010ERRORDeterminism

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);
  }
}

False Positives

WG010 uses the same call-graph analysis as WG001WG009, 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.