mirror of
https://github.com/apache/struts.git
synced 2026-08-05 22:56:59 +00:00
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:
@@ -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 -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-26
@@ -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();
|
||||
|
||||
+182
@@ -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`.
|
||||
Reference in New Issue
Block a user