DO NOT MERGE: #34154: test(java25): ThreadLocal and ScopedValue are not the same idiom - #37443
DO NOT MERGE: #34154: test(java25): ThreadLocal and ScopedValue are not the same idiom#37443fabrizzio-dotCMS wants to merge 1 commit into
Conversation
…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 finished @fabrizzio-dotCMS's task in 1m 25s —— View job Code ReviewI read the added file, verified it against the repo (compiler settings, the real New IssuesNo correctness bugs. The four demonstrations are technically accurate and the JDK-25 API usage is valid:
Notes (non-blocking)
VerdictClean as study material — the code does what the description says, and the description is accurate. The only real question is process, not code: a |
Why this exists
ThreadLocalandScopedValueget described as alternatives, and dotCMS has a great deal of thefirst — 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.
JDK 25, no flags, no Maven, no dotCMS classpath.
What it proves
1 — A pooled thread remembers the last request.
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 notremove(). This is the cleanup idiom dotCMS actually uses, and itwrites null into the entry rather than releasing it:
3 — A scoped value cannot outlive its scope, on the same pooled thread, with no cleanup call to
forget:
isBound()isfalsein the next task.4 — The idiom inverts, and this is the real finding. There is no
ScopedValue.set(). Deepercode can only open a new binding, invisible to the frame above it:
With a
ThreadLocalthe writer decides, at any depth, and the value persists until someoneremoves it. With a
ScopedValuethe caller decides, by wrapping the call, and the value isimmutable for everyone underneath. So each
set()site becomes "find the frame that owns thelifetime 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 insidewrapReturnNoReindex(fireWorkflowPreCheckin)and read inside the siblingwrapVoidNoReindex(fireWorkflowPostCheckin). It is why thatThreadLocalcannot be fully removed,and why #37106 migrated the other two fields and left this one.
The state of the codebase
HttpServletRequestThreadLocal, counted overdotCMS/src/main/javaon 2026-09-07:getRequest(), across 95 classessetRequest(null)remove(), anywhereThose six:
RequestCostApiImpl,JsServlet,VelocityLiveMode,VelocityServlet,PageResourceHelper,WorkflowResource.The class has no
remove()method to call —getRequest()andsetRequest()are the wholeAPI. 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
HttpServletRequestThreadLocal— that is ten call sites of restructuring andbelongs in its own issue, with its own tests.
ThreadContextUtilis discussed, not changed. DO NOT MERGE: #34154: refactor(java25): scoped values for reindex + config guard, flexible constructor for ISODateParam #37106 covers the two fields that do migrate.Refs #34154