Skip to content

WW-5540 Add caching to AbstractLocalizedTextProvider#1808

Open
lukaszlenart wants to merge 19 commits into
mainfrom
WW-5540-localized-text-provider-caching
Open

WW-5540 Add caching to AbstractLocalizedTextProvider#1808
lukaszlenart wants to merge 19 commits into
mainfrom
WW-5540-localized-text-provider-caching

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 single ConcurrentHashMap
lookup instead of re-walking the class → interface → superclass → *.package
hierarchy 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 MissingResourceException per level, repeated every request).

How

  • Raw / format split — resolution of the raw message pattern is separated from
    rendering. Only the raw pattern (or a NOT_FOUND identity sentinel) is cached;
    OGNL translateVariables and MessageFormat argument substitution still run per
    call
    , so dynamic ${…} messages and per-call args remain correct.
  • Two caches in AbstractLocalizedTextProvider (classHierarchyCache,
    packageHierarchyCache), keyed on the classloader hash + class name (String)
    never a Class object, so no classloader pinning.
  • Invalidation wired into reloadBundles, clearBundle, and
    clearMissingBundlesCache; the reload check is hoisted to the top of findText so
    devMode/struts.i18n.reload=true clears the caches before they are read.
  • getMessage / findMessage are retained as @Deprecated delegators for
    descendant 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 are
unbounded, consistent with the existing bundlesMap / missingBundles. See the design
spec 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 an
explicit result type) is filed as WW-5655.

🤖 Generated with Claude Code

lukaszlenart and others added 14 commits July 23, 2026 10:00
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>
Capture the deferred result-wrapper refactor (raised during WW-5540) as a
ready-to-file Jira draft; keep WW-5540 focused on caching.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ConcurrentHashMap caches 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 ignores indexedTextName even though the underlying walk checks both textKey and indexedTextName. 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.

Comment thread core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java Outdated
lukaszlenart and others added 5 commits July 23, 2026 14:06
- 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>
@sonarqubecloud

Copy link
Copy Markdown

@lukaszlenart
lukaszlenart marked this pull request as ready for review July 23, 2026 12:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 valueStack is null, this falls back to reloadBundles(). If ActionContext.getContext() is also null, reloadBundles() will invoke reloadBundles(null), which cannot set the per-request RELOADED flag and will therefore clear bundlesMap and the new caches on every call in reload mode. Consider only calling the ActionContext-based overload when an ActionContext is 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) {

Comment on lines +600 to 606
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;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants