[ 
https://issues.apache.org/jira/browse/WW-5539?focusedWorklogId=1031622&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1031622
 ]

ASF GitHub Bot logged work on WW-5539:
--------------------------------------

                Author: ASF GitHub Bot
            Created on: 22/Jul/26 07:39
            Start Date: 22/Jul/26 07:39
    Worklog Time Spent: 10m 
      Work Description: Copilot commented on code in PR #1799:
URL: https://github.com/apache/struts/pull/1799#discussion_r3628373821


##########
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:
   `computeMappingIfAbsent` calls methods annotated `@Deprecated(forRemoval = 
true)`, which typically triggers both `deprecation` and `removal` compiler 
warnings. The current `@SuppressWarnings("deprecation")` won’t suppress 
`removal` warnings; consider suppressing both to keep builds warning-clean.



##########
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:
   The Javadoc for `getMapping` has an incorrect `@return` description (it 
returns a `Map<String, Object>`, not a `TypeConverter`) and it claims 
`computeMappingIfAbsent` caches “atomically”, but the interface contract 
explicitly allows the builder to run more than once under concurrent first 
access. Updating the Javadoc avoids misleading SPI implementers.



##########
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:
   This plan states the produced API includes `protected final Set<String> 
unknownMappings`, but the implemented approach in this PR keeps `protected 
HashSet<String> unknownMappings` for binary compatibility and uses a separate 
private concurrent set as the live store. The plan should reflect the actual 
field type/compatibility constraint.



##########
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:
   This section says `unknownMappings` is retyped to `Set<String>` and made 
`final`, but the implementation in this PR keeps the `protected HashSet<String> 
unknownMappings` field for binary compatibility and moves the live data to a 
private concurrent set. Updating the design doc to match the actual 
compatibility decision will avoid confusing readers.



##########
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:
   The plan’s `computeMappingIfAbsent` override snippet uses 
`mappings.computeIfAbsent(...)`, but the implementation in this PR explicitly 
avoids running the builder under a `ConcurrentHashMap` bin lock (to prevent 
recursive-update/self-deadlock hazards) and instead does `get` → build → 
`putIfAbsent`. Updating the snippet keeps the plan consistent with the shipped 
design and test expectations (builder may run more than once under concurrent 
first access).





Issue Time Tracking
-------------------

    Worklog Id:     (was: 1031622)
    Time Spent: 50m  (was: 40m)

> Concurrency performance enhancements
> ------------------------------------
>
>                 Key: WW-5539
>                 URL: https://issues.apache.org/jira/browse/WW-5539
>             Project: Struts 2
>          Issue Type: Improvement
>          Components: Core
>    Affects Versions: 6.7.4, 7.0.3
>            Reporter: Kusal Kithul-Godage
>            Assignee: Lukasz Lenart
>            Priority: Minor
>             Fix For: 7.3.0
>
>          Time Spent: 50m
>  Remaining Estimate: 0h
>
> We've identified some code which can be made more performant by improving the 
> locking mechanism in the following classes:
>  * XWorkConverter
>  * DefaultActionValidatorManager
>  * StrutsTypeConverterHolder



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to