This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new a6570b769 WW-5668 Bound the localized-text provider caches and align 
request-locale resolution (#1821)
a6570b769 is described below

commit a6570b769bef8dfc4d7cb132d94eba11bab1bec4
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Aug 1 10:04:40 2026 +0200

    WW-5668 Bound the localized-text provider caches and align request-locale 
resolution (#1821)
    
    * WW-5668 docs: design spec for bounded i18n caches and request-locale 
resolution
    
    Follow-up to WW-5540. Bound the AbstractLocalizedTextProvider caches via the
    existing OgnlCache abstraction (configurable 
struts.i18n.cacheType/cacheMaxSize),
    and add opt-in request-locale resolution consistency between Dispatcher and
    I18nInterceptor (struts.locale.validateRequestLocale, default off).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 docs: implementation plan for bounded i18n caches and 
request-locale resolution
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Add remove(key) to the OgnlCache abstraction
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Bound the localized-text provider caches with configurable size
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Fix reassignable-lock hazard and add volatile to i18n cache fields
    
    synchronized (bundlesMap) locked on a monitor that rebuildI18nCaches()
    can reassign; introduce a dedicated bundlesMapLock and lock on that
    instead. Mark the five i18n cache fields volatile for safe publication
    across the reassignment.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Add opt-in request-locale resolution consistency to Dispatcher
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Keep the localized-text caches transient so the provider stays 
serializable
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Add missing Javadoc and cache-rebuild coverage for i18n provider
    
    Add the one-line Javadoc that sibling fields/setters carry to
    validateRequestLocale and its @Inject setter in Dispatcher, and add two
    tests covering StrutsLocalizedTextProvider's serialize/deserialize cache
    rebuild and cacheType selection behaviour.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Pin explicit serialVersionUID on the localized-text providers
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Suppress false-positive Sonar S3077 on the thread-safe i18n caches
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5668 Drop the unused throws Exception from the new Dispatcher locale 
tests
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../java/org/apache/struts2/StrutsConstants.java   |  24 +
 .../org/apache/struts2/dispatcher/Dispatcher.java  |  41 +-
 .../java/org/apache/struts2/ognl/OgnlCache.java    |   9 +
 .../org/apache/struts2/ognl/OgnlCaffeineCache.java |   5 +
 .../org/apache/struts2/ognl/OgnlDefaultCache.java  |   5 +
 .../java/org/apache/struts2/ognl/OgnlLRUCache.java |   5 +
 .../text/AbstractLocalizedTextProvider.java        | 100 +++-
 .../struts2/text/GlobalLocalizedTextProvider.java  |   2 +
 .../struts2/text/StrutsLocalizedTextProvider.java  |   2 +
 .../org/apache/struts2/default.properties          |  10 +
 .../apache/struts2/dispatcher/DispatcherTest.java  |  40 ++
 .../apache/struts2/ognl/OgnlCacheRemoveTest.java   |  50 ++
 .../text/StrutsLocalizedTextProviderTest.java      |  73 +++
 ...-WW-5668-i18n-cache-bounds-locale-resolution.md | 617 +++++++++++++++++++++
 ...8-i18n-cache-bounds-locale-resolution-design.md | 215 +++++++
 15 files changed, 1181 insertions(+), 17 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 5a09cfa78..9c1245248 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -135,6 +135,14 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_LOCALE = "struts.locale";
 
+    /**
+     * When enabled, request-derived locales (from {@code Accept-Language}, 
used when {@code struts.locale} is
+     * unset) are restricted to the JVM's available-locale set; unavailable 
values fall back to the default.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_LOCALE_VALIDATE_REQUEST = 
"struts.locale.validateRequestLocale";
+
     /**
      * Whether to use a Servlet request parameter workaround necessary for 
some versions of WebLogic
      */
@@ -544,6 +552,22 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_OGNL_EXPRESSION_CACHE_MAXSIZE = 
"struts.ognl.expressionCacheMaxSize";
 
+    /**
+     * Specifies the type of cache to use for the localized-text provider 
caches. Valid values defined in
+     * {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_I18N_CACHE_TYPE = 
"struts.i18n.cacheType";
+
+    /**
+     * Specifies the maximum size of each localized-text provider cache. 
Configure based on the cache type
+     * chosen and application-specific needs.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_I18N_CACHE_MAXSIZE = 
"struts.i18n.cacheMaxSize";
+
     /**
      * Specifies the type of cache to use for proxy detection. Valid values 
defined in
      * {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java 
b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
index 34d6c13c8..5b7d90556 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -151,6 +151,11 @@ public class Dispatcher {
      */
     private String defaultLocale;
 
+    /**
+     * Store state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST} 
setting.
+     */
+    private boolean validateRequestLocale = false;
+
     /**
      * Store state of {@link StrutsConstants#STRUTS_MULTIPART_SAVE_DIR} 
setting.
      */
@@ -310,6 +315,16 @@ public class Dispatcher {
         defaultLocale = val;
     }
 
+    /**
+     * Modify state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST} 
setting.
+     *
+     * @param val New setting
+     */
+    @Inject(value = StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, required = 
false)
+    public void setValidateRequestLocale(String val) {
+        validateRequestLocale = Boolean.parseBoolean(val);
+    }
+
     /**
      * Modify state of StrutsConstants.STRUTS_I18N_ENCODING setting.
      *
@@ -929,7 +944,7 @@ public class Dispatcher {
                 locale = LocaleUtils.toLocale(defaultLocale);
             } catch (IllegalArgumentException e) {
                 try {
-                    locale = request.getLocale();
+                    locale = resolveRequestLocale(request);
                     LOG.warn(new ParameterizedMessage("Cannot convert 
'struts.locale' = [{}] to proper locale, defaulting to request locale [{}]",
                                     defaultLocale, locale), e);
                 } catch (RuntimeException rex) {
@@ -940,7 +955,7 @@ public class Dispatcher {
             }
         } else {
             try {
-                locale = request.getLocale();
+                locale = resolveRequestLocale(request);
             } catch (RuntimeException rex) {
                 LOG.warn("Cannot get locale from HTTP Request, falling back to 
system default locale", rex);
                 locale = Locale.getDefault();
@@ -949,6 +964,28 @@ public class Dispatcher {
         return locale;
     }
 
+    /**
+     * Resolves the request locale. When {@code 
struts.locale.validateRequestLocale} is enabled and the
+     * request locale is not part of the JVM's available-locale set, falls 
back to the configured
+     * {@code struts.locale} when set and parseable, otherwise the JVM 
default. When disabled (default),
+     * returns the request locale unchanged.
+     */
+    protected Locale resolveRequestLocale(HttpServletRequest request) {
+        Locale locale = request.getLocale();
+        if (!validateRequestLocale || LocaleUtils.isAvailableLocale(locale)) {
+            return locale;
+        }
+        if (defaultLocale != null) {
+            try {
+                return LocaleUtils.toLocale(defaultLocale);
+            } catch (IllegalArgumentException e) {
+                LOG.debug("Configured 'struts.locale' = [{}] is not parseable; 
falling back to system default", defaultLocale);
+            }
+        }
+        LOG.debug("Request locale [{}] is not available; falling back to 
system default locale", locale);
+        return Locale.getDefault();
+    }
+
     /**
      * Return the path to save uploaded files to (this is configurable).
      *
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java 
b/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
index d7c88b336..64c7d9eb5 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
@@ -47,6 +47,15 @@ public interface OgnlCache<K, V> {
 
     void putIfAbsent(K key, V value);
 
+    /**
+     * Removes the mapping for the given key, if present.
+     *
+     * @param key the key to remove
+     * @return the previous value associated with the key, or {@code null} if 
none
+     * @since 7.3.0
+     */
+    V remove(K key);
+
     int size();
 
     void clear();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java 
b/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
index b7f5b2750..883e6645f 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
@@ -63,6 +63,11 @@ public class OgnlCaffeineCache<K, V> implements OgnlCache<K, 
V> {
         cache.asMap().putIfAbsent(key, value);
     }
 
+    @Override
+    public V remove(K key) {
+        return cache.asMap().remove(key);
+    }
+
     @Override
     public int size() {
         return cache.asMap().size();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java 
b/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
index 391944a10..70239cdc3 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
@@ -63,6 +63,11 @@ public class OgnlDefaultCache<K, V> implements OgnlCache<K, 
V> {
         clearIfEvictionLimitExceeded();
     }
 
+    @Override
+    public V remove(K key) {
+        return ognlCache.remove(key);
+    }
+
     @Override
     public int size() {
         return ognlCache.size();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java 
b/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
index fc995d438..f1f49467f 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
@@ -70,6 +70,11 @@ public class OgnlLRUCache<K, V> implements OgnlCache<K, V> {
         ognlLRUCache.putIfAbsent(key, value);
     }
 
+    @Override
+    public V remove(K key) {
+        return ognlLRUCache.remove(key);
+    }
+
     @Override
     public int size() {
         return ognlLRUCache.size();
diff --git 
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java 
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
index 2873e311e..2aaebaee4 100644
--- 
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -18,12 +18,16 @@
  */
 package org.apache.struts2.text;
 
+import org.apache.commons.lang3.EnumUtils;
 import org.apache.commons.lang3.ObjectUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.inject.Inject;
+import org.apache.struts2.ognl.DefaultOgnlCacheFactory;
+import org.apache.struts2.ognl.OgnlCache;
+import org.apache.struts2.ognl.OgnlCacheFactory.CacheType;
 import org.apache.struts2.util.TextParseUtil;
 import org.apache.struts2.util.ValueStack;
 
@@ -46,6 +50,8 @@ import java.util.concurrent.CopyOnWriteArrayList;
 
 abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
 
+    private static final long serialVersionUID = 1L;
+
     private static final Logger LOG = 
LogManager.getLogger(AbstractLocalizedTextProvider.class);
 
     public static final String XWORK_MESSAGES_BUNDLE = 
"org/apache/struts2/xwork-messages";
@@ -59,17 +65,38 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     @SuppressWarnings("java:S2129") // deliberate: a non-interned instance is 
required for an identity (==) sentinel
     private static final String NOT_FOUND = new 
String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with 
==
 
-    protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new 
ConcurrentHashMap<>();
     protected boolean devMode = false;
     protected boolean reloadBundles = false;
     protected boolean searchDefaultBundlesFirst = false;  // Search default 
resource bundles first.  Note: This flag may not be meaningful to all 
implementations.
 
-    private final ConcurrentMap<MessageFormatKey, MessageFormat> 
messageFormats = new ConcurrentHashMap<>();
     private final ConcurrentMap<Integer, List<String>> classLoaderMap = new 
ConcurrentHashMap<>();
-    private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
     private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap 
= new ConcurrentHashMap<>();
-    private final ConcurrentMap<TextCacheKey, String> classHierarchyCache = 
new ConcurrentHashMap<>();
-    private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = 
new ConcurrentHashMap<>();
+
+    // Dedicated monitor for bundlesMap-related synchronization: bundlesMap is 
reassigned by
+    // rebuildI18nCaches(), so locking on it directly would lock on a monitor 
that can change identity.
+    // transient + reinitialised in readObject: a bare Object is not 
Serializable.
+    private transient Object bundlesMapLock = new Object();
+
+    private volatile CacheType i18nCacheType = CacheType.WTLFU;
+    private volatile int i18nCacheMaxSize = 10000;
+
+    private <K, V> OgnlCache<K, V> buildI18nCache() {
+        return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize, 
i18nCacheType).buildOgnlCache();
+    }
+
+    // The OgnlCache implementations are themselves thread-safe; volatile only 
safely publishes the
+    // reference when rebuildI18nCaches() replaces a cache (during injection / 
readObject), so S3077
+    // ("volatile is not enough") does not apply here.
+    @SuppressWarnings("java:S3077")
+    protected transient volatile OgnlCache<String, ResourceBundle> bundlesMap 
= buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<MessageFormatKey, MessageFormat> 
messageFormats = buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<String, Boolean> missingBundles = 
buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<TextCacheKey, String> 
classHierarchyCache = buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<TextCacheKey, String> 
packageHierarchyCache = buildI18nCache();
 
     @Override
     public void addDefaultResourceBundle(String bundleName) {
@@ -110,6 +137,21 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         return packageHierarchyCache.size();
     }
 
+    /** Test-support accessor: current number of cached resource bundles. */
+    protected int bundlesMapSize() {
+        return bundlesMap.size();
+    }
+
+    /** Test-support accessor: current number of cached missing-bundle 
markers. */
+    protected int missingBundlesSize() {
+        return missingBundles.size();
+    }
+
+    /** Test-support accessor: current number of cached message formats. */
+    protected int messageFormatsSize() {
+        return messageFormats.size();
+    }
+
     @Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required = 
false)
     public void setCustomI18NResources(String bundles) {
         if (bundles == null || bundles.isEmpty()) {
@@ -191,7 +233,7 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
      * @param classLoader a {@link ClassLoader} to look up the bundle from if 
none can be found on the current thread's classloader
      */
     public void setDelegatedClassLoader(final ClassLoader classLoader) {
-        synchronized (bundlesMap) {
+        synchronized (bundlesMapLock) {
             
delegatedClassLoaderMap.put(getCurrentThreadContextClassLoader().hashCode(), 
classLoader);
         }
     }
@@ -404,39 +446,67 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         this.searchDefaultBundlesFirst = 
Boolean.parseBoolean(searchDefaultBundlesFirst);
     }
 
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+    public void setI18nCacheType(String cacheType) {
+        this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class, 
cacheType, CacheType.WTLFU);
+        rebuildI18nCaches();
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required = 
false)
+    public void setI18nCacheMaxSize(String cacheMaxSize) {
+        this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
+        rebuildI18nCaches();
+    }
+
+    /**
+     * Rebuilds the localized-text caches from the current type/size. Called 
during dependency injection
+     * (single-threaded startup, before the provider serves lookups); discards 
any warm-up entries.
+     */
+    private void rebuildI18nCaches() {
+        bundlesMap = buildI18nCache();
+        messageFormats = buildI18nCache();
+        missingBundles = buildI18nCache();
+        classHierarchyCache = buildI18nCache();
+        packageHierarchyCache = buildI18nCache();
+    }
+
+    private void readObject(java.io.ObjectInputStream in) throws 
java.io.IOException, ClassNotFoundException {
+        in.defaultReadObject();
+        bundlesMapLock = new Object();
+        rebuildI18nCaches();
+    }
+
     @Override
     public ResourceBundle findResourceBundle(String bundleName, Locale locale) 
{
         ClassLoader classLoader = getCurrentThreadContextClassLoader();
         String key = createMissesKey(String.valueOf(classLoader.hashCode()), 
bundleName, locale);
 
-        if (missingBundles.contains(key)) {
+        if (missingBundles.get(key) != null) {
             return null;
         }
 
         ResourceBundle bundle = null;
         try {
-            if (bundlesMap.containsKey(key)) {
-                bundle = bundlesMap.get(key);
-            } else {
+            bundle = bundlesMap.get(key);
+            if (bundle == null) {
                 bundle = ResourceBundle.getBundle(bundleName, locale, 
classLoader);
                 bundlesMap.putIfAbsent(key, bundle);
             }
         } catch (MissingResourceException ex) {
             if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
                 try {
-                    if (bundlesMap.containsKey(key)) {
-                        bundle = bundlesMap.get(key);
-                    } else {
+                    bundle = bundlesMap.get(key);
+                    if (bundle == null) {
                         bundle = ResourceBundle.getBundle(bundleName, locale, 
delegatedClassLoaderMap.get(classLoader.hashCode()));
                         bundlesMap.putIfAbsent(key, bundle);
                     }
                 } catch (MissingResourceException e) {
                     LOG.debug("Missing resource bundle [{}]!", bundleName, e);
-                    missingBundles.add(key);
+                    missingBundles.put(key, Boolean.TRUE);
                 }
             } else {
                 LOG.debug("Missing resource bundle [{}]!", bundleName);
-                missingBundles.add(key);
+                missingBundles.put(key, Boolean.TRUE);
             }
         }
         return bundle;
diff --git 
a/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java 
b/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
index 31ea21650..72383de5e 100644
--- 
a/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
@@ -34,6 +34,8 @@ import java.util.ResourceBundle;
  */
 public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider 
{
 
+    private static final long serialVersionUID = 1L;
+
     private static final Logger LOG = 
LogManager.getLogger(GlobalLocalizedTextProvider.class);
 
     public GlobalLocalizedTextProvider() {
diff --git 
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java 
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
index 71340ffe8..1a0976889 100644
--- 
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
@@ -38,6 +38,8 @@ import java.util.ResourceBundle;
  */
 public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider 
{
 
+    private static final long serialVersionUID = 1L;
+
     private static final Logger LOG = 
LogManager.getLogger(StrutsLocalizedTextProvider.class);
     private transient ReflectionProvider reflectionProvider;
 
diff --git a/core/src/main/resources/org/apache/struts2/default.properties 
b/core/src/main/resources/org/apache/struts2/default.properties
index 51c613a95..a14c84bcc 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -24,6 +24,9 @@
 
 ### This can be used to set your default locale and encoding scheme
 # struts.locale=en_US
+### When true, restrict request-derived locales (Accept-Language, used when 
struts.locale is unset) to the
+### JVM's available-locale set; unavailable values fall back to the default 
locale. Defaults to false.
+struts.locale.validateRequestLocale=false
 struts.i18n.encoding=UTF-8
 
 ### if specified, the default object factory can be overridden here
@@ -296,6 +299,13 @@ struts.ognl.expressionCacheType=wtlfu
 ### chosen and application-specific needs.
 struts.ognl.expressionCacheMaxSize=10000
 
+### Specifies the type of cache to use for the localized-text provider caches. 
See StrutsConstants for details.
+struts.i18n.cacheType=wtlfu
+
+### Specifies the maximum size of each localized-text provider cache. This 
should be configured based on the
+### cache type chosen and application-specific needs.
+struts.i18n.cacheMaxSize=10000
+
 ### Specifies the type of cache to use for BeanInfo objects. See 
StrutsConstants class for further information.
 struts.ognl.beanInfoCacheType=wtlfu
 
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java 
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
index cdfe64b2c..ab1191cbe 100644
--- a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+++ b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
@@ -571,6 +571,46 @@ public class DispatcherTest extends 
StrutsJUnit4InternalTestCase {
         assertEquals(Locale.getDefault(), context.getLocale());  // Expect the 
system default value when Mock request access fails.
     }
 
+    @Test
+    public void testValidateRequestLocaleOffPassesThrough() {
+        initDispatcher(new HashMap<>());
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        // A syntactically valid but not JVM-available locale.
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        assertEquals("Default off must pass the request locale through 
unchanged",
+                exotic, dispatcher.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnKeepsAvailableLocale() {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        initDispatcher(params);
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getLocale()).thenReturn(Locale.UK);
+
+        assertEquals("Available request locale must be kept", Locale.UK, 
dispatcher.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnFallsBackForUnavailableLocale() {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        initDispatcher(params);
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        // struts.locale unset in this dispatcher -> fall back to the JVM 
default.
+        assertEquals("Unavailable request locale must fall back to system 
default",
+                Locale.getDefault(), dispatcher.getLocale(request));
+    }
+
     @Test
     public void dispatcherReinjectedAfterReload() {
         HttpServletRequest request = mock(HttpServletRequest.class);
diff --git 
a/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java 
b/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
new file mode 100644
index 000000000..f84d0f787
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ognl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class OgnlCacheRemoveTest {
+
+    private void assertRemoveContract(OgnlCache<String, String> cache) {
+        cache.put("k", "v");
+        assertEquals("v", cache.get("k"));
+        assertEquals("remove returns previous value", "v", cache.remove("k"));
+        assertNull("entry gone after remove", cache.get("k"));
+        assertNull("remove of absent key returns null", 
cache.remove("absent"));
+    }
+
+    @Test
+    public void caffeineCacheRemove() {
+        assertRemoveContract(new OgnlCaffeineCache<>(10, 16));
+    }
+
+    @Test
+    public void defaultCacheRemove() {
+        assertRemoveContract(new OgnlDefaultCache<>(10, 16, 0.75f));
+    }
+
+    @Test
+    public void lruCacheRemove() {
+        assertRemoveContract(new OgnlLRUCache<>(10, 16, 0.75f));
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
 
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
index d7157c552..076403dc2 100644
--- 
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -748,6 +748,79 @@ public class StrutsLocalizedTextProviderTest extends 
XWorkTestCase {
         assertEquals("A different index should create its own cache entry ?", 
2, provider.classHierarchyCacheSize());
     }
 
+    public void testCachesAreBoundedByConfiguredMaxSize() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("100");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        for (int i = 0; i < 20000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        assertTrue("classHierarchyCache not bounded ?", 
provider.classHierarchyCacheSize() <= 2000);
+        assertTrue("packageHierarchyCache not bounded ?", 
provider.packageHierarchyCacheSize() <= 2000);
+        assertTrue("bundlesMap not bounded ?", provider.bundlesMapSize() <= 
2000);
+        assertTrue("missingBundles not bounded ?", 
provider.missingBundlesSize() <= 2000);
+        assertTrue("messageFormats not bounded ?", 
provider.messageFormatsSize() <= 2000);
+    }
+
+    public void testCorrectTextStillReturnedUnderEviction() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("50");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // Force heavy eviction with many distinct locales.
+        for (int i = 0; i < 5000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        // A real key in a real locale still resolves correctly after eviction 
pressure.
+        String result = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    public void testReloadClearsBoundedCaches() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH, 
"Fallback", null, valueStack);
+        assertTrue("missingBundles not populated ?", 
provider.missingBundlesSize() > 0);
+
+        provider.callReloadBundlesForceReload();
+        assertEquals("reload did not clear bundlesMap ?", 0, 
provider.bundlesMapSize());
+    }
+
+    public void testProviderIsUsableAfterDeserialization() throws Exception {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+
+        java.io.ByteArrayOutputStream baos = new 
java.io.ByteArrayOutputStream();
+        try (java.io.ObjectOutputStream oos = new 
java.io.ObjectOutputStream(baos)) {
+            oos.writeObject(provider);
+        }
+        Object restored;
+        try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream(new 
java.io.ByteArrayInputStream(baos.toByteArray()))) {
+            restored = ois.readObject();
+        }
+        TestStrutsLocalizedTextProvider deserialized = 
(TestStrutsLocalizedTextProvider) restored;
+        // Caches were transient (null right after defaultReadObject) but 
readObject rebuilds them:
+        assertEquals("Deserialized caches not rebuilt empty", 0, 
deserialized.classHierarchyCacheSize());
+        String result = deserialized.findText(CacheFixture.class, 
"cache.static", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    public void testCacheTypeSelectionKeepsProviderWorking() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheType("basic");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        String result = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+        assertTrue("class hierarchy cache should populate", 
provider.classHierarchyCacheSize() >= 1);
+    }
+
     @Override
     protected void setUp() throws Exception {
         super.setUp();
diff --git 
a/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
 
b/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
new file mode 100644
index 000000000..b7a463eb7
--- /dev/null
+++ 
b/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
@@ -0,0 +1,617 @@
+# WW-5668 — Bounded i18n caches and consistent request-locale resolution — 
Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bound the five `AbstractLocalizedTextProvider` caches with 
configurable size/eviction via the existing `OgnlCache` abstraction, and add an 
opt-in consistency check that resolves request-derived locales against the JVM 
available-locale set in `Dispatcher`.
+
+**Architecture:** Reuse `OgnlCache` + `DefaultOgnlCacheFactory` (add an 
additive `remove`) for the i18n caches; drive them from two new `@Inject` 
constants. Add an opt-in `Dispatcher` helper mirroring `I18nInterceptor`'s 
availability check. The two parts are independent; Part 1 is always on, Part 2 
defaults off.
+
+**Tech Stack:** Java 17, Struts core, `org.apache.struts2.ognl` cache classes 
(Caffeine/WTLFU), commons-lang3 `LocaleUtils`/`EnumUtils`, JUnit 4 / 
`XWorkTestCase`, Mockito.
+
+**Spec:** 
`docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md`
+
+## Global Constraints
+
+- Ticket prefix on every commit: `WW-5668`. Neutral framing only — no 
security/DoS/attacker/exhaustion language in code, comments, tests, or commit 
messages.
+- Core tests are JUnit 3/4. Tests extending `XWorkTestCase` use `public void 
testXxx()` methods (JUnit 3 style); standalone tests use JUnit 4 
(`org.junit.Test`, `org.junit.Assert`). Never JUnit 5 `@Test` — it silently 
does not run.
+- Build/test command: `mvn test -DskipAssembly -pl core -Dtest=<ClassName>` 
(append `#<method>` for a single method).
+- New config constant defaults must match existing cache conventions: `wtlfu` 
/ `10000`.
+- `struts.locale.validateRequestLocale` default `false` — current behaviour 
must be preserved byte-for-byte when unset.
+
+---
+
+### Task 1: Add `remove` to the `OgnlCache` abstraction
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java`
+- Test (create): 
`core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java`
+
+**Interfaces:**
+- Produces: `V OgnlCache.remove(K key)` — removes the mapping for `key`, 
returning the previous value or `null`. Implemented by all three cache classes.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java`:
+
+```java
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.ognl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class OgnlCacheRemoveTest {
+
+    private void assertRemoveContract(OgnlCache<String, String> cache) {
+        cache.put("k", "v");
+        assertEquals("v", cache.get("k"));
+        assertEquals("remove returns previous value", "v", cache.remove("k"));
+        assertNull("entry gone after remove", cache.get("k"));
+        assertNull("remove of absent key returns null", 
cache.remove("absent"));
+    }
+
+    @Test
+    public void caffeineCacheRemove() {
+        assertRemoveContract(new OgnlCaffeineCache<>(10, 16));
+    }
+
+    @Test
+    public void defaultCacheRemove() {
+        assertRemoveContract(new OgnlDefaultCache<>(10, 16, 0.75f));
+    }
+
+    @Test
+    public void lruCacheRemove() {
+        assertRemoveContract(new OgnlLRUCache<>(10, 16, 0.75f));
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlCacheRemoveTest`
+Expected: COMPILE FAILURE — `OgnlCache` has no `remove` method.
+
+- [ ] **Step 3: Add `remove` to the interface**
+
+In `OgnlCache.java`, after the `void put(K key, V value);` / `void 
putIfAbsent(...)` declarations, add:
+
+```java
+    /**
+     * Removes the mapping for the given key, if present.
+     *
+     * @param key the key to remove
+     * @return the previous value associated with the key, or {@code null} if 
none
+     * @since 7.3.0
+     */
+    V remove(K key);
+```
+
+- [ ] **Step 4: Implement in all three cache classes**
+
+`OgnlCaffeineCache.java` — add:
+
+```java
+    @Override
+    public V remove(K key) {
+        return cache.asMap().remove(key);
+    }
+```
+
+`OgnlDefaultCache.java` — add:
+
+```java
+    @Override
+    public V remove(K key) {
+        return ognlCache.remove(key);
+    }
+```
+
+`OgnlLRUCache.java` — add:
+
+```java
+    @Override
+    public V remove(K key) {
+        return ognlLRUCache.remove(key);
+    }
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlCacheRemoveTest`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Verify no other implementors broke**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlUtilTest`
+Expected: PASS — the only `OgnlCache` implementors are the three modified 
classes (confirmed by design), so nothing else needed the new method.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/ognl/OgnlCache.java \
+        core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java \
+        core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java \
+        core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java \
+        core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
+git commit -m "WW-5668 Add remove(key) to the OgnlCache abstraction
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 2: Bound the localized-text provider caches (configurable)
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (add 
two constants)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties` 
(document them)
+- Modify: 
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java` 
(cache fields, setters, call sites, size accessors)
+- Test: 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
 (add tests; existing 33 must stay green)
+
+**Interfaces:**
+- Consumes: `OgnlCache<K,V>` with 
`get`/`put`/`putIfAbsent`/`remove`/`clear`/`size` (Task 1); 
`DefaultOgnlCacheFactory<K,V>(int cacheMaxSize, CacheType type)` with 
`buildOgnlCache()`; `OgnlCacheFactory.CacheType` enum (`BASIC`/`LRU`/`WTLFU`).
+- Produces: constants `STRUTS_I18N_CACHE_TYPE = "struts.i18n.cacheType"`, 
`STRUTS_I18N_CACHE_MAXSIZE = "struts.i18n.cacheMaxSize"`; provider setters 
`setI18nCacheType(String)`, `setI18nCacheMaxSize(String)`; `protected int` size 
accessors `bundlesMapSize()`, `missingBundlesSize()`, `messageFormatsSize()` 
(alongside the existing 
`classHierarchyCacheSize()`/`packageHierarchyCacheSize()`).
+
+- [ ] **Step 1: Add the constants**
+
+In `StrutsConstants.java`, after `STRUTS_OGNL_EXPRESSION_CACHE_MAXSIZE` (line 
~545), add:
+
+```java
+    /**
+     * Specifies the type of cache to use for the localized-text provider 
caches. Valid values defined in
+     * {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_I18N_CACHE_TYPE = 
"struts.i18n.cacheType";
+
+    /**
+     * Specifies the maximum size of each localized-text provider cache. 
Configure based on the cache type
+     * chosen and application-specific needs.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_I18N_CACHE_MAXSIZE = 
"struts.i18n.cacheMaxSize";
+```
+
+- [ ] **Step 2: Document them in default.properties**
+
+In `core/src/main/resources/org/apache/struts2/default.properties`, after the 
`struts.ognl.expressionCacheMaxSize=10000` block (line ~297), add:
+
+```properties
+### Specifies the type of cache to use for the localized-text provider caches. 
See StrutsConstants for details.
+struts.i18n.cacheType=wtlfu
+
+### Specifies the maximum size of each localized-text provider cache. This 
should be configured based on the
+### cache type chosen and application-specific needs.
+struts.i18n.cacheMaxSize=10000
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+In `StrutsLocalizedTextProviderTest.java`, add these methods (JUnit 3 style, 
matching the file). They use the existing `TestStrutsLocalizedTextProvider` 
subclass and `CacheFixture` fixture already present in this test class:
+
+```java
+    public void testCachesAreBoundedByConfiguredMaxSize() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("100");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        for (int i = 0; i < 20000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        assertTrue("classHierarchyCache not bounded ?", 
provider.classHierarchyCacheSize() <= 2000);
+        assertTrue("packageHierarchyCache not bounded ?", 
provider.packageHierarchyCacheSize() <= 2000);
+        assertTrue("bundlesMap not bounded ?", provider.bundlesMapSize() <= 
2000);
+        assertTrue("missingBundles not bounded ?", 
provider.missingBundlesSize() <= 2000);
+        assertTrue("messageFormats not bounded ?", 
provider.messageFormatsSize() <= 2000);
+    }
+
+    public void testCorrectTextStillReturnedUnderEviction() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("50");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // Force heavy eviction with many distinct locales.
+        for (int i = 0; i < 5000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        // A real key in a real locale still resolves correctly after eviction 
pressure.
+        String result = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    public void testReloadClearsBoundedCaches() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH, 
"Fallback", null, valueStack);
+        assertTrue("missingBundles not populated ?", 
provider.missingBundlesSize() > 0);
+
+        provider.reloadBundles(ActionContext.getContext().getContextMap());
+        assertEquals("reload did not clear bundlesMap ?", 0, 
provider.bundlesMapSize());
+    }
+```
+
+- [ ] **Step 4: Run the new tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testCachesAreBoundedByConfiguredMaxSize+testCorrectTextStillReturnedUnderEviction+testReloadClearsBoundedCaches`
+Expected: COMPILE FAILURE — `setI18nCacheMaxSize`, `bundlesMapSize`, 
`missingBundlesSize`, `messageFormatsSize` do not exist yet.
+
+- [ ] **Step 5: Add imports and replace the cache fields**
+
+In `AbstractLocalizedTextProvider.java`, add imports:
+
+```java
+import org.apache.commons.lang3.EnumUtils;
+import org.apache.struts2.ognl.DefaultOgnlCacheFactory;
+import org.apache.struts2.ognl.OgnlCache;
+import org.apache.struts2.ognl.OgnlCacheFactory.CacheType;
+```
+
+Replace the five cache field declarations (currently lines ~62, 67, 69, 71, 
72):
+
+```java
+    protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new 
ConcurrentHashMap<>();
+    ...
+    private final ConcurrentMap<MessageFormatKey, MessageFormat> 
messageFormats = new ConcurrentHashMap<>();
+    ...
+    private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
+    ...
+    private final ConcurrentMap<TextCacheKey, String> classHierarchyCache = 
new ConcurrentHashMap<>();
+    private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = 
new ConcurrentHashMap<>();
+```
+
+with (keeping `classLoaderMap` and `delegatedClassLoaderMap` as they are — 
they still use `ConcurrentMap`):
+
+```java
+    private volatile CacheType i18nCacheType = CacheType.WTLFU;
+    private volatile int i18nCacheMaxSize = 10000;
+
+    private <K, V> OgnlCache<K, V> buildI18nCache() {
+        return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize, 
i18nCacheType).buildOgnlCache();
+    }
+
+    protected OgnlCache<String, ResourceBundle> bundlesMap = buildI18nCache();
+    private OgnlCache<MessageFormatKey, MessageFormat> messageFormats = 
buildI18nCache();
+    private OgnlCache<String, Boolean> missingBundles = buildI18nCache();
+    private OgnlCache<TextCacheKey, String> classHierarchyCache = 
buildI18nCache();
+    private OgnlCache<TextCacheKey, String> packageHierarchyCache = 
buildI18nCache();
+```
+
+Keep the field ordering so `i18nCacheType`/`i18nCacheMaxSize` and 
`buildI18nCache()` are declared before the five cache fields (field 
initializers run top-to-bottom). Leave `boolean 
devMode`/`reloadBundles`/`searchDefaultBundlesFirst` where they are.
+
+- [ ] **Step 6: Add the injectable setters and rebuild helper**
+
+Near the other `@Inject` setters (e.g. after `setSearchDefaultBundlesFirst`, 
line ~405), add:
+
+```java
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+    public void setI18nCacheType(String cacheType) {
+        this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class, 
cacheType, CacheType.WTLFU);
+        rebuildI18nCaches();
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required = 
false)
+    public void setI18nCacheMaxSize(String cacheMaxSize) {
+        this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
+        rebuildI18nCaches();
+    }
+
+    /**
+     * Rebuilds the localized-text caches from the current type/size. Called 
during dependency injection
+     * (single-threaded startup, before the provider serves lookups); discards 
any warm-up entries.
+     */
+    private void rebuildI18nCaches() {
+        bundlesMap = buildI18nCache();
+        messageFormats = buildI18nCache();
+        missingBundles = buildI18nCache();
+        classHierarchyCache = buildI18nCache();
+        packageHierarchyCache = buildI18nCache();
+    }
+```
+
+- [ ] **Step 7: Update the `bundlesMap` / `missingBundles` call sites in 
`findResourceBundle`**
+
+Replace the body of `findResourceBundle` (lines ~408-442) that uses 
`missingBundles.contains` / `bundlesMap.containsKey` / `bundlesMap.get` / 
`putIfAbsent` / `missingBundles.add`. New body:
+
+```java
+    @Override
+    public ResourceBundle findResourceBundle(String bundleName, Locale locale) 
{
+        ClassLoader classLoader = getCurrentThreadContextClassLoader();
+        String key = createMissesKey(String.valueOf(classLoader.hashCode()), 
bundleName, locale);
+
+        if (missingBundles.get(key) != null) {
+            return null;
+        }
+
+        ResourceBundle bundle = null;
+        try {
+            bundle = bundlesMap.get(key);
+            if (bundle == null) {
+                bundle = ResourceBundle.getBundle(bundleName, locale, 
classLoader);
+                bundlesMap.putIfAbsent(key, bundle);
+            }
+        } catch (MissingResourceException ex) {
+            if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
+                try {
+                    bundle = bundlesMap.get(key);
+                    if (bundle == null) {
+                        bundle = ResourceBundle.getBundle(bundleName, locale, 
delegatedClassLoaderMap.get(classLoader.hashCode()));
+                        bundlesMap.putIfAbsent(key, bundle);
+                    }
+                } catch (MissingResourceException e) {
+                    LOG.debug("Missing resource bundle [{}]!", bundleName, e);
+                    missingBundles.put(key, Boolean.TRUE);
+                }
+            } else {
+                LOG.debug("Missing resource bundle [{}]!", bundleName);
+                missingBundles.put(key, Boolean.TRUE);
+            }
+        }
+        return bundle;
+    }
+```
+
+`clearBundle` (line ~209) needs no change: `bundlesMap.remove(key)` now calls 
`OgnlCache.remove` and still returns the removed `ResourceBundle`. 
`clearMissingBundlesCache` (`missingBundles.clear()`), and `reloadBundles` 
(`bundlesMap.clear()` etc.) also need no change — `clear()` is unchanged on 
`OgnlCache`. `buildMessageFormat` (`messageFormats.get`/`put`), 
`resolveClassHierarchyRaw`/`resolvePackageHierarchyRaw` (`get`/`putIfAbsent`) 
are signature-compatible and need no change.
+
+- [ ] **Step 8: Add the three size accessors**
+
+Next to the existing `classHierarchyCacheSize()`/`packageHierarchyCacheSize()` 
(lines ~103-111), add:
+
+```java
+    /** Test-support accessor: current number of cached resource bundles. */
+    protected int bundlesMapSize() {
+        return bundlesMap.size();
+    }
+
+    /** Test-support accessor: current number of cached missing-bundle 
markers. */
+    protected int missingBundlesSize() {
+        return missingBundles.size();
+    }
+
+    /** Test-support accessor: current number of cached message formats. */
+    protected int messageFormatsSize() {
+        return messageFormats.size();
+    }
+```
+
+- [ ] **Step 9: Run the new tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testCachesAreBoundedByConfiguredMaxSize+testCorrectTextStillReturnedUnderEviction+testReloadClearsBoundedCaches`
+Expected: PASS (3 tests).
+
+- [ ] **Step 10: Run the full provider suite to confirm no regression**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest`
+Expected: PASS (all existing tests + 3 new).
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+        core/src/main/resources/org/apache/struts2/default.properties \
+        
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java \
+        
core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+git commit -m "WW-5668 Bound the localized-text provider caches with 
configurable size
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 3: Opt-in request-locale resolution consistency in Dispatcher
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (add 
one constant)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties` 
(document it)
+- Modify: `core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java` 
(flag field, setter, helper, `getLocale` call sites)
+- Test: `core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java` 
(add tests)
+
+**Interfaces:**
+- Consumes: `org.apache.commons.lang3.LocaleUtils.isAvailableLocale(Locale)` 
(LocaleUtils already imported in `Dispatcher`).
+- Produces: constant `STRUTS_LOCALE_VALIDATE_REQUEST = 
"struts.locale.validateRequestLocale"`; 
`Dispatcher.setValidateRequestLocale(String)`; private `Locale 
resolveRequestLocale(HttpServletRequest)`.
+
+- [ ] **Step 1: Add the constant**
+
+In `StrutsConstants.java`, after `STRUTS_LOCALE` (line 136), add:
+
+```java
+    /**
+     * When enabled, request-derived locales (from {@code Accept-Language}, 
used when {@code struts.locale} is
+     * unset) are restricted to the JVM's available-locale set; unavailable 
values fall back to the default.
+     *
+     * @since 7.3.0
+     */
+    public static final String STRUTS_LOCALE_VALIDATE_REQUEST = 
"struts.locale.validateRequestLocale";
+```
+
+- [ ] **Step 2: Document it in default.properties**
+
+In `default.properties`, after the `# struts.locale=en_US` line (line ~26), 
add:
+
+```properties
+### When true, restrict request-derived locales (Accept-Language, used when 
struts.locale is unset) to the
+### JVM's available-locale set; unavailable values fall back to the default 
locale. Defaults to false.
+struts.locale.validateRequestLocale=false
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+In `DispatcherTest.java` (JUnit 4, uses Mockito), add — modelled on the 
existing `getLocale` tests around lines 481-532:
+
+```java
+    @Test
+    public void testValidateRequestLocaleOffPassesThrough() throws Exception {
+        Dispatcher du = initDispatcher(new HashMap<>());
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        // A syntactically valid but not JVM-available locale.
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        assertEquals("Default off must pass the request locale through 
unchanged",
+                exotic, du.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnKeepsAvailableLocale() throws 
Exception {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        Dispatcher du = initDispatcher(params);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getLocale()).thenReturn(Locale.UK);
+
+        assertEquals("Available request locale must be kept", Locale.UK, 
du.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnFallsBackForUnavailableLocale() 
throws Exception {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        Dispatcher du = initDispatcher(params);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        // struts.locale unset in this dispatcher -> fall back to the JVM 
default.
+        assertEquals("Unavailable request locale must fall back to system 
default",
+                Locale.getDefault(), du.getLocale(request));
+    }
+```
+
+Note: reuse the test's existing helper for building a `Dispatcher` with init 
params. If the existing tests use a different constructor pattern than 
`initDispatcher(Map)`, match whatever those `getLocale` tests already use to 
build `du` (check the top of the nearest existing `getLocale` test and copy its 
setup verbatim).
+
+- [ ] **Step 4: Run the new tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DispatcherTest#testValidateRequestLocaleOffPassesThrough+testValidateRequestLocaleOnKeepsAvailableLocale+testValidateRequestLocaleOnFallsBackForUnavailableLocale`
+Expected: COMPILE FAILURE — `STRUTS_LOCALE_VALIDATE_REQUEST` / setter not 
present.
+
+- [ ] **Step 5: Add the flag field and setter**
+
+In `Dispatcher.java`, near `defaultLocale` (line ~152) add a field:
+
+```java
+    private boolean validateRequestLocale = false;
+```
+
+Near `setDefaultLocale` (line ~308) add:
+
+```java
+    @Inject(value = StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, required = 
false)
+    public void setValidateRequestLocale(String val) {
+        validateRequestLocale = Boolean.parseBoolean(val);
+    }
+```
+
+- [ ] **Step 6: Add the resolution helper and route both call sites through 
it**
+
+In `Dispatcher.getLocale(HttpServletRequest)` (lines ~925-950), replace the 
two `locale = request.getLocale();` calls (lines ~932 and ~943) with `locale = 
resolveRequestLocale(request);`. Then add the helper directly below `getLocale`:
+
+```java
+    /**
+     * Resolves the request locale. When {@code 
struts.locale.validateRequestLocale} is enabled and the
+     * request locale is not part of the JVM's available-locale set, falls 
back to the configured
+     * {@code struts.locale} when set and parseable, otherwise the JVM 
default. When disabled (default),
+     * returns the request locale unchanged.
+     */
+    protected Locale resolveRequestLocale(HttpServletRequest request) {
+        Locale locale = request.getLocale();
+        if (!validateRequestLocale || LocaleUtils.isAvailableLocale(locale)) {
+            return locale;
+        }
+        if (defaultLocale != null) {
+            try {
+                return LocaleUtils.toLocale(defaultLocale);
+            } catch (IllegalArgumentException e) {
+                LOG.debug("Configured 'struts.locale' = [{}] is not parseable; 
falling back to system default", defaultLocale);
+            }
+        }
+        LOG.debug("Request locale [{}] is not available; falling back to 
system default locale", locale);
+        return Locale.getDefault();
+    }
+```
+
+(The `defaultLocale`-set-and-parseable branch is inert at the two current call 
sites — see the spec's fallback note — but is retained as the helper's general 
contract. Do not add logic assuming it fires.)
+
+- [ ] **Step 7: Run the new tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DispatcherTest#testValidateRequestLocaleOffPassesThrough+testValidateRequestLocaleOnKeepsAvailableLocale+testValidateRequestLocaleOnFallsBackForUnavailableLocale`
+Expected: PASS (3 tests).
+
+- [ ] **Step 8: Run the full Dispatcher suite to confirm no regression**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=DispatcherTest`
+Expected: PASS — existing `getLocale` tests unaffected (flag defaults off).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+        core/src/main/resources/org/apache/struts2/default.properties \
+        core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java \
+        core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+git commit -m "WW-5668 Add opt-in request-locale resolution consistency to 
Dispatcher
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 4: Whole-change verification
+
+- [ ] **Step 1: Run both affected suites together**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=OgnlCacheRemoveTest,StrutsLocalizedTextProviderTest,DispatcherTest,OgnlUtilTest`
+Expected: all PASS.
+
+- [ ] **Step 2: Run the broader i18n/dispatcher regression set**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest="*LocalizedText*,*Dispatcher*,*Ognl*"`
+Expected: all PASS. If any pre-existing unrelated flake appears (e.g. 
jasperreports temp-file), note it and re-run the specific class.
+
+- [ ] **Step 3: Confirm the working tree is clean and the branch is ready**
+
+Run: `git status --short && git log --oneline main..HEAD`
+Expected: no uncommitted changes; three `WW-5668` commits (Task 1-3).
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Part 1 bounded caches (reuse OgnlCache, add `remove`, five caches, config 
constants, default.properties, correctness-safe eviction) → Tasks 1 + 2. ✓
+- Part 1 sizing default `wtlfu`/`10000` single shared size → Task 2 Steps 1-2, 
5. ✓
+- Part 2 opt-in `struts.locale.validateRequestLocale` default false, helper 
mirroring `I18nInterceptor`, fallback to configured `struts.locale` else 
`Locale.getDefault()`, inertness caveat → Task 3. ✓
+- Testing: bound invariant, correctness under eviction, clear/remove, flag 
off/on/fallback → Task 2 Step 3, Task 3 Step 3. ✓
+- Out-of-scope (rename, per-cache sizes, path consolidation) → not 
implemented, correctly deferred. ✓
+
+**Placeholder scan:** No TBD/TODO; all steps contain concrete code or exact 
commands. The one soft spot — Task 3 Step 3's note to match the existing 
`getLocale` test's dispatcher-construction helper — is a real instruction to 
copy verified local code, not a placeholder, because the surrounding 
`getLocale` tests already build `du` that way.
+
+**Type consistency:** `OgnlCache.remove(K)→V` used identically in Task 1 
(definition) and Task 2 (clearBundle relies on it). `CacheType`, 
`DefaultOgnlCacheFactory(int, CacheType)`, `buildOgnlCache()` match the real 
signatures. Size accessors 
`bundlesMapSize`/`missingBundlesSize`/`messageFormatsSize` defined in Task 2 
Step 8, used in Task 2 Step 3. `setI18nCacheMaxSize(String)` defined Step 6, 
used Step 3. `STRUTS_I18N_CACHE_*` and `STRUTS_LOCALE_VALIDATE_REQUEST` defined 
and consumed con [...]
diff --git 
a/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
 
b/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
new file mode 100644
index 000000000..3a53ece5b
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
@@ -0,0 +1,215 @@
+# WW-5668 — Bounded localized-text caches and consistent request-locale 
resolution
+
+- **Ticket:** [WW-5668](https://issues.apache.org/jira/browse/WW-5668)
+- **Type:** Improvement
+- **Fix version:** 7.3.0
+- **Date:** 2026-07-30
+
+> Framing note: this is a follow-up to WW-5540. Keep all wording — spec, code,
+> commits, tests — in terms of cache bounds, eviction, configurability, and
+> resolution consistency. No security/DoS/attacker language.
+
+## Summary
+
+Two related changes to the localized-text subsystem:
+
+1. Give the `AbstractLocalizedTextProvider` caches a configurable maximum size 
and
+   eviction, using the framework's existing cache abstraction, so their 
footprint
+   is bounded like the OGNL expression, BeanInfo, and proxy caches already are.
+2. Make request-derived locale resolution consistent between `Dispatcher` and
+   `I18nInterceptor`, behind an opt-in flag, so an operator can restrict
+   request-derived locales to the JVM's available-locale set.
+
+The two parts are independent. Part 1 is always on. Part 2 defaults off.
+
+## Part 1 — Bounded localized-text caches
+
+### Current state
+
+`AbstractLocalizedTextProvider` holds five internal caches as plain
+`ConcurrentHashMap` / `ConcurrentHashMap.newKeySet`:
+
+| Field | Type | Key |
+|---|---|---|
+| `bundlesMap` | `ConcurrentMap<String, ResourceBundle>` | classloader + 
bundleName + locale |
+| `missingBundles` | `Set<String>` | classloader + bundleName + locale |
+| `messageFormats` | `ConcurrentMap<MessageFormatKey, MessageFormat>` | 
pattern + locale |
+| `classHierarchyCache` | `ConcurrentMap<TextCacheKey, String>` | classloader 
+ className + key + locale |
+| `packageHierarchyCache` | `ConcurrentMap<TextCacheKey, String>` | 
classloader + className + key + locale |
+
+None has a configurable upper bound or eviction. Elsewhere the framework
+standardises on bounded caches via `OgnlCacheFactory` / 
`DefaultOgnlCacheFactory`
+(`struts.ognl.expressionCacheMaxSize=10000`, `struts.proxy.cacheMaxSize=10000`,
+both `wtlfu`). These five are the outlier.
+
+### Approach
+
+Reuse the existing cache abstraction (`OgnlCache` + `DefaultOgnlCacheFactory`)
+rather than introducing a new one or calling Caffeine directly.
+
+**Interface change (additive):** add `V remove(K key)` to `OgnlCache<K, V>`.
+- `OgnlCaffeineCache`: `return cache.asMap().remove(key);`
+- `OgnlDefaultCache`, `OgnlLRUCache`: delegate to the backing map's `remove`.
+- A `default` implementation is acceptable if it keeps existing impls 
compiling,
+  but each impl should override with the native map removal so `clearBundle`
+  keeps returning the removed value for its debug log.
+
+This is the only change to shared OGNL code, and it is purely additive.
+
+**Provider change:** replace the five fields with `OgnlCache` instances built 
from a
+`DefaultOgnlCacheFactory`, mapping call sites:
+
+| Field | New type | Call-site mapping |
+|---|---|---|
+| `bundlesMap` | `OgnlCache<String, ResourceBundle>` | `containsKey` → `get(k) 
!= null`; keep `putIfAbsent`; `remove` |
+| `missingBundles` | `OgnlCache<String, Boolean>` | `contains` → `get(k) != 
null`; `add` → `put(k, Boolean.TRUE)` |
+| `messageFormats` | `OgnlCache<MessageFormatKey, MessageFormat>` | `get` / 
`put` |
+| `classHierarchyCache` | `OgnlCache<TextCacheKey, String>` | `get` / 
`putIfAbsent` / `clear` / `size` |
+| `packageHierarchyCache` | `OgnlCache<TextCacheKey, String>` | `get` / 
`putIfAbsent` / `clear` / `size` |
+
+`reloadBundles`, `clearBundle`, and `clearMissingBundlesCache` keep calling
+`clear()` / `remove()` on these caches exactly as before. The `NOT_FOUND` 
identity
+sentinel in the hierarchy caches is unchanged.
+
+### Why eviction is correctness-safe
+
+All five are pure caches: every entry is fully reconstructible on a miss 
(reload the
+bundle, re-record a miss, rebuild the `MessageFormat`, re-walk the 
class/package
+hierarchy). WTLFU eviction can therefore only cause an occasional recompute, 
never a
+wrong or stale localized result. This preserves the WW-5540 behaviour while 
bounding
+memory.
+
+### Configuration
+
+Two new constants, applied independently to each of the five caches:
+
+| Constant | Values | Default |
+|---|---|---|
+| `struts.i18n.cacheType` | `basic` \| `lru` \| `wtlfu` | `wtlfu` |
+| `struts.i18n.cacheMaxSize` | integer | `10000` |
+
+- Injected via `@Inject(..., required = false)` setters on
+  `AbstractLocalizedTextProvider`, following the existing i18n setters
+  (`setReloadBundles`, `setDevMode`, `setSearchDefaultBundlesFirst`).
+- New keys in `StrutsConstants`.
+- Documented in `default.properties` beside the OGNL/proxy cache settings.
+- A single `cacheMaxSize` governs all five caches for now. Per-cache tuning is
+  deliberately out of scope — see Out of scope / follow-ups.
+
+### Sizing note (accepted trade-off)
+
+The WW-5540 hierarchy caches are keyed by `(classloader, className, key, 
locale)`.
+A very large application under normal single-locale traffic could have more 
than
+10,000 distinct `(class, key)` pairs, in which case a 10,000 bound causes 
eviction
+churn and partially erodes WW-5540's caching benefit (correctness unaffected, 
only
+recompute cost). WTLFU retains the hot entries, so typical applications are
+unaffected, and the bound is configurable. A single default of 10,000 
(matching the
+OGNL/proxy caches) is accepted for this ticket; per-cache tuning is a 
follow-up.
+
+## Part 2 — Consistent request-locale resolution
+
+### Current state
+
+Struts resolves a request locale in more than one place, inconsistently:
+
+- `I18nInterceptor.getLocaleFromParam(...)` resolves the `request_locale`
+  parameter / cookie / session value, then checks it against the 
available-locale
+  set via `LocaleProvider.isValidLocale(...)` (→ 
`LocaleUtils.isAvailableLocale`),
+  falling back to the default locale when it is not available.
+- `Dispatcher.getLocale(HttpServletRequest)` — used when `struts.locale` is 
unset,
+  and as the fallback when a configured `struts.locale` is malformed — returns
+  `request.getLocale()` directly, with no availability check.
+
+### Approach
+
+Add an opt-in flag and a single resolution helper in `Dispatcher`.
+
+**New constant:**
+
+| Constant | Values | Default |
+|---|---|---|
+| `struts.locale.validateRequestLocale` | boolean | `false` |
+
+Default `false` preserves current behaviour byte-for-byte.
+
+**Helper** `resolveRequestLocale(HttpServletRequest request)`, used at both
+`request.getLocale()` sites in `Dispatcher.getLocale` (the 
`struts.locale`-unset
+branch and the malformed-`struts.locale` fallback branch):
+
+1. `locale = request.getLocale()`.
+2. If `!validateRequestLocale` → return `locale` (current behaviour).
+3. Else if `LocaleUtils.isAvailableLocale(locale)` → return `locale`.
+4. Else → fall back to the configured `struts.locale` if set and parseable,
+   otherwise `Locale.getDefault()`; log at debug.
+
+The existing `RuntimeException` handling around `request.getLocale()` (falling 
back
+to `Locale.getDefault()`) is retained.
+
+**Note on the fallback at the current call sites.** Both call sites live inside
+branches that only execute when `struts.locale` is unset (branch 1) or set but
+malformed/unparseable (branch 2). In neither case is a configured 
`struts.locale`
+usable as a fallback, so in practice step 4 resolves to `Locale.getDefault()` 
today.
+The "configured `struts.locale` if set and parseable" clause is retained as the
+helper's general contract (matching the `I18nInterceptor` spirit and keeping 
the
+helper self-contained), but it is inert at the present call sites — the
+implementation must not add dead logic that assumes it fires.
+
+**Reuse vs. duplication.** `Dispatcher` runs before an `ActionContext` /
+`LocaleProvider` is necessarily available, so the helper calls
+`LocaleUtils.isAvailableLocale(...)` directly — the same underlying check
+`I18nInterceptor` reaches through `LocaleProvider.isValidLocale`. The two 
paths share
+the same semantics without sharing a code path. Consolidating them into one 
shared
+locale-resolution component is deliberately out of scope.
+
+**Injection:** `@Inject(value = "struts.locale.validateRequestLocale",
+required = false)` setter on `Dispatcher`, parsed to boolean, consistent with 
how
+`struts.locale` (`defaultLocale`) is already injected.
+
+**Docs:** new constant in `default.properties`, commented, near `struts.locale`
+("restrict request-derived locales to the JVM's available-locale set").
+
+## Testing
+
+Core tests extend `XWorkTestCase` and are JUnit 3/4 — no JUnit-5 `@Test`
+annotations (they would silently not run). Test names stay neutral
+(bounds/eviction/resolution).
+
+### Part 1
+
+- **Bound invariant** (the regression test): with a small configured
+  `cacheMaxSize` (e.g. 1000), drive `findText` with many distinct locales
+  (e.g. 50,000); assert each of the five caches stays on the order of the 
bound,
+  not the number of distinct locales.
+- **Correctness under eviction:** normal and repeated locales still return the
+  correct localized text; a real bundle key still resolves under eviction
+  pressure; the `NOT_FOUND` sentinel path still works.
+- **Configurability:** `cacheMaxSize` changes the ceiling; `cacheType` selects 
the
+  implementation.
+- **Clear/remove:** `reloadBundles` empties the caches; `clearBundle` removes 
the
+  targeted entry.
+
+### Part 2 (extend the existing `Dispatcher` test)
+
+- Flag **off** (default): any request locale passes through unchanged, 
including a
+  valid-but-unavailable one (proves zero behaviour change).
+- Flag **on** + available locale → returned unchanged.
+- Flag **on** + valid-but-unavailable locale → falls back to configured
+  `struts.locale` when set, otherwise `Locale.getDefault()`.
+
+## Backward compatibility
+
+- Part 1 changes internal cache implementations only; no public API or 
behavioural
+  change for normal localized-text usage. Eviction is correctness-safe. Default
+  `cacheMaxSize=10000` matches existing cache conventions. `protected` cache 
fields
+  change type — acceptable for internal framework state; noted for subclasses.
+- Part 2 is fully opt-in; default `false` preserves current behaviour exactly.
+- The added `OgnlCache.remove` is additive.
+
+## Out of scope / follow-ups (separate tickets)
+
+- Rename `OgnlCache*` → `StrutsCache*` to remove the `Ognl` name leak now that 
the
+  abstraction is used outside OGNL. (Separate ticket.)
+- Per-cache size constants (distinct bounds for the hierarchy caches vs.
+  bundle/format/missing caches). (Separate ticket.)
+- Consolidating `Dispatcher` and `I18nInterceptor` locale resolution into one
+  shared component. (Not planned.)

Reply via email to