Non-deterministic Time APIs inside Workflow
- Engine
- Temporal Java SDK
- Since
- 0.2.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 — reads the current wall-clock time via any of:
System.currentTimeMillis()Instant.now()LocalDate.now()LocalDateTime.now()OffsetDateTime.now()ZonedDateTime.now()Clock.systemUTC()Clock.systemDefaultZone()
Why This Matters
Temporal workflows don't run once and stay in memory: they are replayed. Whenever a worker needs to reconstruct a workflow's state, it re-executes the workflow's code from the beginning of the event history, feeding it the recorded results of everything that already happened.
Every API above returns the current system time at the moment it's called — a value that
is, by definition, different on every call and is never recorded in workflow history the
way an activity result or a timer firing is. When workflow code reads the system clock
directly, replay re-executes that read and gets today's time, not the time the
workflow originally saw. If any decision in the workflow depends on that value — a
comparison, a formatted date, a duration calculation — replay can diverge from the
original execution and raise a NonDeterministicException.
This is exactly the same class of bug as WG001's UUID.randomUUID() and
WG002's Thread.sleep(): a source of non-determinism reachable from workflow
code. io.temporal.workflow.Workflow.currentTimeMillis() (and Temporal's other
replay-safe time accessors) return the time as it was recorded in workflow history
instead of the live system clock, so they produce the same value on every replay.
Violation Example
class PaymentService {
long currentTime() {
return System.currentTimeMillis();
}
}or, equally, calling any of the other APIs directly:
Instant chargedAt = Instant.now();Both are 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 process() {
// WG003 still fires: WoGu's call-graph analysis follows this call into
// PaymentService.currentTime() and finds System.currentTimeMillis() inside it.
paymentService.currentTime();
}
}The report renders the full path from the workflow entry point down to the offending call:
PaymentWorkflow.process()
↓
PaymentService.currentTime()
↓
System.currentTimeMillis()Compliant Example
import io.temporal.workflow.Workflow;
public class PaymentWorkflowImpl implements PaymentWorkflow {
@Override
public void process() {
long now = Workflow.currentTimeMillis();
}
}Recommended Fix
Replace the direct system-clock read with Workflow.currentTimeMillis() (or whichever
Temporal-provided deterministic time accessor fits — consult the Temporal Java SDK docs
for date/time helpers built on top of it). It returns the time as recorded in workflow
history, so it is stable across replay, and calls to it are never flagged by this rule
since it's a different method on a different class than the ones listed above.
If you need the real current time inside an Activity rather than workflow code, this rule doesn't apply — activities are not replayed, so any of these APIs are safe there. WG003 only flags code reachable from a workflow's own entry point (see Call Graph Analysis below for what "reachable" means).
References
False Positives
WG003 uses the same call-graph analysis as WG001 and WG002: starting from a workflow's entry-point method(s), it follows every method call that can be resolved to another method's source within the same project, however many hops deep, looking for any of the eight APIs listed above along the way.
As with WG001 and WG002, this analysis intentionally stops, without reporting anything past that point, when it reaches a call it cannot resolve to source it can see — most notably, a call to an Activity made through the activity's interface, which resolves to the interface's bodyless method declaration rather than the real implementation. A call into a compiled dependency, or one resolved only through reflection or dynamic dispatch, is an equally intentional stopping point.
This means WG003 can produce false negatives (a real violation hidden behind a call
it can't see into) but is designed to avoid false positives: each API is matched by
its exact class and method name (e.g. Instant.now() requires either an import of
java.time.Instant, a wildcard import of java.time.*, or the fully qualified form
written inline — a coincidentally named now() method on an unrelated class is never
matched), and Workflow.currentTimeMillis() is correctly never flagged, since
Workflow is a different class entirely. If you believe WG003 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.