Sitelet https://github.com/DataDog/dd-trace-java/pull/12283
Skip to content

Preserve context when the same task instance is submitted twice - #12283

Closed
amarziali wants to merge 1 commit into
masterfrom
andrea.marziali/fix-executor-task-reuse-context
Closed

Preserve context when the same task instance is submitted twice#12283
amarziali wants to merge 1 commit into
masterfrom
andrea.marziali/fix-executor-task-reuse-context

Conversation

@amarziali

@amarziali amarziali commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

The non-wrapping ThreadPoolExecutor path (the default) stores the captured context in a State attached to the task instance, and a State holds a single continuation. So when the same Runnable instance is submitted again while an earlier submission is still queued, the second capture is dropped and that execution runs under the root context, losing the parent.

AdviceUtils.capture now reports that conflict, and the execute advice falls back to wrapping for the losing submission so it carries its own context.

Affected every reused named class and anonymous inner class; JVM lambdas were accidentally safe already, since they are always wrapped. Only the ThreadPoolExecutor path is fixed here: ForkJoin tasks cannot be wrapped (since no field injected)

Motivation

Additional Notes

Contributor Checklist

Jira ticket: [PROJ-IDENT]

@amarziali amarziali added the type: bug fix Bug fix label Aug 25, 2026
@amarziali
amarziali requested a review from a team as a code owner August 25, 2026 13:01
@amarziali amarziali added the inst: others All other instrumentations label Aug 25, 2026
@amarziali
amarziali requested review from bric3 and removed request for a team August 25, 2026 13:01
@amarziali amarziali added the tag: concurrency Virtual Threads, Coroutines, Async, RX, Executors label Aug 25, 2026
@amarziali
amarziali requested a review from mcculls August 25, 2026 13:01

@datadog-datadog-us1-prod datadog-datadog-us1-prod Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: FAIL

The fallback wrapper can make priority executors reject a second comparable task with ClassCastException. It also loses a non-root context that has no active span.

Open Bits AI session

🤖 Datadog Autotest · Commit de6e25a · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keep priority queue entries mutually comparable

The second submission fails and the application task does not enter the queue.

Assertion details
  • Input: Submit the same Comparable Runnable twice while its first submission remains in a naturally ordered PriorityBlockingQueue.
  • Expected: The executor must accept both submissions and keep both queue entries comparable.
  • Actual: The queue compares ComparableRunnable with the raw task. The generated bridge casts the raw task to ComparableRunnable and throws ClassCastException.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Preserve contexts that contain no span

Features that store data in Context can lose that data during duplicate task submission.

Assertion details
  • Input: Submit the same Runnable twice from non-root contexts that contain no AgentSpan while the first submission remains pending.
  • Expected: The fallback must give the second submission its own carrier for the full current Context.
  • Actual: Wrapper.wrap calls captureActiveSpan. That call returns a root continuation when no span exists. The method then returns the original task, so the occupied State still drops the second context.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de6e25a6eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep comparable submissions type-compatible

When the reused task implements Comparable and the executor uses a PriorityBlockingQueue, the first queued entry is the original task while this fallback turns the second into a ComparableRunnable. During the second offer, the queue invokes ComparableRunnable.compareTo with the original task; its generated bridge casts that argument to ComparableRunnable, causing a ClassCastException and rejecting the submission. The collision carrier must remain mutually comparable with an unwrapped entry or avoid mixing wrapped and unwrapped tasks.

Useful? React with 👍 / 👎.

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve spanless contexts in the fallback carrier

When the current context is non-root but contains no AgentSpan, AdviceUtils.shouldCapture deliberately accepts it and the failed state capture reaches this branch, but Wrapper.wrap uses captureActiveSpan(), which returns the root continuation when no span is active and consequently returns the original task unchanged. The repeated submission therefore still loses custom spanless context, contrary to the new fallback contract; the carrier needs to capture the Context.current() value that failed to enter the state slot.

Useful? React with 👍 / 👎.

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve task identity for executor removal

When the first execution has started but its state was still occupied when the same task was submitted again, the queued second submission is now a Wrapper whose identity and equals differ from the submitted task. A later ThreadPoolExecutor.remove(task) therefore returns false and leaves that queued execution in place, whereas without instrumentation it removes the second occurrence; similarly, removing both queued occurrences by calling remove(task) twice leaves the wrapped one behind. Avoid exposing a distinct queue element or ensure removal can locate the wrapper by its delegate.

Useful? React with 👍 / 👎.

// This same instance is already queued with a pending continuation, and its single State
// slot cannot hold ours as well. Fall back to wrapping so this submission carries its own
// context instead of silently running under the root context.
task = Wrapper.wrap(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent the wrapper from consuming the earlier task state

With a pool containing at least two workers, the wrapped second submission can reach delegate.run() before the worker that dequeued the original submission enters beforeExecute. Wrapper.run() first activates the second context, but the delegate's RunnableInstrumentation then consumes the still-pending first continuation from the shared State and activates it on top, so the second run observes the first parent and the original run later has no parent. The fallback must isolate the delegate from its earlier state rather than relying on FIFO dequeue order to imply scope-activation order.

Useful? React with 👍 / 👎.

@datadog-datadog-us1-prod

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.99 s 13.99 s [-0.9%; +0.9%] (no difference)
startup:insecure-bank:tracing:Agent 13.00 s 13.04 s [-1.5%; +0.9%] (no difference)
startup:petclinic:appsec:Agent 16.93 s 16.83 s [-0.3%; +1.5%] (no difference)
startup:petclinic:iast:Agent 16.88 s 16.94 s [-1.1%; +0.3%] (no difference)
startup:petclinic:profiling:Agent 16.63 s 16.79 s [-2.0%; +0.1%] (no difference)
startup:petclinic:sca:Agent 16.75 s 16.45 s [+0.9%; +2.8%] (maybe worse)
startup:petclinic:tracing:Agent 16.06 s 16.24 s [-1.9%; -0.4%] (maybe better)

Commit: de6e25a6 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@amarziali
amarziali marked this pull request as draft August 25, 2026 13:57
@amarziali

Copy link
Copy Markdown
Contributor Author

Closing for now since fixing an edge case might introduce more issues to handle

@amarziali amarziali closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

inst: others All other instrumentations tag: concurrency Virtual Threads, Coroutines, Async, RX, Executors type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant