WW-5539 Concurrency performance enhancements (#1799)

* WW-5539 docs: add concurrency performance enhancements design

Design for removing coarse locks from XWorkConverter,
DefaultActionValidatorManager and StrutsTypeConverterHolder in favour of
concurrent collections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 docs: add concurrency performance implementation plan

Five tasks derived from the approved design: make StrutsTypeConverterHolder
concurrent, add the computeMappingIfAbsent SPI method, remove the locks from
XWorkConverter and DefaultActionValidatorManager, then benchmark and raise
the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 docs: make conditionalReload store its rebuilt mapping

buildConverterMapping no longer stores its result, so reload mode would
have rebuilt from disk on every request without ever caching.

* 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.

* WW-5539 Rename test to match what it actually covers

The method exercised only the unknown-mapping cache, not noMapping.

* WW-5539 Add TypeConverterHolder#computeMappingIfAbsent

Adds an atomic build-once-and-cache operation so callers no longer need
check-then-act around the class mapping cache, and deprecates the three
primitives it subsumes: getMapping, addMapping and containsNoMapping.

The method is a default method delegating to those primitives, so
third-party TypeConverterHolder implementations keep working unchanged.

* WW-5539 Deduplicate the no-mapping path in computeMappingIfAbsent

ConcurrentHashMap.computeIfAbsent stores nothing when the mapping function
returns null, so every concurrent caller re-ran the builder for a class
with no conversion mapping - the common case for an ordinary action, and
the exact thundering herd this method exists to prevent.

Negative results now store a sentinel in the same map, so the builder runs
once per class either way. getMapping and containsNoMapping translate the
sentinel, preserving their existing contracts.

* WW-5539 docs: sync plan with negative-cache sentinel fix

* WW-5539 Pin down addNoMapping's override semantics

Storing the no-mapping sentinel deliberately replaces any mapping cached
for the class, matching the pre-7.3.0 effective behaviour where such a
class was short-circuited before its cached mapping was ever read.
putIfAbsent would instead serve a stale mapping after a failed build.

Also asserts the sentinel translation in getMapping directly, and stops
the interface javadoc promising a specific empty-map instance that
implementations are not required to return.

* WW-5539 Document that addNoMapping may replace a cached mapping

The behaviour was documented only on the Struts implementation, but
addNoMapping stays a non-deprecated SPI primitive that third parties both
call and implement, so the contract belongs on the interface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 Remove coarse locks from XWorkConverter

getConverter() synchronized on the Class object being converted, which is
a globally visible monitor any other library may contend on, and which
serialised every conversion for a given action class including cache
hits. It now delegates to TypeConverterHolder#computeMappingIfAbsent.

registerConverter and registerConverterNotFound drop their synchronized
modifier; they are single delegations to a concurrent map, and the lock
never covered the readers in lookup() in any case.

buildConverterMapping no longer stores its result - storage is owned by
computeMappingIfAbsent.

* WW-5539 Remove global lock from DefaultActionValidatorManager

getValidators() was synchronized on the singleton manager, so every
validated request in the application serialised on it - and the lock
covered the per-request Validator construction loop, which operates on
per-request objects and never needed mutual exclusion.

Both caches become ConcurrentHashMap and cached config lists are wrapped
unmodifiable, since several threads now iterate them concurrently.

* WW-5539 Make the validator concurrency test race a cold cache

The test computed its expected count with a getValidators call before
starting the threads, which warmed the cache and left all 16 workers on
the fast path - never exercising first-build contention, the race the
test is named for.

Also drops an unused import and awaits executor termination.

* WW-5539 Address final review findings

Restores the protected unknownMappings field verbatim as a deprecated,
unused vestige: retyping it changed the field descriptor, so a subclass
compiled against 7.2.0 would have hit NoSuchFieldError on upgrade without
recompiling. Real storage moves to a private concurrent set.

Also stops conditionalReload running for negative-cached classes, which
had been costing a failed classloader resource scan per property per
request in devMode, and restores the unknown-mapping clearing that the
null-converter guard was skipping.

* WW-5539 Fix concurrency regressions from coarse-lock removal

Four correctness fixes surfaced in PR review of the concurrent-collections
refactor:

- StrutsTypeConverterHolder.addDefaultMapping: restore put-before-remove
  ordering. The inverted order let a concurrent XWorkConverter.lookup
  observe (unknown=false, default=false), sending it into lookupSuper()
  and letting it overwrite the more specific converter being registered.
- StrutsTypeConverterHolder.computeMappingIfAbsent: stop building inside a
  ConcurrentHashMap bin lock. The builder reaches ObjectFactory.buildConverter,
  which can autowire arbitrary user TypeConverters; running that under a CHM
  bin lock risked a recursive-update exception or self-deadlock. Callers now
  only get the guarantee that they converge on the same cached instance, not
  that the builder runs exactly once - documented on the interface and
  reflected in the concurrency tests.
- DefaultValidatorFactory.validators: switch to ConcurrentHashMap now that
  DefaultActionValidatorManager.getValidators is no longer synchronized,
  so runtime registerValidator() calls no longer race unsynchronized reads
  of a plain HashMap.
- XWorkConverter.conditionalReload: route empty devMode rebuilds through
  addNoMapping instead of addMapping, so an empty reload result is stored
  as the NO_MAPPING sentinel rather than a plain empty map that would
  silently disable further reloads for the class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 Add tests closing coverage gaps from coarse-lock removal

SonarCloud's quality gate failed at 59.8% coverage on new code (need
>=80%). Adds tests for the specific lines JaCoCo identified as
uncovered, without touching production code:

- TypeConverterHolder.computeMappingIfAbsent's default method body
  (the SPI compatibility fallback for third-party holders that predate
  7.3.0 and don't override it) - new TypeConverterHolderTest against a
  minimal non-overriding implementation.
- StrutsTypeConverterHolder.getMapping/containsNoMapping's remaining
  non-sentinel branch.
- XWorkConverter.conditionalReload's reloadingConfigs==true path (both
  the addMapping and addNoMapping outcomes), buildConverterMappingUnchecked's
  checked-to-IllegalStateException wrapping, and getConverter's
  catch(Throwable) negative-caching.
- DefaultActionValidatorManager's else-if(reloadingConfigs) cache
  rebuild, loadFile's checkFile&&fileNeedsReloading re-parse, and
  buildValidatorConfigs' already-checked short-circuit.

TypeConverterHolder.java and StrutsTypeConverterHolder.java are now at
0 missed lines/branches. XWorkConverter.java and
DefaultActionValidatorManager.java have all requested target lines
covered; remaining misses are pre-existing, unrelated gaps left alone
per scope. Full core suite: 3026 tests (3015 + 11 new), 0
failures/errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 Fix SonarCloud deprecation and test-hygiene issues

- Add since/forRemoval attributes to the 7 @Deprecated elements on
  TypeConverterHolder.getMapping/addMapping/containsNoMapping and
  StrutsTypeConverterHolder's overrides plus the unknownMappings field
  (java:S6355).
- Add the missing @deprecated Javadoc tag to the three
  StrutsTypeConverterHolder overrides, pointing at
  computeMappingIfAbsent as the replacement (java:S1123).
- Remove the unused throws Exception from
  testGetConverterBuildsMappingExactlyOncePerClass (java:S1130).
- Document why StubFileManager.setReloadingConfigs/monitorFile are
  intentionally empty no-ops (java:S1186).
- Rename a local variable that shadowed the converter field in
  testConditionalReloadRebuildsEmptyMappingAndStoresItViaAddNoMapping
  (java:S1117).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 Suppress removal warnings for the deprecated holder primitives

javac treats [removal] as a category separate from [deprecation], so
marking the three primitives forRemoval left four warnings behind: the
deliberate addMapping call in conditionalReload, and the three overrides
that must exist for as long as the interface declares them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5539 Address Copilot review comments

Fixes getMapping's @return (it returns a Map, not a TypeConverter) and
drops the "atomically" wording from its @deprecated tag, which no longer
matches computeMappingIfAbsent's contract now that the builder may run
more than once under concurrent first access.

Syncs the design and plan docs with the shipped approach: the
unknownMappings field is kept for binary compatibility rather than
retyped, and the override uses get/build/putIfAbsent rather than
computeIfAbsent.

* WW-5539 docs: correct the classloader out-of-scope note

The conversion caches are container-scoped singletons with no external
references, so their Class keys do not independently pin the webapp
classloader - that is governed by whatever retains the container (WW-5537).
Reframed as optional defense-in-depth cache clearing, folded into WW-5537
Task 5b, rather than a standalone leak fix.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-07-25 12:30:53 +02:00
committed by GitHub
parent 73f17c1be7
commit 8a5323fbdf
11 changed files with 2809 additions and 73 deletions
@@ -18,15 +18,24 @@
*/
package org.apache.struts2.conversion;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Default implementation of {@link TypeConverterHolder}
*/
public class StrutsTypeConverterHolder implements TypeConverterHolder {
private static final Logger LOG = LogManager.getLogger(StrutsTypeConverterHolder.class);
/**
* Record class and its type converter mapping.
* <pre>
@@ -34,7 +43,7 @@ public class StrutsTypeConverterHolder implements TypeConverterHolder {
* - TypeConverter - instance of TypeConverter
* </pre>
*/
private final HashMap<String, TypeConverter> defaultMappings = new HashMap<>(); // non-action (eg. returned value)
private final Map<String, TypeConverter> defaultMappings = new ConcurrentHashMap<>(); // non-action (eg. returned value)
/**
* Target class conversion Mappings.
@@ -55,25 +64,47 @@ public class StrutsTypeConverterHolder implements TypeConverterHolder {
* Element_property=foo.bar.MyObject
* </pre>
*/
private final HashMap<Class, Map<String, Object>> mappings = new HashMap<>(); // action
private final Map<Class, Map<String, Object>> mappings = new ConcurrentHashMap<>(); // action
/**
* Unavailable target class conversion mappings, serves as a simple cache.
* Marker stored in {@link #mappings} for classes known to have no conversion mapping, so that
* negative results are cached in the same atomic operation as positive ones. Deliberately a
* distinct instance rather than {@link Collections#emptyMap()}, whose shared singleton could
* collide with an empty mapping supplied by a caller.
*/
private final HashSet<Class> noMapping = new HashSet<>(); // action
private static final Map<String, Object> NO_MAPPING = Collections.unmodifiableMap(new HashMap<>());
/**
* Record classes that doesn't have conversion mapping defined.
* <pre>
* - String -&gt; classname as String
* </pre>
*
* @deprecated since 7.3.0, unused - superseded by internal concurrent storage. Retained only
* for binary compatibility with subclasses compiled against earlier versions, and will be
* removed in a future release.
*/
protected HashSet<String> unknownMappings = new HashSet<>(); // non-action (eg. returned value)
@Deprecated(since = "7.3.0", forRemoval = true)
protected HashSet<String> unknownMappings = new HashSet<>();
/**
* Actual storage for classes with no registered converter. Concurrent, so that lock-free
* readers in {@code XWorkConverter.lookup} cannot race writers.
*/
private final Set<String> unknownMappingsInternal = ConcurrentHashMap.newKeySet();
@Override
public void addDefaultMapping(String className, TypeConverter typeConverter) {
if (typeConverter == null) {
LOG.warn("Ignoring null TypeConverter registered for class [{}]", className);
return;
}
// Order is load-bearing: registering the converter before clearing the unknown flag means a
// concurrent XWorkConverter.lookup can never observe (unknown=false, default=false) for this
// class - a state that would otherwise send it down the lookupSuper() path and let it
// overwrite the more specific converter being registered here with a broader one.
defaultMappings.put(className, typeConverter);
unknownMappings.remove(className);
unknownMappingsInternal.remove(className);
}
@Override
@@ -86,34 +117,84 @@ public class StrutsTypeConverterHolder implements TypeConverterHolder {
return defaultMappings.get(className);
}
/**
* Returns {@code null} if {@code clazz} has been flagged as having no mapping via
* {@link #addNoMapping(Class)}, even if a real mapping was previously stored for it.
*
* @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead.
*/
// Implementing the deprecated interface primitives is mandatory until they are removed.
@SuppressWarnings("removal")
@Override
@Deprecated(since = "7.3.0", forRemoval = true)
public Map<String, Object> getMapping(Class clazz) {
return mappings.get(clazz);
Map<String, Object> mapping = mappings.get(clazz);
return mapping == NO_MAPPING ? null : mapping;
}
/**
* @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead.
*/
@SuppressWarnings("removal")
@Override
@Deprecated(since = "7.3.0", forRemoval = true)
public void addMapping(Class clazz, Map<String, Object> mapping) {
mappings.put(clazz, mapping);
}
/**
* @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead.
*/
@SuppressWarnings("removal")
@Override
@Deprecated(since = "7.3.0", forRemoval = true)
public boolean containsNoMapping(Class clazz) {
return noMapping.contains(clazz);
return mappings.get(clazz) == NO_MAPPING;
}
/**
* Stores the {@link #NO_MAPPING} sentinel for the given class, replacing any mapping previously
* cached for it. This matches the pre-7.3.0 effective behaviour, back when a separate no-mapping
* collection was consulted independently of the mappings map: the only in-tree caller,
* {@code XWorkConverter.getConverter}, checked the no-mapping flag first and short-circuited
* before the cached mapping was ever read, so flagging a class as no-mapping made it behave as
* though it had no mapping, real cached mapping or not. {@link Map#putIfAbsent} is deliberately
* not used here: it would leave a stale mapping being served for a class whose conversion build
* subsequently failed, which is a genuine behaviour change rather than a faithful port.
*/
@Override
public void addNoMapping(Class clazz) {
mappings.put(clazz, NO_MAPPING);
}
@Override
public void addNoMapping(Class clazz) {
noMapping.add(clazz);
public Map<String, Object> computeMappingIfAbsent(Class clazz, Function<Class, Map<String, Object>> builder) {
// Deliberately not implemented with mappings.computeIfAbsent(...): that would run the builder
// while holding the ConcurrentHashMap's internal bin lock. The builder reaches
// ObjectFactory.buildConverter(...), which instantiates (and, under SpringObjectFactory,
// autowires) an arbitrary user-supplied TypeConverter - constructors, @PostConstruct,
// afterPropertiesSet. Running that under a bin lock risks IllegalStateException("Recursive
// update") or a self-deadlock if any of it re-enters conversion. Instead the builder runs
// outside any lock, at the cost of allowing it to run more than once under first-access
// contention; putIfAbsent ensures every caller still converges on the same cached instance.
Map<String, Object> existing = mappings.get(clazz);
if (existing != null) {
return existing;
}
Map<String, Object> built = builder.apply(clazz);
Map<String, Object> value = (built == null || built.isEmpty()) ? NO_MAPPING : built;
Map<String, Object> previous = mappings.putIfAbsent(clazz, value);
return previous != null ? previous : value;
}
@Override
public boolean containsUnknownMapping(String className) {
return unknownMappings.contains(className);
return unknownMappingsInternal.contains(className);
}
@Override
public void addUnknownMapping(String className) {
unknownMappings.add(className);
unknownMappingsInternal.add(className);
}
}
@@ -18,7 +18,9 @@
*/
package org.apache.struts2.conversion;
import java.util.Collections;
import java.util.Map;
import java.util.function.Function;
/**
* Holds all mappings related to {@link TypeConverter}s
@@ -52,9 +54,16 @@ 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
* @return the property-converter mapping for the given class, or {@code null} if none
* @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)}, which resolves
* and caches the mapping in one call instead of requiring a check-then-act at the call site.
*/
@Deprecated(since = "7.3.0", forRemoval = true)
Map<String, Object> getMapping(Class clazz);
/**
@@ -62,7 +71,10 @@ public interface TypeConverterHolder {
*
* @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(since = "7.3.0", forRemoval = true)
void addMapping(Class clazz, Map<String, Object> mapping);
/**
@@ -70,11 +82,16 @@ public interface TypeConverterHolder {
*
* @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(since = "7.3.0", forRemoval = true)
boolean containsNoMapping(Class clazz);
/**
* Adds no mapping flag for give class
* Adds no mapping flag for give class. Flagging a class as having no mapping may replace
* any mapping previously cached for it; callers should treat this flag as authoritative
* over a previously cached mapping.
*
* @param clazz class to register missing converter
*/
@@ -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")
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;
}
}
@@ -412,34 +412,43 @@ public class XWorkConverter extends DefaultTypeConverter {
}
protected Object getConverter(Class clazz, String property) {
LOG.debug("Retrieving convert for class [{}] and property [{}]", clazz, property);
LOG.debug("Retrieving converter for class [{}] and property [{}]", clazz, property);
synchronized (clazz) {
if ((property != null) && !converterHolder.containsNoMapping(clazz)) {
try {
Map<String, Object> mapping = converterHolder.getMapping(clazz);
if (property == null) {
return null;
}
try {
Map<String, Object> mapping = converterHolder.computeMappingIfAbsent(clazz, this::buildConverterMappingUnchecked);
if (!mapping.isEmpty()) {
mapping = conditionalReload(clazz, mapping);
}
if (mapping == null) {
mapping = buildConverterMapping(clazz);
} else {
mapping = conditionalReload(clazz, mapping);
}
Object converter = mapping.get(property);
if (converter == null && LOG.isDebugEnabled()) {
LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size());
for (Map.Entry<String, Object> entry : mapping.entrySet()) {
LOG.debug("{}:{}", entry.getKey(), entry.getValue());
}
}
return converter;
} catch (Throwable t) {
LOG.debug("Got exception trying to resolve convert for class [{}] and property [{}]", clazz, property, t);
converterHolder.addNoMapping(clazz);
Object converter = mapping.get(property);
if (converter == null && LOG.isDebugEnabled()) {
LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size());
for (Map.Entry<String, Object> entry : mapping.entrySet()) {
LOG.debug("{}:{}", entry.getKey(), entry.getValue());
}
}
return converter;
} catch (Throwable t) {
LOG.debug("Got exception trying to resolve converter for class [{}] and property [{}]", clazz, property, t);
converterHolder.addNoMapping(clazz);
return null;
}
}
/**
* Adapts {@link #buildConverterMapping(Class)} to {@link java.util.function.Function} by
* rethrowing its checked exception unchecked. The caller's {@code catch (Throwable)} still
* negative-caches the class, so behaviour is unchanged.
*/
private Map<String, Object> buildConverterMappingUnchecked(Class clazz) {
try {
return buildConverterMapping(clazz);
} catch (Exception e) {
throw new IllegalStateException("Could not build converter mapping for " + clazz, e);
}
return null;
}
protected void handleConversionException(Map<String, Object> context, String property, Object value, Object object, Class toClass) {
@@ -463,11 +472,11 @@ public class XWorkConverter extends DefaultTypeConverter {
}
}
public synchronized void registerConverter(String className, TypeConverter converter) {
public void registerConverter(String className, TypeConverter converter) {
converterHolder.addDefaultMapping(className, converter);
}
public synchronized void registerConverterNotFound(String className) {
public void registerConverterNotFound(String className) {
converterHolder.addUnknownMapping(className);
}
@@ -547,6 +556,8 @@ public class XWorkConverter extends DefaultTypeConverter {
* @param clazz the class to look for converter mappings for
* @return the converter mappings
* @throws Exception in case of any errors
* @since 7.3.0 this method no longer stores the built mapping in the {@link TypeConverterHolder};
* storage is owned by {@link TypeConverterHolder#computeMappingIfAbsent(Class, java.util.function.Function)}.
*/
protected Map<String, Object> buildConverterMapping(Class clazz) throws Exception {
Map<String, Object> mapping = new HashMap<>();
@@ -568,15 +579,10 @@ public class XWorkConverter extends DefaultTypeConverter {
curClazz = curClazz.getSuperclass();
}
if (!mapping.isEmpty()) {
converterHolder.addMapping(clazz, mapping);
} else {
converterHolder.addNoMapping(clazz);
}
return mapping;
}
@SuppressWarnings({"deprecation", "removal"})
private Map<String, Object> conditionalReload(Class clazz, Map<String, Object> oldValues) throws Exception {
Map<String, Object> mapping = oldValues;
@@ -584,6 +590,13 @@ public class XWorkConverter extends DefaultTypeConverter {
URL fileUrl = ClassLoaderUtil.getResource(buildConverterFilename(clazz), clazz);
if (fileManager.fileNeedsReloading(fileUrl)) {
mapping = buildConverterMapping(clazz);
if (mapping.isEmpty()) {
converterHolder.addNoMapping(clazz);
} else {
// addMapping is deprecated but remains the correct primitive here:
// computeMappingIfAbsent cannot express an unconditional overwrite.
converterHolder.addMapping(clazz, mapping);
}
}
}
@@ -35,13 +35,11 @@ import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import static java.util.Collections.synchronizedMap;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>
@@ -67,8 +65,8 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
*/
protected static final String VALIDATION_CONFIG_SUFFIX = "-validation.xml";
protected final Map<String, List<ValidatorConfig>> validatorCache = synchronizedMap(new HashMap<>());
protected final Map<String, List<ValidatorConfig>> validatorFileCache = synchronizedMap(new HashMap<>());
protected final Map<String, List<ValidatorConfig>> validatorCache = new ConcurrentHashMap<>();
protected final Map<String, List<ValidatorConfig>> validatorFileCache = new ConcurrentHashMap<>();
private static final Logger LOG = LogManager.getLogger(DefaultActionValidatorManager.class);
protected ValidatorFactory validatorFactory;
@@ -137,17 +135,19 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
}
@Override
public synchronized List<Validator> getValidators(Class<?> clazz, String context, String method) {
public List<Validator> getValidators(Class<?> clazz, String context, String method) {
String validatorKey = buildValidatorKey(clazz, context);
if (!validatorCache.containsKey(validatorKey)) {
validatorCache.put(validatorKey, buildValidatorConfigs(clazz, context, false, null));
List<ValidatorConfig> configs = validatorCache.get(validatorKey);
if (configs == null) {
configs = validatorCache.computeIfAbsent(validatorKey,
key -> buildValidatorConfigs(clazz, context, false, null));
} else if (reloadingConfigs) {
validatorCache.put(validatorKey, buildValidatorConfigs(clazz, context, true, null));
configs = buildValidatorConfigs(clazz, context, true, null);
validatorCache.put(validatorKey, configs);
}
ValueStack stack = ActionContext.getContext().getValueStack();
List<ValidatorConfig> configs = validatorCache.get(validatorKey);
List<Validator> validators = new ArrayList<>();
for (ValidatorConfig config : configs) {
if (method == null || method.equals(config.getParams().get("methodName"))) {
@@ -158,7 +158,7 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
}
@Override
public synchronized List<Validator> getValidators(Class<?> clazz, String context) {
public List<Validator> getValidators(Class<?> clazz, String context) {
return getValidators(clazz, context, null);
}
@@ -283,7 +283,7 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
if (checked == null) {
checked = new TreeSet<>();
} else if (checked.contains(clazz.getName())) {
return validatorConfigs;
return Collections.emptyList();
}
if (clazz.isInterface()) {
@@ -314,7 +314,7 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
}
checked.add(clazz.getName());
return validatorConfigs;
return Collections.unmodifiableList(validatorConfigs);
}
protected List<ValidatorConfig> buildAliasValidatorConfigs(Class<?> aClass, String context, boolean checkFile) {
@@ -328,22 +328,35 @@ public class DefaultActionValidatorManager implements ActionValidatorManager {
}
protected List<ValidatorConfig> loadFile(String fileName, Class<?> clazz, boolean checkFile) {
List<ValidatorConfig> retList = Collections.emptyList();
URL fileUrl = ClassLoaderUtil.getResource(fileName, clazz);
if ((checkFile && fileManager.fileNeedsReloading(fileUrl)) || !validatorFileCache.containsKey(fileName)) {
try (InputStream is = fileManager.loadFile(fileUrl)) {
if (is != null) {
retList = new ArrayList<>(validatorFileParser.parseActionValidatorConfigs(validatorFactory, is, fileName));
}
} catch (IOException e) {
LOG.error("Caught exception while closing file {}", fileName, e);
}
if (checkFile && fileManager.fileNeedsReloading(fileUrl)) {
List<ValidatorConfig> reloaded = parseValidatorConfigs(fileUrl, fileName);
validatorFileCache.put(fileName, reloaded);
return reloaded;
}
validatorFileCache.put(fileName, retList);
} else {
retList = validatorFileCache.get(fileName);
return validatorFileCache.computeIfAbsent(fileName, key -> parseValidatorConfigs(fileUrl, fileName));
}
/**
* Parses the validator configs from the given file, returning an unmodifiable list. Returns an
* empty list when the file does not exist or cannot be read.
*
* @param fileUrl URL of the validation config file, may be null
* @param fileName name of the validation config file, used for logging and parser context
* @return an unmodifiable list of validator configs, never null
*/
protected List<ValidatorConfig> parseValidatorConfigs(URL fileUrl, String fileName) {
List<ValidatorConfig> retList = Collections.emptyList();
try (InputStream is = fileManager.loadFile(fileUrl)) {
if (is != null) {
retList = Collections.unmodifiableList(
new ArrayList<>(validatorFileParser.parseActionValidatorConfigs(validatorFactory, is, fileName)));
}
} catch (IOException e) {
LOG.error("Caught exception while closing file {}", fileName, e);
}
return retList;
@@ -36,10 +36,10 @@ import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -52,7 +52,7 @@ import java.util.zip.ZipInputStream;
*/
public class DefaultValidatorFactory implements ValidatorFactory, Initializable {
protected Map<String, String> validators = new HashMap<>();
protected Map<String, String> validators = new ConcurrentHashMap<>();
private static final Logger LOG = LogManager.getLogger(DefaultValidatorFactory.class);
protected ObjectFactory objectFactory;
protected ValidatorFileParser validatorFileParser;
@@ -0,0 +1,442 @@
/*
* 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.lang.reflect.Field;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
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 java.util.concurrent.atomic.AtomicInteger;
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 testConcurrentUnknownMappingRegistrationLosesNothing() 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();
}
/**
* A {@link Map} that pauses the call to {@code put} for one specific key immediately after the
* underlying write has taken effect, so a test can deterministically observe state exactly
* between two writes without depending on lucky thread scheduling.
*/
private static final class PausingAfterPutMap extends ConcurrentHashMap<String, TypeConverter> {
private final String watchedKey;
private final CountDownLatch writeStarted;
private final CountDownLatch release;
PausingAfterPutMap(String watchedKey, CountDownLatch writeStarted, CountDownLatch release) {
this.watchedKey = watchedKey;
this.writeStarted = writeStarted;
this.release = release;
}
@Override
public TypeConverter put(String key, TypeConverter value) {
TypeConverter result = super.put(key, value);
pauseIfWatched(key, watchedKey, writeStarted, release);
return result;
}
}
/**
* A {@link Set} that pauses the call to {@code remove} for one specific key immediately after
* the underlying write has taken effect, mirroring {@link PausingAfterPutMap} for the unknown-
* mappings collection.
*/
private static final class PausingAfterRemoveSet extends AbstractSet<String> {
private final Set<String> delegate = ConcurrentHashMap.newKeySet();
private final String watchedKey;
private final CountDownLatch writeStarted;
private final CountDownLatch release;
PausingAfterRemoveSet(String watchedKey, CountDownLatch writeStarted, CountDownLatch release) {
this.watchedKey = watchedKey;
this.writeStarted = writeStarted;
this.release = release;
}
@Override
public boolean add(String s) {
return delegate.add(s);
}
@Override
public boolean remove(Object o) {
boolean result = delegate.remove(o);
pauseIfWatched(o, watchedKey, writeStarted, release);
return result;
}
@Override
public boolean contains(Object o) {
return delegate.contains(o);
}
@Override
public Iterator<String> iterator() {
return delegate.iterator();
}
@Override
public int size() {
return delegate.size();
}
}
/**
* Signals {@code writeStarted} and blocks on {@code release} the first time it is called for
* {@code watchedKey}. Safe to wire into both backing collections with the same latch pair:
* whichever write executes first pauses here for the test to inspect state; the other write's
* call is a no-op (both latches are already at zero by the time it runs).
*/
private static void pauseIfWatched(Object key, String watchedKey, CountDownLatch writeStarted, CountDownLatch release) {
if (!watchedKey.equals(key)) {
return;
}
writeStarted.countDown();
try {
if (!release.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("release latch was never counted down");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private static void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = StrutsTypeConverterHolder.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/**
* Pins the write ordering inside {@code addDefaultMapping}: registering the converter must
* become visible before the unknown-mapping flag is cleared. If the order were inverted, a
* concurrent reader could observe {@code (containsUnknownMapping == false && containsDefaultMapping
* == false)} for a class that started out flagged unknown - a state that sends
* {@code XWorkConverter.lookup} down the {@code lookupSuper()} path, letting it overwrite the more
* specific converter this method is in the middle of registering with a broader one.
*
* <p>The window between the two writes is a couple of instructions wide, so rather than relying
* on a lucky interleaving, this replaces the holder's two backing collections with variants that
* deterministically pause immediately after whichever one is written first, letting the test
* inspect the exact intermediate state {@code addDefaultMapping} produces.</p>
*/
public void testAddDefaultMappingNeverExposesForbiddenIntermediateStateToReaders() throws Exception {
String className = "stub.OrderingProbe";
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
holder.addUnknownMapping(className);
assertThat(holder.containsUnknownMapping(className)).isTrue();
assertThat(holder.containsDefaultMapping(className)).isFalse();
CountDownLatch writeStarted = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
injectField(holder, "defaultMappings", new PausingAfterPutMap(className, writeStarted, release));
injectField(holder, "unknownMappingsInternal", new PausingAfterRemoveSet(className, writeStarted, release));
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
Future<?> writer = pool.submit(() -> holder.addDefaultMapping(className, stubConverter()));
assertThat(writeStarted.await(10, TimeUnit.SECONDS))
.as("addDefaultMapping's first write did not happen in time")
.isTrue();
// Snapshot exactly between the two writes, whichever order they run in.
boolean unknownFlagged = holder.containsUnknownMapping(className);
boolean defaultFlagged = holder.containsDefaultMapping(className);
release.countDown();
writer.get(10, TimeUnit.SECONDS);
assertThat(unknownFlagged || defaultFlagged)
.as("a concurrent reader observed (unknown=false, default=false) mid-registration for "
+ "[%s] - addDefaultMapping must register the converter before clearing the "
+ "unknown flag", className)
.isTrue();
} finally {
pool.shutdown();
}
assertThat(holder.containsUnknownMapping(className)).isFalse();
assertThat(holder.getDefaultMapping(className)).isNotNull();
}
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();
assertThat(holder.getMapping(String.class)).isNull();
}
/**
* Covers the branches of {@code getMapping} and {@code containsNoMapping} that are not the
* {@link StrutsTypeConverterHolder#NO_MAPPING} sentinel: a class with a real cached mapping
* must get that mapping back (not {@code null}), and must not be reported as having no mapping.
*/
public void testGetMappingAndContainsNoMappingReflectRealMapping() {
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
Map<String, Object> real = new HashMap<>();
real.put("someProperty", "someConverter");
holder.addMapping(String.class, real);
assertThat(holder.getMapping(String.class)).isSameAs(real);
assertThat(holder.containsNoMapping(String.class)).isFalse();
}
public void testAddNoMappingOverridesPreviouslyCachedMapping() {
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
Map<String, Object> real = new HashMap<>();
real.put("someProperty", "someConverter");
holder.addMapping(String.class, real);
holder.addNoMapping(String.class);
assertThat(holder.containsNoMapping(String.class)).isTrue();
assertThat(holder.getMapping(String.class)).isNull();
assertThat(holder.computeMappingIfAbsent(String.class, clazz -> {
throw new AssertionError("builder must not run when no mapping is set");
})).isNotNull().isEmpty();
}
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();
}
/**
* {@code computeMappingIfAbsent} deliberately does not run the builder inside a lock (see the
* comment on the production method), so under concurrent first access the builder may run more
* than once. What must still hold - and is the property callers actually depend on - is that
* every caller converges on the same cached mapping instance, with the correct content.
*/
public void testComputeMappingIfAbsentConvergesOnSameInstanceUnderConcurrency() throws Exception {
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
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 -> {
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(expected).containsEntry("someProperty", "someConverter");
}
/**
* Same property as above, for the negative-cache path: every caller must converge on the same
* cached empty mapping instance, and the class must end up flagged as having no mapping.
*/
public void testComputeMappingIfAbsentConvergesOnSameInstanceUnderConcurrencyForUnmappedClass() throws Exception {
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
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 -> Collections.emptyMap());
}));
}
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).isEmpty();
}
pool.shutdown();
assertThat(holder.containsNoMapping(String.class)).isTrue();
}
}
@@ -0,0 +1,151 @@
/*
* 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.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Exercises the {@code default} body of {@link TypeConverterHolder#computeMappingIfAbsent}, the
* SPI compatibility fallback kept for third-party {@link TypeConverterHolder} implementations
* written before 7.3.0 that do not override the method. {@link StrutsTypeConverterHolder} - the
* only in-tree implementation - does override it, so nothing else in the codebase exercises the
* default body; these tests do so directly against a minimal implementation that deliberately
* implements only the original (non-default) primitives, using plain, non-concurrent collections,
* mirroring what a pre-7.3.0 holder looks like.
*/
public class TypeConverterHolderTest extends XWorkTestCase {
/**
* Minimal, non-thread-safe {@link TypeConverterHolder} implementing only the pre-7.3.0
* primitives. Deliberately does not override {@link TypeConverterHolder#computeMappingIfAbsent},
* so calls against it run the interface's default check-then-act body.
*/
private static class LegacyTypeConverterHolder implements TypeConverterHolder {
private final Map<String, TypeConverter> defaultMappings = new HashMap<>();
private final Map<Class, Map<String, Object>> mappings = new HashMap<>();
private final Set<Class> noMapping = new HashSet<>();
private final Set<String> unknownMappings = new HashSet<>();
@Override
public void addDefaultMapping(String className, TypeConverter typeConverter) {
defaultMappings.put(className, typeConverter);
}
@Override
public boolean containsDefaultMapping(String className) {
return defaultMappings.containsKey(className);
}
@Override
public TypeConverter getDefaultMapping(String className) {
return defaultMappings.get(className);
}
@Override
public Map<String, Object> getMapping(Class clazz) {
return mappings.get(clazz);
}
@Override
public void addMapping(Class clazz, Map<String, Object> mapping) {
mappings.put(clazz, mapping);
}
@Override
public boolean containsNoMapping(Class clazz) {
return noMapping.contains(clazz);
}
@Override
public void addNoMapping(Class clazz) {
noMapping.add(clazz);
}
@Override
public boolean containsUnknownMapping(String className) {
return unknownMappings.contains(className);
}
@Override
public void addUnknownMapping(String className) {
unknownMappings.add(className);
}
}
public void testDefaultComputeMappingIfAbsentBuildsOnceAndCaches() {
LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder();
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 -> {
throw new AssertionError("builder must not run again once the mapping is cached");
});
assertThat(builds.get()).isEqualTo(1);
assertThat(first).containsEntry("someProperty", "someConverter");
assertThat(second).isSameAs(first);
}
public void testDefaultComputeMappingIfAbsentNegativeCachesEmptyResult() {
LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder();
AtomicInteger builds = new AtomicInteger();
Map<String, Object> result = holder.computeMappingIfAbsent(String.class, clazz -> {
builds.incrementAndGet();
return new HashMap<>();
});
assertThat(result).isNotNull().isEmpty();
assertThat(builds.get()).as("an empty build result must be negative cached").isEqualTo(1);
assertThat(holder.containsNoMapping(String.class)).isTrue();
}
public void testDefaultComputeMappingIfAbsentNegativeCachesNullResult() {
LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder();
Map<String, Object> result = holder.computeMappingIfAbsent(String.class, clazz -> null);
assertThat(result).isNotNull().isEmpty();
assertThat(holder.containsNoMapping(String.class)).isTrue();
}
public void testDefaultComputeMappingIfAbsentShortCircuitsOnKnownNoMapping() {
LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder();
holder.addNoMapping(String.class);
Map<String, Object> result = holder.computeMappingIfAbsent(String.class, clazz -> {
throw new AssertionError("builder must not run for a class already flagged as having no mapping");
});
assertThat(result).isNotNull().isEmpty();
}
}
@@ -19,6 +19,7 @@
package org.apache.struts2.conversion.impl;
import org.apache.struts2.ActionContext;
import org.apache.struts2.FileManager;
import org.apache.struts2.ModelDrivenAction;
import org.apache.struts2.SimpleAction;
import org.apache.struts2.text.StubTextProvider;
@@ -37,8 +38,11 @@ import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.OgnlRuntime;
import org.apache.struts2.conversion.TypeConverter;
import org.apache.struts2.conversion.StrutsTypeConverterHolder;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URL;
@@ -47,6 +51,12 @@ import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
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 java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertArrayEquals;
@@ -804,6 +814,248 @@ public class XWorkConverterTest extends XWorkTestCase {
assertEquals(converted, Arrays.asList(1, 2, 3));
}
public static class CountingXWorkConverter extends XWorkConverter {
final AtomicInteger builds = new AtomicInteger();
public CountingXWorkConverter() {
super();
}
@Override
protected Map<String, Object> buildConverterMapping(Class clazz) throws Exception {
builds.incrementAndGet();
return super.buildConverterMapping(clazz);
}
}
public void testGetConverterBuildsMappingExactlyOncePerClass() {
CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class);
// a cold, dedicated holder so other tests' cached mappings cannot mask the behaviour
countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder());
Object first = countingConverter.getConverter(User.class, "Collection_list");
Object second = countingConverter.getConverter(User.class, "Collection_list");
assertEquals(String.class, first);
assertEquals(String.class, second);
assertEquals(1, countingConverter.builds.get());
}
/**
* {@code computeMappingIfAbsent} deliberately does not run the builder inside a lock (see
* {@link org.apache.struts2.conversion.StrutsTypeConverterHolder#computeMappingIfAbsent}), so
* under concurrent first access {@code buildConverterMapping} may run more than once. What must
* still hold is that every caller converges on the same, correct result.
*/
public void testGetConverterConvergesOnSameResultUnderConcurrency() throws Exception {
final int threads = 16;
CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class);
countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder());
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
futures.add(pool.submit(() -> {
start.await();
return countingConverter.getConverter(User.class, "Collection_list");
}));
}
start.countDown();
for (Future<Object> future : futures) {
assertEquals(String.class, future.get(60, TimeUnit.SECONDS));
}
pool.shutdown();
}
public void testGetConverterReturnsNullForUnknownProperty() {
XWorkConverter freshConverter = container.inject(XWorkConverter.class);
freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder());
assertNull(freshConverter.getConverter(User.class, "noSuchPropertyAnywhere"));
}
public void testGetConverterReturnsNullForNullProperty() {
XWorkConverter freshConverter = container.inject(XWorkConverter.class);
freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder());
assertNull(freshConverter.getConverter(User.class, null));
}
/**
* Minimal {@link FileManager} whose {@code fileNeedsReloading} answer is fixed at construction,
* so {@code conditionalReload}'s branches can be driven deterministically without depending on
* real file timestamps or the shared, statically-cached {@link org.apache.struts2.util.fs.DefaultFileManager}.
* {@code loadFile} is never exercised through this seam: {@code XWorkConverter.fileManager} is
* only ever read by {@code conditionalReload}'s {@code fileNeedsReloading} call - actual property
* file parsing goes through {@link org.apache.struts2.conversion.impl.DefaultConversionFileProcessor}'s
* own, separately-injected {@link FileManager}.
*/
private static class StubFileManager implements FileManager {
private final boolean needsReloading;
StubFileManager(boolean needsReloading) {
this.needsReloading = needsReloading;
}
@Override
public void setReloadingConfigs(boolean reloadingConfigs) {
// intentionally empty: this stub only controls fileNeedsReloading for the reload
// tests, and nothing in conditionalReload reads back the reloading-configs flag
}
@Override
public boolean fileNeedsReloading(String fileName) {
return needsReloading;
}
@Override
public boolean fileNeedsReloading(URL fileUrl) {
return needsReloading;
}
@Override
public InputStream loadFile(URL fileUrl) {
throw new UnsupportedOperationException("not exercised via XWorkConverter.fileManager");
}
@Override
public void monitorFile(URL fileUrl) {
// intentionally empty: this stub only controls fileNeedsReloading for the reload
// tests, and nothing in conditionalReload depends on file monitoring being registered
}
@Override
public URL normalizeToFileProtocol(URL url) {
return url;
}
@Override
public boolean support() {
return true;
}
@Override
public boolean internal() {
return true;
}
@Override
public Collection<? extends URL> getAllPhysicalUrls(URL url) {
return List.of(url);
}
}
private static void setFileManager(XWorkConverter converter, FileManager fileManager) throws Exception {
Field field = XWorkConverter.class.getDeclaredField("fileManager");
field.setAccessible(true);
field.set(converter, fileManager);
}
/**
* Covers {@code conditionalReload}'s {@code reloadingConfigs == true} branch where the rebuilt
* mapping is real (non-empty), so it is stored via {@code addMapping}. {@code User} has a real
* {@code User-conversion.properties} on the test classpath (see {@code Collection_list} used
* elsewhere in this file), so {@code buildConverterMapping} legitimately returns a non-empty
* mapping both for the initial cache population and for the forced reload.
*/
public void testConditionalReloadRebuildsRealMappingAndStoresItViaAddMapping() throws Exception {
CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class);
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
countingConverter.setTypeConverterHolder(holder);
countingConverter.setReloadingConfigs("true");
setFileManager(countingConverter, new StubFileManager(true));
Object converterForList = countingConverter.getConverter(User.class, "Collection_list");
assertEquals(String.class, converterForList);
assertEquals("buildConverterMapping must run once for the initial cache miss and once more "
+ "for the forced reload",
2, countingConverter.builds.get());
assertFalse("the reload found a real mapping, so the class must not be negative-cached",
holder.containsNoMapping(User.class));
}
/**
* Toggles between a real and an empty mapping across successive {@code buildConverterMapping}
* calls, so the forced reload in {@code conditionalReload} can be made to observe an empty
* rebuild independently of any real property file's contents.
*/
public static class ToggleXWorkConverter extends XWorkConverter {
final AtomicInteger calls = new AtomicInteger();
public ToggleXWorkConverter() {
super();
}
@Override
protected Map<String, Object> buildConverterMapping(Class clazz) {
if (calls.incrementAndGet() == 1) {
Map<String, Object> real = new HashMap<>();
real.put("someProperty", "someConverter");
return real;
}
return Collections.emptyMap();
}
}
/**
* Covers {@code conditionalReload}'s {@code reloadingConfigs == true} branch where the rebuilt
* mapping is empty, so it is routed to {@code addNoMapping} instead of {@code addMapping}.
*/
public void testConditionalReloadRebuildsEmptyMappingAndStoresItViaAddNoMapping() throws Exception {
ToggleXWorkConverter toggleConverter = container.inject(ToggleXWorkConverter.class);
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
toggleConverter.setTypeConverterHolder(holder);
toggleConverter.setReloadingConfigs("true");
setFileManager(toggleConverter, new StubFileManager(true));
Object resolvedConverter = toggleConverter.getConverter(User.class, "someProperty");
assertNull("the reload rebuilt an empty mapping, so no converter can be found", resolvedConverter);
assertEquals("buildConverterMapping must run once for the initial cache miss (real mapping) "
+ "and once more for the forced reload (empty mapping)",
2, toggleConverter.calls.get());
assertTrue("an empty rebuild must negative-cache the class", holder.containsNoMapping(User.class));
}
/**
* A {@code buildConverterMapping} that always fails with a checked exception, to drive
* {@code buildConverterMappingUnchecked}'s wrapping of it as an unchecked
* {@code IllegalStateException}, and {@code getConverter}'s {@code catch (Throwable)}
* negative-caching of the failure.
*/
public static class ThrowingXWorkConverter extends XWorkConverter {
final AtomicInteger attempts = new AtomicInteger();
public ThrowingXWorkConverter() {
super();
}
@Override
protected Map<String, Object> buildConverterMapping(Class clazz) throws Exception {
attempts.incrementAndGet();
throw new Exception("simulated checked failure building converter mapping for " + clazz);
}
}
public void testGetConverterNegativeCachesOnBuildFailure() {
ThrowingXWorkConverter throwingConverter = container.inject(ThrowingXWorkConverter.class);
StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder();
throwingConverter.setTypeConverterHolder(holder);
Object first = throwingConverter.getConverter(User.class, "Collection_list");
assertNull(first);
assertTrue("a build failure must negative-cache the class", holder.containsNoMapping(User.class));
Object second = throwingConverter.getConverter(User.class, "Collection_list");
assertNull(second);
assertEquals("the negative cache must prevent a second build attempt",
1, throwingConverter.attempts.get());
}
public static class Foo1 {
public Bar1 getBar() {
return new Bar1Impl();
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.validator;
import org.apache.struts2.FileManager;
import org.apache.struts2.FileManagerFactory;
import org.apache.struts2.SimpleAction;
import org.apache.struts2.TestBean;
@@ -27,6 +28,8 @@ import org.apache.struts2.interceptor.ValidationAware;
import org.apache.struts2.test.DataAware2;
import org.apache.struts2.test.SimpleAction3;
import org.apache.struts2.test.User;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.ValueStackFactory;
import org.apache.struts2.validator.validators.DateRangeFieldValidator;
import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
import org.apache.struts2.validator.validators.ExpressionValidator;
@@ -39,10 +42,21 @@ import org.apache.struts2.StrutsException;
import org.assertj.core.api.Assertions;
import org.xml.sax.SAXParseException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -375,4 +389,172 @@ public class DefaultActionValidatorManagerTest extends XWorkTestCase {
assertEquals((e.getValue()).get(0), "password hint is required");
}
public void testConcurrentGetValidatorsReturnsConsistentResults() throws Exception {
final int threads = 16;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> futures = new ArrayList<>();
// actionValidatorManager is freshly injected in setUp(), so validatorCache is cold here -
// all 16 threads below race the first build for this key, which is the interesting
// contention this test exists to cover.
// ActionContext is a plain (non-inheritable) ThreadLocal, so each pool thread needs its
// own context bound - with its own ValueStack - before it can call getValidators(),
// mirroring what XWorkTestCaseHelper does for the main test thread.
ValueStackFactory valueStackFactory = container.getInstance(ValueStackFactory.class);
for (int t = 0; t < threads; t++) {
futures.add(pool.submit(() -> {
ValueStack stack = valueStackFactory.createValueStack();
stack.getActionContext().withContainer(container).withValueStack(stack).bind();
start.await();
return actionValidatorManager.getValidators(SimpleAction.class, alias).size();
}));
}
start.countDown();
int firstSize = futures.get(0).get(60, TimeUnit.SECONDS);
assertThat(firstSize).isGreaterThan(0);
for (Future<Integer> future : futures) {
assertThat(future.get(60, TimeUnit.SECONDS)).isEqualTo(firstSize);
}
pool.shutdown();
assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
}
public void testCachedValidatorConfigsAreUnmodifiable() {
actionValidatorManager.getValidators(SimpleAction.class, alias);
List<ValidatorConfig> cached = actionValidatorManager.validatorCache.values().iterator().next();
assertThatThrownBy(() -> cached.add(null))
.isInstanceOf(UnsupportedOperationException.class);
}
/**
* Covers {@code getValidators}' {@code else if (reloadingConfigs)} branch: reached when the
* validator-config key is already cached <em>and</em> reload mode is on, in which case the
* configs are rebuilt and the cache entry is replaced rather than reused. Rebuilding produces a
* fresh {@link List} instance even though the content is unchanged, so object identity is the
* real, observable signal that this branch - rather than the {@code computeIfAbsent} branch -
* ran.
*/
public void testGetValidatorsRebuildsCacheWhenReloadingConfigsEnabled() {
actionValidatorManager.getValidators(SimpleAction.class, alias);
String key = actionValidatorManager.buildValidatorKey(SimpleAction.class, alias);
List<ValidatorConfig> cachedBeforeReload = actionValidatorManager.validatorCache.get(key);
assertThat(cachedBeforeReload).isNotNull().isNotEmpty();
actionValidatorManager.reloadingConfigs = true;
actionValidatorManager.getValidators(SimpleAction.class, alias);
List<ValidatorConfig> cachedAfterReload = actionValidatorManager.validatorCache.get(key);
assertThat(cachedAfterReload)
.as("the else-if(reloadingConfigs) branch must rebuild and replace the cache entry, "
+ "not reuse the previously cached list")
.isNotSameAs(cachedBeforeReload);
assertThat(cachedAfterReload).hasSameSizeAs(cachedBeforeReload);
}
/**
* Minimal {@link FileManager} wrapping a real one, whose {@code fileNeedsReloading} answer is
* fixed at construction. {@code loadFile} and everything else delegate to the real instance so
* that the actual validation XML on the test classpath is genuinely parsed - only the reload
* check itself is made deterministic.
*/
private static class ReloadFlagFileManager implements FileManager {
private final FileManager delegate;
private final boolean needsReloading;
ReloadFlagFileManager(FileManager delegate, boolean needsReloading) {
this.delegate = delegate;
this.needsReloading = needsReloading;
}
@Override
public void setReloadingConfigs(boolean reloadingConfigs) {
delegate.setReloadingConfigs(reloadingConfigs);
}
@Override
public boolean fileNeedsReloading(String fileName) {
return needsReloading;
}
@Override
public boolean fileNeedsReloading(URL fileUrl) {
return needsReloading;
}
@Override
public InputStream loadFile(URL fileUrl) {
return delegate.loadFile(fileUrl);
}
@Override
public void monitorFile(URL fileUrl) {
delegate.monitorFile(fileUrl);
}
@Override
public URL normalizeToFileProtocol(URL url) {
return delegate.normalizeToFileProtocol(url);
}
@Override
public boolean support() {
return delegate.support();
}
@Override
public boolean internal() {
return delegate.internal();
}
@Override
public Collection<? extends URL> getAllPhysicalUrls(URL url) throws IOException {
return delegate.getAllPhysicalUrls(url);
}
}
/**
* Covers {@code loadFile}'s {@code checkFile && fileManager.fileNeedsReloading(...)} branch,
* which re-parses and {@code put}s directly rather than using {@code computeIfAbsent}. Forcing
* a real re-parse produces a new {@link List} instance even though {@code SimpleAction-
* validationAlias-validation.xml} hasn't changed, so object identity is the observable proof
* that this branch - rather than the {@code computeIfAbsent} fallback - ran.
*/
public void testLoadFileReparsesWhenCheckFileAndFileNeedsReloading() {
String fileName = SimpleAction.class.getName().replace('.', '/') + "-" + alias + "-validation.xml";
FileManager realFileManager = actionValidatorManager.fileManager;
actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, false);
List<ValidatorConfig> warm = actionValidatorManager.loadFile(fileName, SimpleAction.class, false);
assertThat(warm).isNotEmpty();
assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(warm);
actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, true);
List<ValidatorConfig> reloaded = actionValidatorManager.loadFile(fileName, SimpleAction.class, true);
assertThat(reloaded)
.as("checkFile && fileNeedsReloading must re-parse rather than reuse the cached list")
.isNotSameAs(warm);
assertThat(reloaded).hasSameSizeAs(warm);
assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(reloaded);
}
/**
* Covers {@code buildValidatorConfigs} returning {@code Collections.emptyList()} when the class
* was already recorded in the {@code checked} set - the short-circuit that stops the hierarchy
* walk from revisiting a class (e.g. an interface reachable through more than one path).
*/
public void testBuildValidatorConfigsShortCircuitsWhenClassAlreadyChecked() {
Set<String> checked = new TreeSet<>();
checked.add(SimpleAction.class.getName());
List<ValidatorConfig> result = actionValidatorManager.buildValidatorConfigs(SimpleAction.class, alias, false, checked);
assertThat(result).isNotNull().isEmpty();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
# 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 spreads the same
`Class`-keyed cache across 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** keeps its original
`protected HashSet<String>` declaration, marked `@Deprecated(forRemoval = true)` and left
unused. Retyping it — the original plan — would have changed the field descriptor and thrown
`NoSuchFieldError` at runtime for any subclass compiled against an earlier version, a binary
break not permitted in a minor release. The live storage moves to a new `private` concurrent
set (`unknownMappingsInternal`, backed by `ConcurrentHashMap.newKeySet()`); the deprecated
field is retained solely for binary compatibility until its removal ticket lands.
### 2. `XWorkConverter`
**`getConverter(Class, String)``synchronized (clazz)` removed:**
```java
protected Object getConverter(Class clazz, String property) {
if (property == null) {
return null;
}
try {
Map<String, Object> mapping = converterHolder.computeMappingIfAbsent(clazz, this::buildConverterMapping);
mapping = conditionalReload(clazz, mapping);
return mapping.get(property);
} catch (Throwable t) {
LOG.debug("Got exception trying to resolve converter for class [{}] and property [{}]", clazz, property, t);
converterHolder.addNoMapping(clazz);
return null;
}
}
```
The `containsNoMapping` guard folds into the holder: a negative-cached class returns an
empty map and `mapping.get(property)` yields `null`, the same outcome. The
`catch (Throwable)` behaviour is preserved exactly.
Two consequences:
- **`buildConverterMapping` stops storing.** It currently calls `addMapping`/`addNoMapping`
itself (`XWorkConverter.java:571-575`); with the holder owning storage that becomes a
double write. It reduces to "build and return the map". The method is `protected`, so
this is a behaviour change visible to subclasses and belongs in the release notes.
- **`buildConverterMapping` declares `throws Exception`**, which does not fit `Function`.
It is wrapped in a private lambda that rethrows as unchecked; the outer
`catch (Throwable)` still catches it, so negative-caching semantics are unchanged.
**`conditionalReload` stays outside the compute.** It only does work when
`struts.configuration.xml.reload` is enabled, and it must run on cache *hits* — that is
its purpose. Under reload it may rebuild concurrently with last-write-wins; that is a
dev-mode-only path where the current code is no better, and rebuilding is idempotent.
**`registerConverter` / `registerConverterNotFound``synchronized` removed.** Both are
now single delegations to a concurrent map. They are `public`, so the modifier is
externally observable; any caller relying on them for mutual exclusion was relying on a
lock that never covered the readers in `lookup()`.
**`lookup(String, boolean)` keeps check-then-act.** Its resolver is `lookupSuper()`
(`XWorkConverter.java:600-626`), which *reads* `getDefaultMapping()` recursively while
walking the class hierarchy. A recursive read inside `ConcurrentHashMap.computeIfAbsent`
is forbidden — it deadlocks or throws `IllegalStateException: Recursive update`. This
cache therefore simply becomes lock-free. Two threads racing on a cold miss may both walk
the hierarchy and both call `registerConverter` with an equal result; the work is a few
lock-free map reads and the outcome is identical.
**Known benign race, deliberately not closed.** `lookup` (line 364) reads
`containsUnknownMapping` and `containsDefaultMapping` as two separate calls. Each is
atomic against the concurrent map, but the *pair* is not — a converter registered between
them yields a stale `null` for that one call. Today, against an unsynchronised `HashMap`,
this is outright unsafe; afterwards it is a benign race that self-corrects on the next
lookup. No lock is added: doing so would reintroduce the contention being removed, on the
hottest read path, to close a window that resolves itself. This is recorded so a future
reader does not mistake it for an oversight.
### 3. `DefaultActionValidatorManager`
**Both `getValidators` overloads lose `synchronized`.** The three-argument one becomes:
```java
String validatorKey = buildValidatorKey(clazz, context);
List<ValidatorConfig> configs = validatorCache.get(validatorKey);
if (configs == null) {
configs = validatorCache.computeIfAbsent(validatorKey,
k -> buildValidatorConfigs(clazz, context, false, null));
} else if (reloadingConfigs) {
configs = buildValidatorConfigs(clazz, context, true, null);
validatorCache.put(validatorKey, configs);
}
```
This mirrors the existing `containsKey` / `else if (reloadingConfigs)` logic exactly,
including that a first-ever call builds with `checkFile=false` even in reload mode.
**The substantive win is what now sits outside any lock:** the loop at lines 149-157 that
reads the `ValueStack` and calls `validatorFactory.getValidator(config)` per config. That
is per-request work on per-request objects. Setting caching aside entirely, removing it
from the critical path takes the manager off the serialisation path of every validated
request.
**`loadFile` gets the same treatment**, with the file-reload branch kept explicit:
```java
URL fileUrl = ClassLoaderUtil.getResource(fileName, clazz);
if (checkFile && fileManager.fileNeedsReloading(fileUrl)) {
List<ValidatorConfig> reloaded = parseValidatorConfigs(fileUrl, fileName);
validatorFileCache.put(fileName, reloaded);
return reloaded;
}
return validatorFileCache.computeIfAbsent(fileName, k -> parseValidatorConfigs(fileUrl, fileName));
```
`parseValidatorConfigs(URL, String)` is a new private helper extracted from the existing
body of `loadFile` — the `fileManager.loadFile` / `validatorFileParser` /
`catch (IOException)` block at lines 336-342, unchanged in behaviour. It is extracted only
so the same logic can serve both branches above without duplication.
This `computeIfAbsent` runs inside the one on `validatorCache`. They are different maps
and nothing calls back the other direction, so there is no lock-ordering cycle. Empty
results are still cached, preserving today's negative caching.
**Both caches become `ConcurrentHashMap`.** The declared field types stay
`Map<String, List<ValidatorConfig>>` and stay `protected`, so `AnnotationActionValidatorManager`
and any third-party subclass are unaffected.
**Cached lists are wrapped unmodifiable before publication.** With `synchronized` gone,
several threads iterate the same cached `List<ValidatorConfig>` concurrently. That is safe
only while no cached list is mutated after publication — true today, but only incidentally,
and `buildValidatorConfigs` does `addAll` into lists that flow around freely. Wrapping makes
it true by construction: a future mutation fails loudly at the mistake instead of becoming
an intermittent production heisenbug. Callers were checked —
`buildClassValidatorConfigs`/`buildAliasValidatorConfigs` results are only read or
`addAll`-ed into a fresh accumulator, and `AnnotationActionValidatorManager` already copies
defensively.
**Noted limitation, untouched:** `AnnotationActionValidatorManager.buildValidatorKey` calls
`ActionContext.getContext().getActionInvocation()`, so the cache key depends on per-request
state. It works, but the class cannot be reasoned about purely from its own source.
## Testing
**Regression gate.** The existing `XWorkConverterTest`, `AnnotationXWorkConverterTest`,
`DefaultActionValidatorManagerTest` and `AnnotationActionValidatorManagerTest`
(`XWorkTestCase`-based, JUnit 4 as core actually uses) must pass untouched. Any need to
*edit* them signals a semantic change that this design says will not happen.
**New concurrency tests**, added to the existing test classes. Each uses 16 threads
released simultaneously via `CountDownLatch`, and asserts on collected results rather than
on timing, so there is no sleep-based flakiness:
- `StrutsTypeConverterHolder`: 16 threads registering distinct default mappings while
others read; assert every registration is visible at the end and no read returns a torn
result. This test would plausibly fail against today's code.
- `XWorkConverter.getConverter`: 16 threads resolving converters for the same class
concurrently; assert equal results and no exception. Additionally assert
`buildConverterMapping` runs exactly once per class via a counting subclass — the
concrete payoff of `computeMappingIfAbsent`.
- `DefaultActionValidatorManager.getValidators`: 16 threads against the same class and
context; assert consistent validator lists and a single build.
**Caveat.** These tests can demonstrate a race but never its absence; a green run on a
strongly-ordered x86 machine is weak evidence. The primary correctness argument is the
reasoning about the data structures — reads on concurrent collections, both
`computeIfAbsent` call sites provably not re-entering their own maps, and the remaining
races benign and self-correcting. The tests are a backstop for that argument, not a
substitute.
**Benchmark, not committed.** A throwaway harness measuring `getValidators` and
`getConverter` throughput at 1/4/16/64 threads, before and after, warmed. Numbers go in
the PR description. The result will be reported faithfully, including the realistic
outcome that the converter changes do not move the needle while the validator lock does —
the converter caches warm fast, and the validator lock is the coarser of the two.
## Out of scope
1. **Clearing the caches on cleanup (defense-in-depth).** `mappings` and the negative cache hold
strong references to application `Class` objects. This does **not** independently pin the webapp
classloader — the holder is a container-scoped singleton with no static, thread, or otherwise
external reference, so it is reachable only through the container and is collected together with it.
The classloader's survival is governed by whatever retains the *container*, which is out of scope
here and handled by the WW-5537 cleanup work. As a tidiness measure, clearing these caches during
`Dispatcher.cleanup()` (via `InternalDestroyable`) is folded into WW-5537 as Task 5b; it is not a
leak fix.
2. **The `FIXME lukaszlenart` in `TypeConverterHolder`** about merging `unknownMappings`
into `noMapping` — a semantic consolidation, unrelated to locking.
3. **Removal of the deprecated methods** — tracked by the project lead for a later release.
## Risk
The change is mostly deletion, but it deletes locks, so the failure mode is silent and
load-dependent rather than a failing build. Two things warrant the closest review:
- No `computeIfAbsent` builder can re-enter its own map. Verified for both call sites;
the `lookupSuper` case is precisely why the default-mapping cache keeps check-then-act.
Confirmed that `DefaultConversionFileProcessor` never touches the holder and
`DefaultConversionAnnotationProcessor` writes only to `defaultMappings`, a different map.
- No cached collection is mutated after publication — enforced by unmodifiable wrapping in
`DefaultActionValidatorManager`.