Repository navigation
[Bug]: Align virtual-context operation hook isReplay with JavaScript to prevent Invocation span ID reuse #854
Description
Expected Behavior
Align the Java core operation-hook contract with the JavaScript SDK: virtual contexts report isReplay=true on onOperationStart and on terminal onOperationEnd, including the first invocation and both success and failure paths.
This lets plugins treat each invocation's virtual context as a separate segment of a stable logical operation. In particular, InvocationOtelPlugin must export a distinct span ID for each invocation segment while preserving the durable operation ID and execution trace ID.
Actual Behavior
Java virtual contexts report isReplay=false each time they run:
- BaseDurableOperation.execute calls
fireOnOperationStart(null)for virtual operations. - PluginInfoConverter derives
isReplayfromoperation != null, so the virtual start is always false. - ChildContextOperation passes false on virtual success; its failure path does the same.
- InvocationOtelPlugin consequently uses the same deterministic operation span ID in every invocation, with a different Invocation parent.
A local public-API probe with InMemorySpanExporter confirmed that the first virtual span was exported, then the same trace/span ID was exported again with different invocation parents. A backend can therefore collapse or overwrite the first segment; this is not evidence that the first start hook never fired.
Steps to Reproduce
- Configure
InvocationOtelPluginwithSimpleSpanProcessorandInMemorySpanExporter. - Run this handler body using
LocalDurableTestRunner:
return context.runInChildContext(
"otel-virtual-context",
String.class,
child -> {
child.step("otel-virtual-before", String.class, step -> "before");
child.wait("otel-virtual-wait", Duration.ofSeconds(5));
return child.step("otel-virtual-after", String.class, step -> "after");
},
RunInChildContextConfig.builder().isVirtual(true).build());- Call
runner.run(input), assert PENDING, and capture exported spans. - Resume with
runner.runUntilComplete(input), assert SUCCEEDED, and compare raw virtual span records by execution, operation ID, trace ID, span ID, and parent ID. - Register a recording core plugin as a separate regression check to capture the operation start/end
isReplayvalues.
In the existing local probe, virtual span e9cb48b5d6aff1e3 was exported on one trace with three different invocation parents: b2005cf2a32b68d7, 91a3d23c40235752, and 408b66395bd1fd85. The local runner performed an immediate retry before automatically advancing the wait, producing three invocations. Each before/after step body executed exactly once. These are local in-memory observations; the cloud backend's merge/overwrite behavior was not independently revalidated.
SDK Version
Java source commit 4b1aa82b0d898fea495cda45760ce3737fbb341e (3.0.0 source tree).
JavaScript comparison: 708cfe37fed12563c76f374e2e03adc02ec223d3.
Java Version
25 for the local diagnostic probe. The core fix should retain the repository's supported Java baseline.
Is this a regression?
Unknown; no known working Java version for this virtual-context scenario.
Additional Context
JavaScript already sets virtual operation start and success/failure end hooks to isReplay=true, including first execution:
Requested Fix and Acceptance Criteria
- Normalize virtual operation start/end metadata in core, where
isVirtualis known, so every registered plugin receives the same JS-compatible contract. Avoid an OTel-only override. - Virtual start and successful/failed terminal end hooks report
isReplay=trueon initial execution and re-entry. Preserve existing hook delivery and suspension behavior. - Preserve nonvirtual behavior. A null operation also occurs for a brand-new nonvirtual operation, so
operation == nullalone must not be used to identify virtual contexts. - Document the virtual-context exception in the plugin API: this flag does not prove that a durable checkpoint exists or that a completion has already been exported. Plugins must not blindly suppress every
isReplay=trueevent. - Add core hook tests for virtual first execution, resume, success, failure, and a nonvirtual control. Add an OTel public-API regression asserting distinct per-invocation virtual span IDs and the correct invocation parent for each virtual segment.
- Verify that completed step bodies remain skipped during replay, results and failures remain stable, and checkpoint counts, durable operation IDs, and checkpoint ParentIds are unchanged.
- Keep user-function replay metadata and
DurableContext.isReplaying()semantics separate from this operation-hook change. JavaScript's child-function wrapper derives replay state separately; matching its operation hooks does not justify changing every replay flag.
The Execution-view virtual lifetime/start-time issue, logical child-parent hierarchy, and cross-resume exactly-once export are separate work. This change should be validated against both OTel views but does not claim to resolve those broader issues. In particular, preserve the first virtual completion rather than adding a blanket replay-suppression rule.
Related: Java #507 covered the analogous span-ID collision for step retries; Java #435 discussed virtual-context hook delivery. JS #997 tracks the separate Execution-view duplicate virtual export problem.
Activity
hln33 commentedon Oct 30, 2026
Severity - medium
What is wrong. Virtual child contexts report
isReplay=falseon every invocation.InvocationOtelPluginuses that flag to pick the span ID, so each invocation exports the virtual context span with the same span ID under a different Invocation parent. A backend that keys on span ID collapses or overwrites the earlier segment, so the Invocation view shows one virtual segment where there should be one per invocation.What is correct. Execution results, checkpoint counts, operation IDs, checkpoint ParentIds, replay skipping, and
DurableContext.isReplaying()are all unchanged. The trace ID and the durable operation ID are correct. The child steps and waits inside the virtual context export correctly.Scope. Every user of the Java OTel plugin in Invocation view whose virtual child context spans more than one invocation.
Not a regression. The virtual start hook predates v2.1.0, and the
isReplay-based span ID selection predates v2.2.0. Every release since v2.2.0 has emitted the colliding IDs.Why not higher severity. There is no ongoing outage, no data loss, and no change to execution behavior. The trace still carries the correct trace ID and operation ID, and the inner steps are intact, so a customer can still follow the execution. The only gap is that they may not be able to tell which specific invocation a virtual context ran in.
Why not lower severity. The collision hits a default code path (
withRetry) rather than an opt-in flag, and it changes a core plugin contract that every plugin reads. Each release that keeps the current contract raises the cost of changing it, and JS already ships the target behavior, so the two SDKs disagree on whatisReplaymeans for plugins today.