WW-5540 Add caching to AbstractLocalizedTextProvider#1808
WW-5540 Add caching to AbstractLocalizedTextProvider#1808lukaszlenart wants to merge 19 commits into
Conversation
Design for caching the class/package hierarchy traversal result in findText, keyed on (classloader, class name, textKey, locale). Caches the raw resolved pattern (or a NOT_FOUND marker) only; translation and formatting stay per-call. Wires invalidation into the existing reloadBundles/clearBundle/clearMissingBundlesCache sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the 3-task TDD implementation plan and record the formatWithNullDetection fall-through decision in the spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve pre-flight duplication/dead-code finding: old traversal helpers delegate to the raw twins and are marked @deprecated instead of being duplicated. Add a direct characterization test for the findMessage delegator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add getRawMessage/formatMessage and a raw twin findMessageRaw. Re-express getMessage via formatMessage and make findMessage delegate to findMessageRaw + formatMessage; deprecate both as legacy extension points superseded by the raw-resolution path. The deprecated findMessage triggers the bundle reload on entry, preserving the reload side effect the old getMessage-per-probe walk provided. Groundwork for the traversal caches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cache the class/interface/superclass traversal in findText keyed on (classloader, class name, key, locale), storing the raw pattern or a NOT_FOUND marker. Formatting stays per call and falls through to the next tier when a cached pattern formats to null. Invalidated on reloadBundles/clearBundle/clearMissingBundlesCache; reload is hoisted to the top of findText so caches are cleared before they are read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cache the *.package traversal in findText the same way as the class hierarchy, with the same keying, fall-through, and invalidation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assert single cache entry in the per-call-format tests (proves the raw pattern is cached, not the formatted result), and mirror the package-cache clearBundle/clearMissingBundlesCache invalidation test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket is filed; the draft's content now lives in WW-5655 itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves Struts i18n lookup performance by caching the expensive class/interface/superclass and *.package hierarchy traversals performed by StrutsLocalizedTextProvider.findText, while keeping OGNL translation and MessageFormat rendering per call to preserve dynamic behavior.
Changes:
- Split raw pattern lookup (cacheable) from formatting/translation (per call) in
AbstractLocalizedTextProvider. - Add two unbounded
ConcurrentHashMapcaches for class-hierarchy and package-hierarchy raw lookups, with invalidation wired into reload/clear paths. - Add targeted regression tests and fixtures to verify cache hits, miss caching, per-call formatting/OGNL behavior, null-format fall-through, and invalidation.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/plans/2026-07-23-WW-5540-localized-text-provider-caching.md | Implementation plan/spec documenting the caching approach and constraints. |
| core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java | Introduces raw-pattern resolution + formatting split, cache keys/sentinels, cache resolvers, and invalidation hooks. |
| core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java | Wires cached raw resolution into findText for class/model/package tiers and hoists reload. |
| core/src/test/java/org/apache/struts2/text/CacheFixture.java | Test fixture class used for cache behavior verification. |
| core/src/test/resources/org/apache/struts2/text/CacheFixture.properties | Bundle backing the cache tests (static, param, OGNL, null-format cases). |
| core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java | Adds new tests and exposes cache sizes via a test provider subclass. |
Comments suppressed due to low confidence (1)
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java:705
resolvePackageHierarchyRaw’s cache key ignoresindexedTextNameeven though the underlying walk checks bothtextKeyandindexedTextName. If callers vary the indexed form for the same base key, this cache can return the wrong raw pattern. Include the indexed key in the cache key (or derive it internally and drop the parameter).
protected String resolvePackageHierarchyRaw(Class<?> startClazz, String textKey, String indexedTextName, Locale locale) {
TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), startClazz.getName(), textKey, locale);
String cached = packageHierarchyCache.get(cacheKey);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Document that the deprecated getMessage/findMessage are no longer invoked by findText, and name formatMessage as the override point - Fall back to the ActionContext-based reloadBundles() when findText is called without a value stack, so the RELOADED flag is tracked and the caches can warm on that path in reload/devMode - Narrow resolveClassHierarchyRaw/resolvePackageHierarchyRaw to package-private (the cache key omits indexedKey, which is safe only when derived from textKey as the internal call sites do) - Suppress java:S2129 on the NOT_FOUND identity sentinel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two literal NUL bytes in the sentinel example made git/GitHub treat the whole markdown file as binary and unreviewable in the PR UI; align the example with the shipped sentinel name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keys Close the review-noted coverage gaps: the ModelDriven tier resolves via the shared class-hierarchy cache (action miss + model hit), each locale gets its own cache entry backed by a new _de fixture bundle, and indexed keys (name[N] -> name[*]) resolve and cache per full textKey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Partition the caches by System.identityHashCode of the context classloader so a custom ClassLoader overriding hashCode() cannot collide or collapse the per-loader partitions - Derive the indexed key inside the resolvers (miss-only) instead of accepting it as a parameter, so the cache key trivially covers every input that influences the resolution result Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Suppress S4973 on isNotFound: the identity comparison against the non-interned NOT_FOUND sentinel is the design, not a bug - Reduce findMessageRaw cognitive complexity (S3776) by extracting getRawMessageWithAlternate, reused by the package walk - Add missing @OverRide annotations and suppress the deliberate deprecated-delegator call in the test helper (S1161, S5738) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java:73
- When
valueStackis null, this falls back toreloadBundles(). IfActionContext.getContext()is also null,reloadBundles()will invokereloadBundles(null), which cannot set the per-requestRELOADEDflag and will therefore clearbundlesMapand the new caches on every call in reload mode. Consider only calling the ActionContext-based overload when anActionContextis present; otherwise skip the reload call (matching the previous behavior for stack-less calls and avoiding pathological repeated clears).
// Trigger bundle reload (and cache invalidation) once, before any cached hierarchy lookup,
// so that in reload/devMode the hierarchy caches are cleared before they are read. With no
// value stack, fall back to the ActionContext-based overload so the RELOADED flag is still
// tracked and the caches can warm on that path too.
if (valueStack != null) {
| private String findMessageRaw(Class<?> clazz, String key, String indexedKey, Locale locale, Set<String> checked) { | ||
| if (checked == null) { | ||
| checked = new TreeSet<>(); | ||
| } else if (checked.contains(clazz.getName())) { | ||
| return null; | ||
| } | ||
|
|



Fixes WW-5540
What
Caches the class- and package-hierarchy traversal performed by
StrutsLocalizedTextProvider.findText(Class, …), so repeated lookups for the same(classloader, class name, key, locale)collapse to a singleConcurrentHashMaplookup instead of re-walking the class → interface → superclass →
*.packagehierarchy on every call. This path backs every UI tag label and validation message
rendered per request; missing keys were especially costly (full hierarchy walk +
swallowed
MissingResourceExceptionper level, repeated every request).How
rendering. Only the raw pattern (or a
NOT_FOUNDidentity sentinel) is cached;OGNL
translateVariablesandMessageFormatargument substitution still run percall, so dynamic
${…}messages and per-call args remain correct.AbstractLocalizedTextProvider(classHierarchyCache,packageHierarchyCache), keyed on the classloader hash + class name (String) —never a
Classobject, so no classloader pinning.reloadBundles,clearBundle, andclearMissingBundlesCache; the reload check is hoisted to the top offindTextsodevMode/
struts.i18n.reload=trueclears the caches before they are read.getMessage/findMessageare retained as@Deprecateddelegators fordescendant classes.
Behavior
Behavior-preserving: lookup order, dynamic-message evaluation, argument formatting,
and devMode/reload semantics are unchanged. The one documented, accepted divergence is
a pathological case where the same key is redefined at multiple levels of a single class
hierarchy and the shallowest match formats to the literal string
"null". Caches areunbounded, consistent with the existing
bundlesMap/missingBundles. See the designspec under
docs/superpowers/specs/for details.Testing
mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest→ 33/33 green(11 new tests covering raw-only caching, per-call formatting/OGNL, miss caching, the
null-format fall-through, and cache invalidation at all three clear sites). Broader
localized-text slice: 54/54 green.
Follow-up
A separate, deferred cleanup (replacing the
null-overloaded control flow with anexplicit result type) is filed as WW-5655.
🤖 Generated with Claude Code