Skip to content

[Bug]: Align virtual-context operation hook isReplay with JavaScript to prevent Invocation span ID reuse #854

Open

Description

@zhongkechen

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:

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

  1. Configure InvocationOtelPlugin with SimpleSpanProcessor and InMemorySpanExporter.
  2. 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());
  1. Call runner.run(input), assert PENDING, and capture exported spans.
  2. Resume with runner.runUntilComplete(input), assert SUCCEEDED, and compare raw virtual span records by execution, operation ID, trace ID, span ID, and parent ID.
  3. Register a recording core plugin as a separate regression check to capture the operation start/end isReplay values.

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 isVirtual is 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=true on 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 == null alone 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=true event.
  • 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

  1. added
    parityProvides parity with other language implementations of the SDK
    pkg:otelModule: otel-plugin
    pkg:pluginsPlugin interface, possibly impacting all plugins
    on Oct 30, 2026
  2. hln33 commented on Oct 30, 2026

    @hln33
    Contributor

    Severity - medium

    What is wrong. Virtual child contexts report isReplay=false on every invocation. InvocationOtelPlugin uses 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 what isReplay means for plugins today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparityProvides parity with other language implementations of the SDKpkg:otelModule: otel-pluginpkg:pluginsPlugin interface, possibly impacting all pluginspkg:sdkModule: sdk

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions