lukaszlenart commented on code in PR #1799:
URL: https://github.com/apache/struts/pull/1799#discussion_r3632811731


##########
core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java:
##########
@@ -52,29 +54,44 @@ public interface TypeConverterHolder {
     /**
      * Target class conversion Mappings.
      *
+     * <p>Returns {@code null} if the class has been flagged as having no 
mapping via
+     * {@link #addNoMapping(Class)}, even if a real mapping was previously 
stored for it with
+     * {@link #addMapping(Class, Map)}.</p>
+     *
      * @param clazz class to convert to/from
      * @return {@link TypeConverter} for given class
+     * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, 
Function)} which resolves
+     * and caches the mapping atomically instead of requiring a check-then-act 
at the call site.

Review Comment:
   Fixed in bd70047a1 — `@return` now reads "the property-converter mapping for 
the given class, or `null` if none" (it was incorrectly copied from a 
`TypeConverter`-returning method), and the "atomically" wording is dropped from 
the `@deprecated` tag. Good catch that it contradicted the reworded 
`computeMappingIfAbsent` contract, where the builder may run more than once 
under concurrent first access.



##########
core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java:
##########
@@ -97,4 +114,42 @@ public interface TypeConverterHolder {
      */
     void addUnknownMapping(String className);
 
+    /**
+     * Returns the property-converter mapping for the given class, building 
and caching it on first
+     * use. Never returns {@code null}: a class known to have no mapping 
yields an empty map.
+     *
+     * <p>If the builder returns {@code null} or an empty map, the class is 
recorded in the negative
+     * cache so the builder is not invoked for it again.</p>
+     *
+     * <p>Implementations only guarantee that all callers converge on the same 
cached mapping
+     * instance for a given class - not that the builder runs at most once. 
Under concurrent first
+     * access, the builder may be invoked more than once (each on a different 
thread, for the same
+     * class); only one of the resulting mappings is retained and returned to 
every caller. The
+     * builder must therefore be idempotent and free of side effects on the 
holder itself. The
+     * default implementation is a non-atomic check-then-act using the 
deprecated primitives,
+     * preserving pre-7.3.0 behaviour for third-party holders that do not 
override it.</p>
+     *
+     * @param clazz   class to convert to/from
+     * @param builder builds the property-converter mapping for the class when 
it is not yet cached
+     * @return the mapping for the class, or an empty map if it has none
+     * @since 7.3.0
+     */
+    @SuppressWarnings("deprecation")

Review Comment:
   Verified this one and it does not reproduce — leaving as-is. The `default` 
body calls `getMapping`/`addMapping`/`containsNoMapping`, which are declared in 
this same interface (`TypeConverterHolder`). Per JLS 9.6.4.6, a use within the 
same outermost class as the deprecated element produces no warning at all — 
neither `deprecation` nor `removal` — so there is nothing for `removal` to 
suppress here (the existing `deprecation` suppression is in fact redundant on 
this method). Confirmed by compiling `core` with lint active: 44 `[removal]` 
warnings across the module, none in `TypeConverterHolder.java`. The genuine 
cross-class site, `XWorkConverter.conditionalReload`, was widened to 
`@SuppressWarnings({"deprecation","removal"})` in ebc0e8dff.



##########
docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md:
##########
@@ -0,0 +1,326 @@
+# WW-5539 — Concurrency performance enhancements
+
+**Jira**: [WW-5539](https://issues.apache.org/jira/browse/WW-5539)
+**Fix version**: 7.3.0
+**Component**: Core
+**Date**: 2026-07-21
+
+## Problem
+
+WW-5539 names three classes as candidates for improved locking, without 
further detail.
+Inspection confirms a distinct problem in each.
+
+### `StrutsTypeConverterHolder` — unsynchronised shared mutable state
+
+All four collections are plain `HashMap`/`HashSet` 
(`StrutsTypeConverterHolder.java:37-71`).
+The holder is a container singleton mutated at runtime through 
`addDefaultMapping`,
+`addMapping`, `addNoMapping` and `addUnknownMapping`. Meanwhile 
`XWorkConverter.lookup()`
+(`XWorkConverter.java:364-368`) reads it under no lock at all, while 
`registerConverter()`
+writes under the `XWorkConverter` monitor. Readers therefore race writers on a 
`HashMap`:
+lost updates, and torn reads during resize.
+
+This is a correctness defect, not only a contention problem.
+
+### `XWorkConverter` — two coarse locks
+
+1. `getConverter()` wraps its body in `synchronized (clazz)` 
(`XWorkConverter.java:417`).
+   Locking on a `Class` object is a well-known anti-pattern — the monitor is 
globally
+   visible and any other library may contend on it. It also serialises every 
conversion
+   for a given action class, including pure cache hits.
+2. `registerConverter` and `registerConverterNotFound` are `synchronized` 
methods
+   (`XWorkConverter.java:466,470`) on the singleton, so every cache miss takes 
a
+   process-wide lock.
+
+### `DefaultActionValidatorManager` — global lock on the request path
+
+`getValidators(...)` is `synchronized` on the singleton manager
+(`DefaultActionValidatorManager.java:140`). Every validated request in the 
application
+serialises on it. The lock covers not just the cache lookup but the per-request
+`Validator` instantiation loop (lines 149-157), which operates on per-request 
objects
+and never needed mutual exclusion. The caches are `synchronizedMap` wrappers 
with
+non-atomic `containsKey`/`get`/`put` sequences layered on top (lines 143-150, 
335-347).
+
+## Approach
+
+Make the caches genuinely concurrent, then delete the coarse locks. Where a 
computation
+is expensive and provably safe to guard, use `computeIfAbsent`. Where it is 
not safe,
+accept that two threads may occasionally redo cheap idempotent work and 
converge on the
+same answer.
+
+These caches are read-mostly with a small warm-up burst, which is the workload
+`ConcurrentHashMap` is built for. The resulting diff mostly removes code, 
which matters
+for a change whose entire risk profile is the soundness of its concurrency 
reasoning.
+
+Two alternatives were considered and rejected:
+
+- **Striped per-key locking** (interning a lock object per class). Preserves
+  exactly-once computation everywhere, but adds a lock-object cache that 
itself needs
+  eviction reasoning, still blocks readers behind writers, and duplicates the
+  classloader-pinning problem in a second map. `computeIfAbsent` provides the 
same
+  guarantee where it matters, for free.
+- **Copy-on-write immutable snapshots.** Fastest possible reads, but 
`mappings` is keyed
+  by every action class in the application, so each cold miss copies the whole 
map:
+  O(n) per write, O(n²) to warm. Under `struts.configuration.xml.reload` 
writes never
+  stop. Wrong shape for this data.
+
+## Compatibility constraints
+
+`TypeConverterHolder` is an SPI: aliased to `struts.converter.holder`
+(`StrutsBeanSelectionProvider.java:413`) and bound in `struts-beans.xml:115`. 
Third
+parties can supply their own implementations. 7.3.0 is a minor release, so the 
interface
+may gain `default` methods but must not break existing implementations.
+
+## Design
+
+### 1. `TypeConverterHolder` SPI
+
+One new `default` method:
+
+```java
+default Map<String, Object> computeMappingIfAbsent(Class clazz,
+                                                   Function<Class, Map<String, 
Object>> builder)
+```
+
+Contract: return the class's property-converter mapping, building and caching 
it on first
+use. Returns `Collections.emptyMap()` when the class is known to have none — 
never
+`null`. A builder returning `null` or an empty map means "no mapping", and the 
holder
+records that in the negative cache itself.
+
+The `default` body implements this with the existing
+`containsNoMapping`/`getMapping`/`addMapping`/`addNoMapping` primitives — 
check-then-act,
+matching today's semantics. Third-party holders inherit it unchanged and keep 
working,
+without the atomicity benefit.
+
+Because the `default` body calls methods this same change deprecates, it 
carries
+`@SuppressWarnings("deprecation")`. That is intentional and not an oversight: 
the fallback
+path must keep using the old primitives, since those are the only methods a 
third-party
+implementation is guaranteed to provide.
+
+`StrutsTypeConverterHolder` overrides it:
+
+```java
+if (noMapping.contains(clazz)) {
+    return Collections.emptyMap();
+}
+Map<String, Object> mapping = mappings.computeIfAbsent(clazz, c -> {
+    Map<String, Object> built = builder.apply(c);
+    return (built == null || built.isEmpty()) ? null : built;
+});
+if (mapping == null) {
+    noMapping.add(clazz);
+    return Collections.emptyMap();
+}
+return mapping;
+```
+
+A builder returning `null` inside `computeIfAbsent` stores nothing and yields 
`null`, so
+the negative case falls out naturally and `getMapping()` retains its current
+"null when absent" meaning.
+
+**Fields** become `ConcurrentHashMap` and `ConcurrentHashMap.newKeySet()`. 
This is the
+part that fixes the data race.
+
+**Deprecations.** `getMapping`, `addMapping` and `containsNoMapping` are marked
+`@Deprecated`: all three are strictly subsumed by `computeMappingIfAbsent`, 
and each
+invites check-then-act at call sites.
+
+`addNoMapping` is **not** deprecated. `XWorkConverter.getConverter` catches 
`Throwable`
+and negative-caches the failure; that is a distinct operation ("this class 
failed to
+build, stop retrying"), and folding it into the compute method would mean 
swallowing
+`Throwable` inside the SPI, hiding real failures from implementers.
+
+The default-mapping methods (`addDefaultMapping`, `containsDefaultMapping`,
+`getDefaultMapping`, `containsUnknownMapping`, `addUnknownMapping`) are **not**
+deprecated. They back the cache that cannot use `computeIfAbsent` (see below) 
and have
+live external callers in `StrutsConversionPropertiesProcessor` and
+`DefaultConversionAnnotationProcessor`.
+
+Removal of the deprecated methods is tracked separately by the project lead.
+
+**The `protected HashSet<String> unknownMappings` field** is retyped to 
`Set<String>`,
+made `final`, backed by `ConcurrentHashMap.newKeySet()`, and marked 
`@Deprecated`.
+Retyping is a source-compatibility break for any subclass that assigns it or 
calls a
+`HashSet`-specific method. The risk is judged negligible, but it is a break 
and is
+recorded here deliberately rather than left implicit.

Review Comment:
   Fixed in bd70047a1 — the design doc now describes the shipped approach: 
`unknownMappings` keeps its original `protected HashSet<String>` declaration 
(deprecated, unused) for binary compatibility, with live storage in a private 
`ConcurrentHashMap.newKeySet()`. Retyping it, as the doc originally proposed, 
would have changed the field descriptor and thrown `NoSuchFieldError` for 
subclasses compiled against an earlier version.



##########
docs/superpowers/plans/2026-07-21-WW-5539-concurrency-performance.md:
##########
@@ -0,0 +1,1209 @@
+# WW-5539 Concurrency Performance Enhancements Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove three coarse locks from Struts core's type-conversion and 
validation caches, replacing them with concurrent collections so that 
read-mostly cache hits no longer serialise.
+
+**Architecture:** Make the backing caches genuinely thread-safe 
(`ConcurrentHashMap` / `ConcurrentHashMap.newKeySet()`), then delete the 
`synchronized` blocks that were compensating for them. One new `default` SPI 
method, `TypeConverterHolder.computeMappingIfAbsent`, provides atomic 
build-once semantics for the single expensive computation where 
`computeIfAbsent` is provably safe. Everywhere else, check-then-act is retained 
and the duplicated work is cheap and idempotent.
+
+**Tech Stack:** Java 17 (`maven.compiler.release=17`), Maven multi-module, 
JUnit 3-style tests via `junit.framework.TestCase` (through 
`org.apache.struts2.XWorkTestCase`), AssertJ assertions, Log4j2.
+
+**Spec:** 
`docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md`
+
+## Global Constraints
+
+- **Branch:** all work lands on `WW-5539`. Never commit to `main`.
+- **Commit messages:** must be prefixed `WW-5539 `.
+- **Target release:** 7.3.0 (minor) — the `TypeConverterHolder` SPI may gain 
`default` methods but must not break existing third-party implementations.
+- **Test style:** core tests extend `org.apache.struts2.XWorkTestCase`, which 
extends `junit.framework.TestCase`. Test methods are `public void testXxx()` 
with **no** `@Test` annotation. Setup is `@Override protected void setUp() 
throws Exception { super.setUp(); ... }`. Do **not** convert these to JUnit 5.
+- **Available test fields:** `XWorkTestCase` provides `protected Container 
container` and `protected ConfigurationManager configurationManager`.
+- **`TypeConverter` is a functional interface** (single abstract method 
`convertValue(Map, Object, Member, String, Object, Class)`), so test stubs may 
be lambdas.
+- **Build command:** `mvn test -DskipAssembly -pl core 
-Dtest=ClassName#methodName`
+- **No binary-compatibility gate** (no japicmp/revapi) and **deprecation 
warnings do not fail the build** — verified in `pom.xml`.
+- **Exactly three methods get `@Deprecated`:** 
`TypeConverterHolder.getMapping`, `addMapping`, `containsNoMapping`. Do not 
deprecate `addNoMapping` or any of the five default-mapping methods.
+
+---
+
+### Task 1: Make `StrutsTypeConverterHolder` thread-safe
+
+This is the correctness fix. The holder is a container singleton whose plain 
`HashMap`s are read without any lock by `XWorkConverter.lookup()` while being 
written elsewhere.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java:21-77`
+- Test: 
`core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java`
 (create)
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: `StrutsTypeConverterHolder` with thread-safe internals and a 
`public StrutsTypeConverterHolder()` no-arg constructor (already implicit). 
Field `protected final Set<String> unknownMappings`. Behaviour change: 
`addDefaultMapping(className, null)` is now ignored rather than storing a null 
value.

Review Comment:
   Fixed in bd70047a1 — the plan's "Produces" line now reflects that the 
original `protected HashSet<String> unknownMappings` field is retained 
(deprecated, unused) for binary compatibility, with live storage in a new 
private concurrent set.



##########
docs/superpowers/plans/2026-07-21-WW-5539-concurrency-performance.md:
##########
@@ -0,0 +1,1209 @@
+# WW-5539 Concurrency Performance Enhancements Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove three coarse locks from Struts core's type-conversion and 
validation caches, replacing them with concurrent collections so that 
read-mostly cache hits no longer serialise.
+
+**Architecture:** Make the backing caches genuinely thread-safe 
(`ConcurrentHashMap` / `ConcurrentHashMap.newKeySet()`), then delete the 
`synchronized` blocks that were compensating for them. One new `default` SPI 
method, `TypeConverterHolder.computeMappingIfAbsent`, provides atomic 
build-once semantics for the single expensive computation where 
`computeIfAbsent` is provably safe. Everywhere else, check-then-act is retained 
and the duplicated work is cheap and idempotent.
+
+**Tech Stack:** Java 17 (`maven.compiler.release=17`), Maven multi-module, 
JUnit 3-style tests via `junit.framework.TestCase` (through 
`org.apache.struts2.XWorkTestCase`), AssertJ assertions, Log4j2.
+
+**Spec:** 
`docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md`
+
+## Global Constraints
+
+- **Branch:** all work lands on `WW-5539`. Never commit to `main`.
+- **Commit messages:** must be prefixed `WW-5539 `.
+- **Target release:** 7.3.0 (minor) — the `TypeConverterHolder` SPI may gain 
`default` methods but must not break existing third-party implementations.
+- **Test style:** core tests extend `org.apache.struts2.XWorkTestCase`, which 
extends `junit.framework.TestCase`. Test methods are `public void testXxx()` 
with **no** `@Test` annotation. Setup is `@Override protected void setUp() 
throws Exception { super.setUp(); ... }`. Do **not** convert these to JUnit 5.
+- **Available test fields:** `XWorkTestCase` provides `protected Container 
container` and `protected ConfigurationManager configurationManager`.
+- **`TypeConverter` is a functional interface** (single abstract method 
`convertValue(Map, Object, Member, String, Object, Class)`), so test stubs may 
be lambdas.
+- **Build command:** `mvn test -DskipAssembly -pl core 
-Dtest=ClassName#methodName`
+- **No binary-compatibility gate** (no japicmp/revapi) and **deprecation 
warnings do not fail the build** — verified in `pom.xml`.
+- **Exactly three methods get `@Deprecated`:** 
`TypeConverterHolder.getMapping`, `addMapping`, `containsNoMapping`. Do not 
deprecate `addNoMapping` or any of the five default-mapping methods.
+
+---
+
+### Task 1: Make `StrutsTypeConverterHolder` thread-safe
+
+This is the correctness fix. The holder is a container singleton whose plain 
`HashMap`s are read without any lock by `XWorkConverter.lookup()` while being 
written elsewhere.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java:21-77`
+- Test: 
`core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java`
 (create)
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: `StrutsTypeConverterHolder` with thread-safe internals and a 
`public StrutsTypeConverterHolder()` no-arg constructor (already implicit). 
Field `protected final Set<String> unknownMappings`. Behaviour change: 
`addDefaultMapping(className, null)` is now ignored rather than storing a null 
value.
+
+- [ ] **Step 1: Write the failing concurrency test**
+
+Create 
`core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java`:
+
+```java
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.conversion;
+
+import org.apache.struts2.XWorkTestCase;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class StrutsTypeConverterHolderTest extends XWorkTestCase {
+
+    private static final int THREADS = 16;
+    private static final int PER_THREAD = 200;
+
+    private static TypeConverter stubConverter() {
+        return (context, target, member, propertyName, value, toType) -> null;
+    }
+
+    public void testConcurrentDefaultMappingRegistrationLosesNothing() throws 
Exception {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+        CountDownLatch start = new CountDownLatch(1);
+        List<Future<?>> futures = new ArrayList<>();
+
+        for (int t = 0; t < THREADS; t++) {
+            final int threadId = t;
+            futures.add(pool.submit(() -> {
+                start.await();
+                for (int i = 0; i < PER_THREAD; i++) {
+                    String className = "stub.Class" + threadId + "_" + i;
+                    holder.addDefaultMapping(className, stubConverter());
+                    // interleave reads with writes to provoke the race
+                    holder.containsDefaultMapping("stub.Class0_0");
+                    holder.getDefaultMapping(className);
+                }
+                return null;
+            }));
+        }
+
+        start.countDown();
+        for (Future<?> future : futures) {
+            future.get(60, TimeUnit.SECONDS);
+        }
+        pool.shutdown();
+
+        for (int t = 0; t < THREADS; t++) {
+            for (int i = 0; i < PER_THREAD; i++) {
+                String className = "stub.Class" + t + "_" + i;
+                assertThat(holder.getDefaultMapping(className))
+                        .as("lost registration for %s", className)
+                        .isNotNull();
+            }
+        }
+    }
+
+    public void 
testConcurrentNoMappingAndUnknownMappingRegistrationLosesNothing() throws 
Exception {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+        CountDownLatch start = new CountDownLatch(1);
+        List<Future<?>> futures = new ArrayList<>();
+
+        for (int t = 0; t < THREADS; t++) {
+            final int threadId = t;
+            futures.add(pool.submit(() -> {
+                start.await();
+                for (int i = 0; i < PER_THREAD; i++) {
+                    holder.addUnknownMapping("stub.Unknown" + threadId + "_" + 
i);
+                    holder.containsUnknownMapping("stub.Unknown0_0");
+                }
+                return null;
+            }));
+        }
+
+        start.countDown();
+        for (Future<?> future : futures) {
+            future.get(60, TimeUnit.SECONDS);
+        }
+        pool.shutdown();
+
+        for (int t = 0; t < THREADS; t++) {
+            for (int i = 0; i < PER_THREAD; i++) {
+                String className = "stub.Unknown" + t + "_" + i;
+                assertThat(holder.containsUnknownMapping(className))
+                        .as("lost unknown mapping for %s", className)
+                        .isTrue();
+            }
+        }
+    }
+
+    public void testAddDefaultMappingIgnoresNullConverter() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+
+        holder.addDefaultMapping("stub.NullConverter", null);
+
+        
assertThat(holder.containsDefaultMapping("stub.NullConverter")).isFalse();
+        assertThat(holder.getDefaultMapping("stub.NullConverter")).isNull();
+    }
+
+    public void testAddDefaultMappingClearsUnknownMapping() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+
+        holder.addUnknownMapping("stub.Later");
+        assertThat(holder.containsUnknownMapping("stub.Later")).isTrue();
+
+        holder.addDefaultMapping("stub.Later", stubConverter());
+
+        assertThat(holder.containsUnknownMapping("stub.Later")).isFalse();
+        assertThat(holder.getDefaultMapping("stub.Later")).isNotNull();
+    }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest`
+
+Expected: `testAddDefaultMappingIgnoresNullConverter` FAILS (currently a null 
value is stored, so `containsDefaultMapping` returns `true`).
+
+The two concurrency tests are **expected to be flaky against the unfixed 
code** — they may pass on a strongly-ordered x86/ARM machine. Record whatever 
happens; do not treat a pass as evidence the current code is correct. The 
null-converter test is the deterministic gate for this step.
+
+- [ ] **Step 3: Make the collections concurrent**
+
+In `StrutsTypeConverterHolder.java`, replace the imports at lines 21-23:
+
+```java
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+```
+
+Replace the four field declarations (lines 37, 58, 63, 71) with:
+
+```java
+    private final Map<String, TypeConverter> defaultMappings = new 
ConcurrentHashMap<>();  // non-action (eg. returned value)
+```
+
+```java
+    private final Map<Class, Map<String, Object>> mappings = new 
ConcurrentHashMap<>(); // action
+```
+
+```java
+    private final Set<Class> noMapping = ConcurrentHashMap.newKeySet(); // 
action
+```
+
+```java
+    /**
+     * Record classes that doesn't have conversion mapping defined.
+     * <pre>
+     * - String -&gt; classname as String
+     * </pre>
+     *
+     * @deprecated since 7.3.0, this field is an implementation detail and 
will be made private.
+     */
+    @Deprecated
+    protected final Set<String> unknownMappings = 
ConcurrentHashMap.newKeySet();     // non-action (eg. returned value)
+```
+
+Keep the existing Javadoc comments above `defaultMappings`, `mappings` and 
`noMapping` unchanged.
+
+- [ ] **Step 4: Add the null-converter guard**
+
+`ConcurrentHashMap` forbids null values, so `addDefaultMapping` would now 
throw `NullPointerException` where it previously stored a null. A null 
converter was never useful — it left `containsDefaultMapping` returning `true` 
while `getDefaultMapping` returned `null`. `ConverterFactory` is a pluggable 
SPI, so a third-party implementation returning null is reachable. Skip it 
explicitly instead.
+
+Add the logger imports to `StrutsTypeConverterHolder.java`:
+
+```java
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+```
+
+Add the field just inside the class body, before `defaultMappings`:
+
+```java
+    private static final Logger LOG = 
LogManager.getLogger(StrutsTypeConverterHolder.class);
+```
+
+Replace `addDefaultMapping` (lines 73-77) with:
+
+```java
+    @Override
+    public void addDefaultMapping(String className, TypeConverter 
typeConverter) {
+        if (typeConverter == null) {
+            LOG.warn("Ignoring null TypeConverter registered for class [{}]", 
className);
+            return;
+        }
+        defaultMappings.put(className, typeConverter);
+        unknownMappings.remove(className);
+    }
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest`
+
+Expected: PASS, all four tests.
+
+- [ ] **Step 6: Run the surrounding regression suites**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest='XWorkConverterTest,AnnotationXWorkConverterTest,StrutsConversionPropertiesProcessorTest,ConfigurationManagerTest'`
+
+Expected: PASS, no edits to those files.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java 
\
+        
core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java
+git commit -m "WW-5539 Make StrutsTypeConverterHolder collections concurrent
+
+The holder is a container singleton whose HashMaps were read without any
+lock by XWorkConverter.lookup() while being written elsewhere, risking
+lost updates and torn reads during resize.
+
+Null TypeConverters are now ignored with a warning rather than stored,
+since ConcurrentHashMap forbids null values and a null converter left the
+holder in an inconsistent state."
+```
+
+---
+
+### Task 2: Add `computeMappingIfAbsent` to the `TypeConverterHolder` SPI
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java:19-100`
+- Modify: 
`core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java`
+- Test: 
`core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java`
 (modify)
+
+**Interfaces:**
+- Consumes: `StrutsTypeConverterHolder` with concurrent internals (Task 1).
+- Produces: `Map<String, Object> 
TypeConverterHolder.computeMappingIfAbsent(Class clazz, Function<Class, 
Map<String, Object>> builder)` — never returns `null`; returns 
`Collections.emptyMap()` when the class has no mapping. `getMapping`, 
`addMapping`, `containsNoMapping` are now `@Deprecated`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `StrutsTypeConverterHolderTest.java` (and add these imports):
+
+```java
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+```
+
+```java
+    public void testComputeMappingIfAbsentBuildsOnceAndCaches() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        AtomicInteger builds = new AtomicInteger();
+
+        Map<String, Object> first = 
holder.computeMappingIfAbsent(String.class, clazz -> {
+            builds.incrementAndGet();
+            Map<String, Object> built = new HashMap<>();
+            built.put("someProperty", "someConverter");
+            return built;
+        });
+        Map<String, Object> second = 
holder.computeMappingIfAbsent(String.class, clazz -> {
+            builds.incrementAndGet();
+            return new HashMap<>();
+        });
+
+        assertThat(builds.get()).isEqualTo(1);
+        assertThat(first).containsEntry("someProperty", "someConverter");
+        assertThat(second).isSameAs(first);
+    }
+
+    public void testComputeMappingIfAbsentNegativeCachesEmptyResult() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        AtomicInteger builds = new AtomicInteger();
+
+        Map<String, Object> first = 
holder.computeMappingIfAbsent(String.class, clazz -> {
+            builds.incrementAndGet();
+            return Collections.emptyMap();
+        });
+        Map<String, Object> second = 
holder.computeMappingIfAbsent(String.class, clazz -> {
+            builds.incrementAndGet();
+            return Collections.emptyMap();
+        });
+
+        assertThat(first).isEmpty();
+        assertThat(second).isEmpty();
+        assertThat(builds.get()).as("empty result must be negative 
cached").isEqualTo(1);
+        assertThat(holder.containsNoMapping(String.class)).isTrue();
+    }
+
+    public void testComputeMappingIfAbsentNegativeCachesNullResult() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+
+        Map<String, Object> result = 
holder.computeMappingIfAbsent(String.class, clazz -> null);
+
+        assertThat(result).isNotNull().isEmpty();
+        assertThat(holder.containsNoMapping(String.class)).isTrue();
+    }
+
+    public void testComputeMappingIfAbsentShortCircuitsOnKnownNoMapping() {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        holder.addNoMapping(String.class);
+
+        Map<String, Object> result = 
holder.computeMappingIfAbsent(String.class, clazz -> {
+            throw new AssertionError("builder must not run for a 
negative-cached class");
+        });
+
+        assertThat(result).isNotNull().isEmpty();
+    }
+
+    public void testComputeMappingIfAbsentBuildsOnceUnderConcurrency() throws 
Exception {
+        StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
+        AtomicInteger builds = new AtomicInteger();
+        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+        CountDownLatch start = new CountDownLatch(1);
+        List<Future<Map<String, Object>>> futures = new ArrayList<>();
+
+        for (int t = 0; t < THREADS; t++) {
+            futures.add(pool.submit(() -> {
+                start.await();
+                return holder.computeMappingIfAbsent(String.class, clazz -> {
+                    builds.incrementAndGet();
+                    Map<String, Object> built = new HashMap<>();
+                    built.put("someProperty", "someConverter");
+                    return built;
+                });
+            }));
+        }
+
+        start.countDown();
+        Map<String, Object> expected = futures.get(0).get(60, 
TimeUnit.SECONDS);
+        for (Future<Map<String, Object>> future : futures) {
+            assertThat(future.get(60, TimeUnit.SECONDS)).isSameAs(expected);
+        }
+        pool.shutdown();
+
+        assertThat(builds.get()).as("mapping must be built exactly 
once").isEqualTo(1);
+    }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest`
+
+Expected: COMPILATION FAILURE — `cannot find symbol: method 
computeMappingIfAbsent`.
+
+- [ ] **Step 3: Add the `default` method to the SPI**
+
+In `TypeConverterHolder.java`, add imports:
+
+```java
+import java.util.Collections;
+import java.util.Map;
+import java.util.function.Function;
+```
+
+Add `@Deprecated` to exactly three existing methods, keeping their existing 
Javadoc and adding a `@deprecated` tag to each:
+
+```java
+    /**
+     * Target class conversion Mappings.
+     *
+     * @param clazz class to convert to/from
+     * @return {@link TypeConverter} for given class
+     * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, 
Function)} which resolves
+     * and caches the mapping atomically instead of requiring a check-then-act 
at the call site.
+     */
+    @Deprecated
+    Map<String, Object> getMapping(Class clazz);
+
+    /**
+     * Assign mapping of converters for given class
+     *
+     * @param clazz   class to convert to/from
+     * @param mapping property converters
+     * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, 
Function)} which stores
+     * the built mapping itself.
+     */
+    @Deprecated
+    void addMapping(Class clazz, Map<String, Object> mapping);
+
+    /**
+     * Check if there is no mapping for given class to convert
+     *
+     * @param clazz class to convert to/from
+     * @return true if mapping couldn't be found
+     * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, 
Function)} which returns
+     * an empty map for classes known to have no mapping.
+     */
+    @Deprecated
+    boolean containsNoMapping(Class clazz);
+```
+
+Leave `addNoMapping`, `addDefaultMapping`, `containsDefaultMapping`, 
`getDefaultMapping`, `containsUnknownMapping` and `addUnknownMapping` 
**undeprecated**.
+
+Add the new method at the end of the interface, before the closing brace:
+
+```java
+    /**
+     * Returns the property-converter mapping for the given class, building 
and caching it on first
+     * use. Never returns {@code null}: a class known to have no mapping yields
+     * {@link Collections#emptyMap()}.
+     *
+     * <p>If the builder returns {@code null} or an empty map, the class is 
recorded in the negative
+     * cache so the builder is not invoked for it again.</p>
+     *
+     * <p>Implementations are expected to make this atomic so that the builder 
runs at most once per
+     * class. The default implementation is a non-atomic check-then-act using 
the deprecated
+     * primitives, preserving pre-7.3.0 behaviour for third-party holders that 
do not override it.</p>
+     *
+     * @param clazz   class to convert to/from
+     * @param builder builds the property-converter mapping for the class when 
it is not yet cached
+     * @return the mapping for the class, or an empty map if it has none
+     * @since 7.3.0
+     */
+    @SuppressWarnings("deprecation")
+    default Map<String, Object> computeMappingIfAbsent(Class clazz, 
Function<Class, Map<String, Object>> builder) {
+        if (containsNoMapping(clazz)) {
+            return Collections.emptyMap();
+        }
+        Map<String, Object> mapping = getMapping(clazz);
+        if (mapping != null) {
+            return mapping;
+        }
+        mapping = builder.apply(clazz);
+        if (mapping == null || mapping.isEmpty()) {
+            addNoMapping(clazz);
+            return Collections.emptyMap();
+        }
+        addMapping(clazz, mapping);
+        return mapping;
+    }
+```
+
+The `@SuppressWarnings("deprecation")` is deliberate: the fallback path must 
keep using the old primitives, because those are the only methods a third-party 
implementation is guaranteed to provide.
+
+- [ ] **Step 4: Override it in `StrutsTypeConverterHolder`**
+
+Add imports to `StrutsTypeConverterHolder.java`:
+
+```java
+import java.util.Collections;
+import java.util.function.Function;
+```
+
+Add the override after `getMapping`, and mark the three implementing methods 
`@Deprecated` to match the interface:
+
+Negative results are stored as a sentinel in the same map, so 
`computeIfAbsent` deduplicates the builder on **both** paths. This matters 
because the no-mapping case is the *common* one — an ordinary action class has 
no `-conversion.properties` and no `@Conversion` annotations, yet 
`buildConverterMapping` still walks its whole hierarchy doing classpath lookups 
and reflection. Returning `null` from the mapping function would store nothing, 
letting every concurrent caller re-run that walk: the exact thundering herd 
this method exists to prevent.
+
+Add the sentinel. It MUST be a distinct instance, not 
`Collections.emptyMap()`, whose shared JDK singleton could collide with an 
empty map a caller passed to `addMapping`:
+
+```java
+    private static final Map<String, Object> NO_MAPPING = 
Collections.unmodifiableMap(new HashMap<>());
+```
+
+Delete the `noMapping` field — the sentinel replaces it. Then:
+
+```java
+    @Override
+    @Deprecated
+    public Map<String, Object> getMapping(Class clazz) {
+        Map<String, Object> mapping = mappings.get(clazz);
+        return mapping == NO_MAPPING ? null : mapping;
+    }
+
+    @Override
+    @Deprecated
+    public boolean containsNoMapping(Class clazz) {
+        return mappings.get(clazz) == NO_MAPPING;
+    }
+
+    @Override
+    public void addNoMapping(Class clazz) {
+        mappings.put(clazz, NO_MAPPING);
+    }
+
+    @Override
+    public Map<String, Object> computeMappingIfAbsent(Class clazz, 
Function<Class, Map<String, Object>> builder) {
+        return mappings.computeIfAbsent(clazz, c -> {
+            Map<String, Object> built = builder.apply(c);
+            return (built == null || built.isEmpty()) ? NO_MAPPING : built;
+        });
+    }

Review Comment:
   Fixed in bd70047a1 — the plan snippet now shows the shipped `get` → build → 
`putIfAbsent` form, with a note explaining why the builder deliberately runs 
outside `computeIfAbsent` (it instantiates arbitrary, Spring-autowired 
`TypeConverter` classes, which under a CHM bin lock risks 
`IllegalStateException: Recursive update` or self-deadlock) and that the 
trade-off is a possible duplicate build under concurrent first access.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to