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

joerghoh pushed a commit to branch master
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-i18n.git


The following commit(s) were added to refs/heads/master by this push:
     new bd9650f  SLING-13392 Refresh i18n parents before dependent bundles 
(#24)
bd9650f is described below

commit bd9650fab0ec6a3b6dffb245c9955cc2248fff35
Author: Jörg Hoh <[email protected]>
AuthorDate: Mon Oct 19 12:31:44 2026 +0200

    SLING-13392 Refresh i18n parents before dependent bundles (#24)
    
    * SLING-13392: Refresh i18n parents before dependent bundles
---
 README.md                                          |   8 ++
 .../sling/i18n/impl/JcrResourceBundleProvider.java |   8 +-
 .../ConcurrentJcrResourceBundleLoadingTest.java    | 119 ++++++++++++++++++++-
 .../i18n/it/EagerResourceBundleProviderIT.java     |  43 ++++++++
 .../sling/i18n/it/ResourceBundleProviderIT.java    |  21 ++++
 5 files changed, 193 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index e6e2d53..64dc559 100644
--- a/README.md
+++ b/README.md
@@ -8,3 +8,11 @@ This module is part of the [Apache 
Sling](https://sling.apache.org) project.
 
 Support for creating Java I18N ResourceBundles from repository
 resources. Further details in 
<https://sling.apache.org/documentation/bundles/internationalization-support-i18n.html>.
+
+## Dictionary updates
+
+When `preload.bundles` is enabled, an individual bundle refresh reloads parents
+before their dependent locales so that refreshed bundles use current fallback
+translations. With preloading disabled, affected bundles are invalidated and
+loaded on demand. Existing bundle objects remain snapshots; callers should
+request a bundle again to obtain refreshed translations.
diff --git 
a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java 
b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
index 15a57e9..97047ab 100644
--- a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
+++ b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
@@ -407,14 +407,14 @@ public class JcrResourceBundleProvider
                 }
             }
         }
-        for (JcrResourceBundle dependentBundle : dependentBundles) {
-            reloadBundle(new Key(dependentBundle.getBaseName(), 
dependentBundle.getLocale()));
-        }
-
         if (this.preloadBundles && !resourceBundleRegistry.isClosed()) {
             // reload the bundle from the repository (will also fill cache and 
register as a service)
             getResourceBundleInternal(null, key.baseName, key.locale, true);
         }
+
+        for (JcrResourceBundle dependentBundle : dependentBundles) {
+            reloadBundle(new Key(dependentBundle.getBaseName(), 
dependentBundle.getLocale()));
+        }
     }
 
     // ---------- SCR Integration 
----------------------------------------------
diff --git 
a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
 
b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
index 7f3d506..bb8b670 100644
--- 
a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
+++ 
b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
@@ -35,6 +35,10 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
 import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.apache.sling.i18n.impl.JcrResourceBundleProvider.Key;
