Skip to content

DO NOT MERGE: #34154: test(java25): ThreadLocal and ScopedValue are not the same idiom - #37443

Draft
fabrizzio-dotCMS wants to merge 1 commit into
mainfrom
issue-34154-java25-scopedvalue-idiom
Draft

DO NOT MERGE: #34154: test(java25): ThreadLocal and ScopedValue are not the same idiom#37443
fabrizzio-dotCMS wants to merge 1 commit into
mainfrom
issue-34154-java25-scopedvalue-idiom

Conversation

@fabrizzio-dotCMS

Copy link
Copy Markdown
Member

Why this exists

ThreadLocal and ScopedValue get described as alternatives, and dotCMS has a great deal of the
first — so a migration reads as mechanical: change the type, delete the cleanup. It is not
mechanical, and this is the argument, executed rather than asserted.

Study material for the Lunch & Learn (#34154). DO NOT MERGE.

java dotCMS/src/test/java/com/dotcms/jdk/ThreadLocalVsScopedValue.java

JDK 25, no flags, no Maven, no dotCMS classpath.

What it proves

1 — A pooled thread remembers the last request.

deep code during request A   sees: request-A
deep code during request B   sees: request-A

Task B set nothing. It read A's value, on a thread the container handed it clean. Nothing failed
and nothing logged — it just served the wrong request's context.

2 — setRequest(null) is not remove(). This is the cleanup idiom dotCMS actually uses, and it
writes null into the entry rather than releasing it:

first read (runs the initial supplier) : computed-118203673637583
after set(null)                        : null                       <- supplier did NOT re-run
after remove()                         : computed-118203674187416   <- entry was gone

3 — A scoped value cannot outlive its scope, on the same pooled thread, with no cleanup call to
forget: isBound() is false in the next task.

4 — The idiom inverts, and this is the real finding. There is no ScopedValue.set(). Deeper
code can only open a new binding, invisible to the frame above it:

outer frame            : request-A
inner frame rebinds it : request-A-rewritten
back in the outer frame: request-A          <- the rebinding was never visible here

With a ThreadLocal the writer decides, at any depth, and the value persists until someone
removes it. With a ScopedValue the caller decides, by wrapping the call, and the value is
immutable for everyone underneath. So each set() site becomes "find the frame that owns the
lifetime and turn the call inside out" — ten wraps, not ten replacements.

And a value that flows outward — inner code informing outer code — has no equivalent at all.
That is exactly the shape of ThreadContextUtil.includeDependencies: written inside
wrapReturnNoReindex(fireWorkflowPreCheckin) and read inside the sibling
wrapVoidNoReindex(fireWorkflowPostCheckin). It is why that ThreadLocal cannot be fully removed,
and why #37106 migrated the other two fields and left this one.

The state of the codebase

HttpServletRequestThreadLocal, counted over dotCMS/src/main/java on 2026-09-07:

103 reads of getRequest(), across 95 classes
10 sites that set a real request
3 sites that clear it — all with setRequest(null)
0 calls to remove(), anywhere
6 classes that set it and never clear it

Those six: RequestCostApiImpl, JsServlet, VelocityLiveMode, VelocityServlet,
PageResourceHelper, WorkflowResource.

The class has no remove() method to callgetRequest() and setRequest() are the whole
API. So the leak is not an oversight at the call sites; it is the only thing the type allows. Its
own javadoc already warns that async work will not see the value, which is the same scoping
confusion from the other direction.

Not in scope

Refs #34154

…oms (#34154)

The two are presented as alternatives, and dotCMS has a lot of the first, so the
migration looks mechanical. It is not. This demo proves four things by running
them, and closes with the real counts from the codebase.

- A pooled thread remembers the last request. Task A sets it and does not clear
  it; task B, which sets nothing, reads A's value. Nothing fails and nothing logs.
- setRequest(null) is not remove(). Writing null leaves the map entry in place --
  shown with a withInitial ThreadLocal whose supplier does NOT re-run after
  set(null) but does after remove().
- A scoped value cannot outlive its scope, on the same pooled thread, with no
  cleanup call to forget.
- The idiom inverts. There is no ScopedValue.set(): deeper code can only open a
  new binding, invisible to the frame above it. With a ThreadLocal the writer
  decides at any depth; with a ScopedValue the caller decides by wrapping.

That last point is why ThreadContextUtil cannot be fully migrated: its
includeDependencies is written inside one wrap and read inside the next, which
is a value flowing outward, and a scoped value has no equivalent.

Counted over dotCMS/src/main/java for HttpServletRequestThreadLocal: 103 reads
across 95 classes, 10 sites that set a request, 3 that clear it (all with
setRequest(null)), 0 calls to remove() anywhere, and 6 classes that set it and
never clear it. The class exposes no remove() at all.

Verified on JDK 25.0.2; test-compile -pl :dotcms-core --am passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 1m 25s —— View job


Code Review

I read the added file, verified it against the repo (compiler settings, the real HttpServletRequestThreadLocal), and checked the technical claims it makes.

New Issues

No correctness bugs. The four demonstrations are technically accurate and the JDK-25 API usage is valid:

  • The ThreadLocal pooled-thread leak (§1), set(null)remove() (§2), scope teardown on run() return (§3), and inward-only rebinding (§4) all behave exactly as narrated.
  • ScopedValue is a final feature in JDK 25 (JEP 506), so the "no flags, no --enable-preview" claim for the single-file launcher holds. Under Maven, dotcms.core.compiler.release=25 + enablePreview=true (parent/pom.xml:31,40) compiles it fine too.
  • The verdict's factual anchor checks out: HttpServletRequestThreadLocal (dotCMS/src/main/java/com/dotcms/api/web/HttpServletRequestThreadLocal.java) exposes only getRequest() / setRequest() with no remove(), and its javadoc already warns that async work won't see the value — matching the "same scoping confusion from the other direction" argument.

Notes (non-blocking)

  • 🟡 Medium: dotCMS/src/test/java/com/dotcms/jdk/ThreadLocalVsScopedValue.java:74,83,96,… — Uses System.out.println throughout, which the Critical Rules forbid (Logger only). Here it's justified and correct — the whole point is a zero-dependency file runnable via java <file>.java with no dotCMS classpath, where Logger isn't available. Flagging only so it's a conscious exception, not a pattern to copy. No change recommended.
  • 🟡 Medium: The file lives under src/test/java, so even though it's DO NOT MERGE it will be compiled by dotcms-core's test-compile in CI (it has a main, no @Test, so it never runs as a test — compile only). That's fine on JDK 25, but it does pin this module's test sources to a JDK-25-only API; anyone overriding -Ddotcms.core.compiler.release to an older value for local test builds would hit a compile error on ScopedValue. Consistent with the PR's "DO NOT MERGE / study material" intent.

Verdict

Clean as study material — the code does what the description says, and the description is accurate. The only real question is process, not code: a main-based demo in src/test/java compiles in CI forever once merged, which is precisely why the DO NOT MERGE label is the right call. If it ever needs a permanent home, consider a docs/-adjacent location or a scratch/example module excluded from the standard build rather than the test source root.
· issue-34154-java25-scopedvalue-idiom

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

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant