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