@@ -46,6 +50,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.mockito.Mockito;
+import org.osgi.framework.Constants;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -56,8 +61,8 @@ import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
 
 /**
- * Test case to verify that each bundle is only loaded once, even
- * if concurrent requests for the same bundle are made.
+ * Verifies bundle caching, concurrent loading, and refreshed fallback chains
+ * with both eager preloading and lazy invalidation.
  */
 @RunWith(Parameterized.class)
 public class ConcurrentJcrResourceBundleLoadingTest {
@@ -74,9 +79,18 @@ public class ConcurrentJcrResourceBundleLoadingTest {
     public Boolean preload = Boolean.FALSE;
 
     private JcrResourceBundleProvider provider;
+    private final List<Resource> dictionaryRoots = new ArrayList<>();
 
     @Before
     public void setup() throws Exception {
+        ResourceResolver resolver = Mockito.spy(context.resourceResolver());
+        Mockito.doNothing().when(resolver).close();
+        Mockito.doAnswer(query -> dictionaryRoots.iterator())
+                .when(resolver)
+                .findResources(JcrResourceBundle.QUERY_LANGUAGE_ROOTS, 
"xpath");
+        ResourceResolverFactory resolverFactory = 
Mockito.mock(ResourceResolverFactory.class);
+        
Mockito.when(resolverFactory.getServiceResourceResolver(null)).thenReturn(resolver);
+        context.registerService(ResourceResolverFactory.class, 
resolverFactory, Constants.SERVICE_RANKING, 1000);
         // mock other required services
         Scheduler mockScheduler = context.registerService(Scheduler.class, 
Mockito.mock(Scheduler.class));
         // mock this call to avoid a NPE during activation
@@ -143,6 +157,10 @@ public class ConcurrentJcrResourceBundleLoadingTest {
         assertEquals(1, germanSet.size());
     }
 
+    /**
+     * Reloading a child must preserve its unchanged parent, both in the cache
+     * and in the child's fallback reference.
+     */
     @Test
     public void newBundleUsedAfterReload() throws Exception {
         ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
@@ -153,12 +171,108 @@ public class ConcurrentJcrResourceBundleLoadingTest {
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
         assertNotSame(german2, german);
+        assertSame(english, ((JcrResourceBundle) german2).getParent());
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
     }
 
+    /**
+     * Changes an English fallback shared by the de -> en, de_DE -> de -> en,
+     * and fr -> en chains. Refreshed children must reference the current 
parents
+     * and return the new fallback while retaining locale-specific overrides.
+     * An explicitly unnamed reload must leave the named bundle chain 
untouched.
+     */
+    @Test
+    public void reloadedFallbacksUseCurrentParents() {
+        Resource englishDictionary = createDictionary("en", "en");
+        Resource fallback =
+                context.create().resource(englishDictionary.getPath() + 
"/fallback", "sling:message", "old English");
+        Resource germanDictionary = createDictionary("de", "de");
+        context.create().resource(germanDictionary.getPath() + "/override", 
"sling:message", "German override");
+        Resource countryDictionary = createDictionary("de_DE", "de_DE");
+        context.create().resource(countryDictionary.getPath() + "/override", 
"sling:message", "Country override");
+        Resource namedDictionary = createDictionary("named-en", "en", "named");
+        context.create().resource(namedDictionary.getPath() + "/named", 
"sling:message", "Named English");
+
+        ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle country = provider.getResourceBundle(Locale.GERMANY);
+        ResourceBundle french = provider.getResourceBundle(Locale.FRENCH);
+        ResourceBundle named = provider.getResourceBundle("named", 
Locale.GERMANY);
+        assertEquals("old English", country.getString("fallback"));
+        assertEquals("old English", french.getString("fallback"));
+
+        fallback.adaptTo(ModifiableValueMap.class).put("sling:message", "new 
English");
+        provider.reloadBundle(new Key(null, Locale.ENGLISH));
+
+        ResourceBundle english2 = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle country2 = provider.getResourceBundle(Locale.GERMANY);
+        ResourceBundle french2 = provider.getResourceBundle(Locale.FRENCH);
+        assertNotSame(english, english2);
+        assertNotSame(german, german2);
+        assertNotSame(country, country2);
+        assertNotSame(french, french2);
+        assertSame(english2, ((JcrResourceBundle) german2).getParent());
+        assertSame(german2, ((JcrResourceBundle) country2).getParent());
+        assertSame(english2, ((JcrResourceBundle) french2).getParent());
+        assertEquals("new English", country2.getString("fallback"));
+        assertEquals("new English", french2.getString("fallback"));
+        assertEquals("German override", german2.getString("override"));
+        assertEquals("Country override", country2.getString("override"));
+        assertSame(named, provider.getResourceBundle("named", Locale.GERMANY));
+        assertEquals("Named English", named.getString("named"));
+    }
+
+    /**
+     * Refreshes the named en -> de -> de_DE dependency chain and verifies its
+     * updated fallback value and parent references. Explicitly refreshing that
+     * basename must not replace the cached unnamed bundles.
+     */
+    @Test
+    public void namedFallbackRefreshDoesNotReplaceOtherBasenames() {
+        Resource dictionary = createDictionary("named-en", "en", "named");
+        Resource message =
+                context.create().resource(dictionary.getPath() + "/fallback", 
"sling:message", "old named English");
+        ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle named = provider.getResourceBundle("named", 
Locale.GERMANY);
+        assertEquals("old named English", named.getString("fallback"));
+
+        message.adaptTo(ModifiableValueMap.class).put("sling:message", "new 
named English");
+        provider.reloadBundle(new Key("named", Locale.ENGLISH));
+
+        ResourceBundle named2 = provider.getResourceBundle("named", 
Locale.GERMANY);
+        JcrResourceBundle namedGerman = (JcrResourceBundle) 
provider.getResourceBundle("named", Locale.GERMAN);
+        assertNotSame(named, named2);
+        assertSame(namedGerman, ((JcrResourceBundle) named2).getParent());
+        assertSame(provider.getResourceBundle("named", Locale.ENGLISH), 
namedGerman.getParent());
+        assertEquals("new named English", named2.getString("fallback"));
+        assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
+        assertSame(german, provider.getResourceBundle(Locale.GERMAN));
+    }
+
+    private Resource createDictionary(String name, String language, String... 
baseNames) {
+        Resource dictionary = context.create()
+                .resource(
+                        "/apps/i18n/" + name,
+                        "jcr:mixinTypes",
+                        new String[] {"mix:language"},
+                        "jcr:language",
+                        language,
+                        "sling:basename",
+                        baseNames);
+        dictionaryRoots.add(dictionary);
+        return dictionary;
+    }
+
+    /**
+     * Replacing a parent must also replace its dependent child and attach the
+     * child to the new parent. New bundle identities alone would miss a stale
+     * parent reference left by child-first eager refresh.
+     */
     @Test
     public void newBundleUsedAsParentAfterReload() throws Exception {
         ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
@@ -170,6 +284,7 @@ public class ConcurrentJcrResourceBundleLoadingTest {
         assertNotSame(english2, english);
         ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
         assertNotSame(german2, german);
+        assertSame(english2, ((JcrResourceBundle) german2).getParent());
         assertSame(english2, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
         assertSame(english2, provider.getResourceBundle(Locale.ENGLISH));
diff --git 
a/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java 
b/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java
new file mode 100644
index 0000000..51adde9
--- /dev/null
+++ b/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java
@@ -0,0 +1,43 @@
+/*
+ * 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.sling.i18n.it;
+
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.OptionUtils;
+
+import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration;
+
+/**
+ * Runs the inherited repository-backed tests with eager preloading enabled,
+ * complementing the default lazy-loading suite. In particular, targeted 
fallback
+ * updates must rebuild dependent bundles against their refreshed parents.
+ */
+public class EagerResourceBundleProviderIT extends ResourceBundleProviderIT {
+
+    @Override
+    @Configuration
+    public Option[] configuration() {
+        return OptionUtils.combine(
+                super.configuration(),
+                
newConfiguration("org.apache.sling.i18n.impl.JcrResourceBundleProvider")
+                        .put("preload.bundles", true)
+                        .asOption());
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java 
b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
index ed2419b..b6e7255 100644
--- a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
+++ b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
@@ -175,6 +175,27 @@ public class ResourceBundleProviderIT extends 
I18nTestSupport {
         assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME, "overwritten");
     }
 
+    /**
+     * Verifies that repository observation propagates an English fallback 
update
+     * to German, German-country, and French bundles after they have been 
cached.
+     * The nt:unstructured entry is supported by message loading but avoids the
+     * sling:MessageEntry detection that promotes edits to full rediscovery.
+     * This exercises targeted refresh, where stale parent references would
+     * otherwise leave dependent locales returning the old English value.
+     */
+    @Test
+    public void testFallbackChangesForUntypedMessages() throws 
RepositoryException {
+        Node message = enRoot.addNode("fallback", "nt:unstructured");
+        message.setProperty("sling:key", MSG_KEY2);
+        message.setProperty("sling:message", "old English fallback");
+        session.save();
+        assertMessages(MSG_KEY2, "old English fallback", "old English 
fallback", "old English fallback");
+
+        message.setProperty("sling:message", "new English fallback");
+        session.save();
+        assertMessages(MSG_KEY2, "new English fallback", "new English 
fallback", "new English fallback");
+    }
+
     @Test
     public void testChangesDetection() throws RepositoryException {
         // set a key which is only available in the en dictionary

Reply via email to