You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by da...@apache.org on 2026/03/28 06:40:55 UTC
(jackrabbit-oak) branch trunk updated: OAK-12145 : add compatibility tests for Caffeine migration (PR 2807) (#2811)
This is an automated email from the ASF dual-hosted git repository.
daim pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git
The following commit(s) were added to refs/heads/trunk by this push:
new aa090915c1 OAK-12145 : add compatibility tests for Caffeine migration (PR 2807) (#2811)
aa090915c1 is described below
commit aa090915c17e44970bac893d7a5e2a21052186c5
Author: Rishabh Kumar <ri...@gmail.com>
AuthorDate: Sat Mar 28 12:10:48 2026 +0530
OAK-12145 : add compatibility tests for Caffeine migration (PR 2807) (#2811)
* OAK-12145 : add compatibility tests for Caffeine migration (PR 2807)
Add implementation-independent test coverage for all classes affected
by the Guava-to-Caffeine cache migration in OAK-11946. Tests reference
only Oak-level types (CacheLIRS, CacheStats, DiffCache, etc.) so the
same suite can be cherry-picked to OAK-11946 and run unchanged; any
failure there is a migration compatibility gap.
Modules covered:
- oak-core-spi: AbstractCacheStats, CacheLIRS, EmpiricalWeigher
- oak-store-document: DocumentNodeStoreBuilder, NodeDocumentCache,
MemoryDiffCache, LocalDiffCache, TieredDiffCache,
CachingCommitValueResolver, DocumentNodeStore, PersistentCache
- oak-run-commons: DocumentNodeStoreHelper
- oak-search: ExtractedTextCache (stats tracking)
- oak-search-elastic: ElasticIndexStatistics (cache, refresh, failure)
- oak-segment-tar: SegmentCache (loader failure contract)
- oak-blob: BlobIdSet (cache miss / persistence semantics)
- oak-blob-cloud: S3Backend (expiry, cache enable/disable)
- oak-blob-cloud-azure: AzureBlobStoreBackend,
AzureBlobStoreBackendV8 (expiry, cache enable/disable)
* OAK-12145: Clarify cache compatibility test intent
* OAK-12145: Rename S3 compatibility test
* OAK-12145: Make cache compatibility tests behavior-based
* OAK-12145: Fix SegmentCache compatibility assertion
---
.../AzureBlobStoreBackendCompatibilityTest.java | 102 +++++++
.../v8/AzureBlobStoreBackendV8Test.java | 98 +++++++
.../blob/cloud/s3/S3BackendCompatibilityTest.java | 134 +++++++++
.../spi/blob/split/BlobIdSetCompatibilityTest.java | 83 ++++++
.../oak/cache/AbstractCacheStatsTest.java | 235 +++++++++++++++
.../oak/cache/CacheLIRSCompatibilityTest.java | 129 +++++++++
.../jackrabbit/oak/cache/EmpiricalWeigherTest.java | 94 ++++++
.../document/DocumentNodeStoreHelperTest.java | 80 +++++
.../ElasticIndexStatisticsCompatibilityTest.java | 321 +++++++++++++++++++++
.../index/search/ExtractedTextCacheTest.java | 11 +
.../jackrabbit/oak/segment/SegmentCacheTest.java | 15 +
.../plugins/document/CommitValueResolverTest.java | 21 ++
.../document/DocumentNodeStoreBuilderTest.java | 127 ++++++++
.../plugins/document/DocumentNodeStoreTest.java | 79 +++++
.../document/LocalDiffCacheCompatibilityTest.java | 79 +++++
.../oak/plugins/document/MemoryDiffCacheTest.java | 57 ++++
.../oak/plugins/document/TieredDiffCacheTest.java | 130 +++++++++
.../document/cache/NodeDocumentCacheTest.java | 70 +++++
.../PersistentCacheCompatibilityTest.java | 183 ++++++++++++
19 files changed, 2048 insertions(+)
diff --git a/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/AzureBlobStoreBackendCompatibilityTest.java b/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/AzureBlobStoreBackendCompatibilityTest.java
new file mode 100644
index 0000000000..98f94736bf
--- /dev/null
+++ b/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/AzureBlobStoreBackendCompatibilityTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.jackrabbit.oak.blob.cloud.azure.blobstorage;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+
+import org.apache.jackrabbit.core.data.DataIdentifier;
+import org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordDownloadOptions;
+import org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordUploadOptions;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+/**
+ * Compatibility tests for direct-download and upload cache configuration in
+ * {@link AzureBlobStoreBackend}. The assertions are intentionally behavior-
+ * based and do not depend on a specific cache library.
+ */
+public class AzureBlobStoreBackendCompatibilityTest {
+
+ @Test
+ public void setHttpDownloadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage for the direct-download expiry value used by presigned URIs.
+ AzureBlobStoreBackend backend = new AzureBlobStoreBackend();
+
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ assertEquals(3600, getIntField(backend, "httpDownloadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpUploadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage for the direct-upload expiry used during upload initiation.
+ AzureBlobStoreBackend backend = new AzureBlobStoreBackend();
+
+ backend.setHttpUploadURIExpirySeconds(1800);
+
+ assertEquals(1800, getIntField(backend, "httpUploadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpDownloadURICacheSizeCreatesAndDisablesCache() throws Exception {
+ // Verify the cache-size toggle actually creates and then removes the backing cache.
+ AzureBlobStoreBackend backend = new AzureBlobStoreBackend();
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ backend.setHttpDownloadURICacheSize(100);
+ assertNotNull(getField(backend, "httpDownloadURICache"));
+
+ backend.setHttpDownloadURICacheSize(0);
+ assertNull(getField(backend, "httpDownloadURICache"));
+ }
+
+ @Test
+ public void createHttpDownloadURIReturnsNullWhenDisabled() {
+ // With no download expiry configured, direct download access should stay disabled.
+ AzureBlobStoreBackend backend = new AzureBlobStoreBackend();
+
+ URI downloadURI = backend.createHttpDownloadURI(
+ new DataIdentifier("test"),
+ DataRecordDownloadOptions.DEFAULT);
+
+ assertNull(downloadURI);
+ }
+
+ @Test
+ public void initiateHttpUploadReturnsNullWhenDisabled() {
+ // Upload initiation follows the same disabled-by-default contract until configured.
+ AzureBlobStoreBackend backend = new AzureBlobStoreBackend();
+
+ assertNull(backend.initiateHttpUpload(1024, 1, DataRecordUploadOptions.DEFAULT));
+ }
+
+ private static int getIntField(AzureBlobStoreBackend backend, String fieldName) throws Exception {
+ Field field = AzureBlobStoreBackend.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (int) field.get(backend);
+ }
+
+ private static Object getField(AzureBlobStoreBackend backend, String fieldName) throws Exception {
+ Field field = AzureBlobStoreBackend.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(backend);
+ }
+}
diff --git a/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/v8/AzureBlobStoreBackendV8Test.java b/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/v8/AzureBlobStoreBackendV8Test.java
index 49cfff277d..10764b03e1 100644
--- a/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/v8/AzureBlobStoreBackendV8Test.java
+++ b/oak-blob-cloud-azure/src/test/java/org/apache/jackrabbit/oak/blob/cloud/azure/blobstorage/v8/AzureBlobStoreBackendV8Test.java
@@ -36,7 +36,10 @@ import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
import java.net.URISyntaxException;
+import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
@@ -163,6 +166,77 @@ public class AzureBlobStoreBackendV8Test {
assertReferenceSecret(azureBlobStoreBackend);
}
+ @Test
+ public void setHttpDownloadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage for the v8 direct-download expiry value used by presigned URIs.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ assertEquals(3600, getIntField(backend, "httpDownloadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpUploadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage for the v8 direct-upload expiry used during upload initiation.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+
+ backend.setHttpUploadURIExpirySeconds(1800);
+
+ assertEquals(1800, getIntField(backend, "httpUploadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpDownloadURICacheSizeCreatesAndDisablesCache() throws Exception {
+ // Verify the cache-size toggle actually creates and then removes the backing cache.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ backend.setHttpDownloadURICacheSize(100);
+ assertNotNull(getField(backend, "httpDownloadURICache"));
+
+ backend.setHttpDownloadURICacheSize(0);
+ assertNull(getField(backend, "httpDownloadURICache"));
+ }
+
+ @Test
+ public void createHttpDownloadURIReturnsNullWhenDisabled() throws DataStoreException {
+ // With no download expiry configured, direct download access should stay disabled.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+
+ assertNull(backend.createHttpDownloadURI(new org.apache.jackrabbit.core.data.DataIdentifier("test"),
+ org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordDownloadOptions.DEFAULT));
+ }
+
+ @Test
+ public void initiateHttpUploadReturnsNullWhenDisabled() throws DataStoreException {
+ // Upload initiation follows the same disabled-by-default contract until configured.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+
+ assertNull(backend.initiateHttpUpload(1024, 1,
+ org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordUploadOptions.DEFAULT));
+ }
+
+ @Test
+ public void createHttpDownloadURIReturnsPreExistingCachedURI() throws Exception {
+ // Seed a cache entry directly, then verify the externally observable
+ // behavior that the same URI is returned for the same download request.
+ AzureBlobStoreBackendV8 backend = new AzureBlobStoreBackendV8();
+ org.apache.jackrabbit.core.data.DataIdentifier identifier =
+ new org.apache.jackrabbit.core.data.DataIdentifier("cached");
+ URI cachedUri = URI.create("https://cached.example/download");
+
+ backend.setHttpDownloadURIExpirySeconds(300);
+ setField(backend, "downloadDomainOverride", "cached.example");
+ setField(backend, "presignedDownloadURIVerifyExists", false);
+ backend.setHttpDownloadURICacheSize(10);
+ putIntoCache(getField(backend, "httpDownloadURICache"),
+ identifier + "cached.example", cachedUri);
+
+ assertEquals(cachedUri, backend.createHttpDownloadURI(identifier,
+ org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordDownloadOptions.DEFAULT));
+ }
+
/* make sure that blob1.txt and blob2.txt are uploaded to AZURE_ACCOUNT_NAME/blobstore container before
* executing this test
* */
@@ -312,6 +386,30 @@ public class AzureBlobStoreBackendV8Test {
return UtilsV8.getConnectionString(AzuriteDockerRule.ACCOUNT_NAME, AzuriteDockerRule.ACCOUNT_KEY, azurite.getBlobEndpoint());
}
+ private static int getIntField(AzureBlobStoreBackendV8 backend, String fieldName) throws Exception {
+ Field field = AzureBlobStoreBackendV8.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (int) field.get(backend);
+ }
+
+ private static Object getField(AzureBlobStoreBackendV8 backend, String fieldName) throws Exception {
+ Field field = AzureBlobStoreBackendV8.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(backend);
+ }
+
+ private static void setField(AzureBlobStoreBackendV8 backend, String fieldName, Object value) throws Exception {
+ Field field = AzureBlobStoreBackendV8.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(backend, value);
+ }
+
+ private static void putIntoCache(Object cache, Object key, Object value) throws Exception {
+ Method put = cache.getClass().getMethod("put", Object.class, Object.class);
+ put.setAccessible(true);
+ put.invoke(cache, key, value);
+ }
+
private static void assertReferenceSecret(AzureBlobStoreBackendV8 azureBlobStoreBackend)
throws DataStoreException {
// assert secret already created on init
diff --git a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3BackendCompatibilityTest.java b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3BackendCompatibilityTest.java
new file mode 100644
index 0000000000..b8bbfcb0f4
--- /dev/null
+++ b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3BackendCompatibilityTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.jackrabbit.oak.blob.cloud.s3;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.URI;
+
+import org.apache.jackrabbit.core.data.DataIdentifier;
+import org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordDownloadOptions;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+/**
+ * Compatibility tests for direct-download and upload cache configuration in
+ * {@link S3Backend}. The assertions are intentionally behavior-based and do
+ * not reference third-party cache types.
+ */
+public class S3BackendCompatibilityTest {
+
+ @Test
+ public void setHttpDownloadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage: direct-download expiry must be stored verbatim because
+ // later cache/window calculations depend on this value.
+ S3Backend backend = new S3Backend();
+
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ assertEquals(3600, getIntField(backend, "httpDownloadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpUploadURIExpirySecondsUpdatesField() throws Exception {
+ // Setter coverage for the upload-side expiry used by presigned upload URIs.
+ S3Backend backend = new S3Backend();
+
+ backend.setHttpUploadURIExpirySeconds(1800);
+
+ assertEquals(1800, getIntField(backend, "httpUploadURIExpirySeconds"));
+ }
+
+ @Test
+ public void setHttpDownloadURICacheSizeCreatesAndDisablesCache() throws Exception {
+ // Toggle the cache on and back off again to verify the configuration method
+ // controls the backing cache lifecycle directly.
+ S3Backend backend = new S3Backend();
+ backend.setHttpDownloadURIExpirySeconds(3600);
+
+ backend.setHttpDownloadURICacheSize(100);
+ assertNotNull(getField(backend, "httpDownloadURICache"));
+
+ backend.setHttpDownloadURICacheSize(0);
+ assertNull(getField(backend, "httpDownloadURICache"));
+ }
+
+ @Test
+ public void createHttpDownloadURIReturnsNullWhenDisabled() {
+ // With download expiry left at the default disabled state, direct download
+ // access must stay off and return null immediately.
+ S3Backend backend = new S3Backend();
+
+ URI downloadURI = backend.createHttpDownloadURI(
+ new DataIdentifier("test"),
+ DataRecordDownloadOptions.DEFAULT);
+
+ assertNull(downloadURI);
+ }
+
+ @Test
+ public void initiateHttpUploadReturnsNullWhenDisabled() {
+ // Upload URIs use the same disabled-by-default contract when no expiry is configured.
+ S3Backend backend = new S3Backend();
+
+ assertNull(backend.initiateHttpUpload(1024, 1));
+ }
+
+ @Test
+ public void createHttpDownloadURIReturnsPreExistingCachedURI() throws Exception {
+ // Seed a cache entry directly, then verify the externally observable
+ // behavior that the same URI is returned for the same download request.
+ S3Backend backend = new S3Backend();
+ DataIdentifier identifier = new DataIdentifier("cached");
+ URI cachedUri = URI.create("https://cached.example/download");
+
+ backend.setHttpDownloadURIExpirySeconds(300);
+ backend.setHttpDownloadURICacheSize(10);
+ setField(backend, "presignedDownloadURIVerifyExists", false);
+ putIntoCache(getField(backend, "httpDownloadURICache"), identifier, cachedUri);
+
+ assertEquals(cachedUri, backend.createHttpDownloadURI(identifier, DataRecordDownloadOptions.DEFAULT));
+ }
+
+ private static int getIntField(S3Backend backend, String fieldName) throws Exception {
+ Field field = S3Backend.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (int) field.get(backend);
+ }
+
+ private static Object getField(S3Backend backend, String fieldName) throws Exception {
+ Field field = S3Backend.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(backend);
+ }
+
+ private static void setField(S3Backend backend, String fieldName, Object value) throws Exception {
+ Field field = S3Backend.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(backend, value);
+ }
+
+ private static void putIntoCache(Object cache, Object key, Object value) throws Exception {
+ Method put = cache.getClass().getMethod("put", Object.class, Object.class);
+ put.setAccessible(true);
+ put.invoke(cache, key, value);
+ }
+}
diff --git a/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/split/BlobIdSetCompatibilityTest.java b/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/split/BlobIdSetCompatibilityTest.java
new file mode 100644
index 0000000000..d3ba6f9b79
--- /dev/null
+++ b/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/split/BlobIdSetCompatibilityTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.jackrabbit.oak.spi.blob.split;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Compatibility tests for {@link BlobIdSet}.
+ * These assertions verify observable lookup semantics without depending on the
+ * underlying cache implementation.
+ */
+public class BlobIdSetCompatibilityTest {
+
+ private static final String TEST_FILENAME = "compat-blob-ids.txt";
+
+ private File tempDir;
+ private File storeFile;
+ private BlobIdSet blobIdSet;
+
+ @Before
+ public void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("blob-id-set-compat").toFile();
+ storeFile = new File(tempDir, TEST_FILENAME);
+ blobIdSet = new BlobIdSet(tempDir.getAbsolutePath(), TEST_FILENAME);
+ }
+
+ @After
+ public void tearDown() {
+ if (storeFile.exists()) {
+ storeFile.delete();
+ }
+ tempDir.delete();
+ }
+
+ @Test
+ public void containsReturnsTrueForEntryAddedAfterRestart() throws IOException {
+ // Seed the on-disk store first, then rebuild BlobIdSet to show startup
+ // rehydrates lookup state from the persisted file.
+ try (FileWriter writer = new FileWriter(storeFile)) {
+ writer.write("blob-from-store\n");
+ }
+
+ BlobIdSet restarted = new BlobIdSet(tempDir.getAbsolutePath(), TEST_FILENAME);
+
+ assertTrue(restarted.contains("blob-from-store"));
+ }
+
+ @Test
+ public void addMakesEntryVisibleBeforeAndAfterRestart() throws IOException {
+ // Add through the public API and verify both the current instance and a
+ // restarted one observe the same persisted membership result.
+ blobIdSet.add("added-through-api");
+
+ assertTrue(blobIdSet.contains("added-through-api"));
+
+ BlobIdSet restarted = new BlobIdSet(tempDir.getAbsolutePath(), TEST_FILENAME);
+ assertTrue(restarted.contains("added-through-api"));
+ }
+}
diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/AbstractCacheStatsTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/AbstractCacheStatsTest.java
new file mode 100644
index 0000000000..4d3fb779d4
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/AbstractCacheStatsTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.jackrabbit.oak.cache;
+
+import java.util.concurrent.ExecutionException;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link AbstractCacheStats} using {@link CacheLIRS} as the backing cache.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class AbstractCacheStatsTest {
+
+ private static final String CACHE_NAME = "testCache";
+ private static final long MAX_WEIGHT = 1000;
+
+ private CacheLIRS<String, String> cache;
+ private CacheStats stats;
+
+ @Before
+ public void setUp() {
+ cache = new CacheLIRS<>(null, MAX_WEIGHT, 1, 1, 0, null, null, null);
+ stats = new CacheStats(cache, CACHE_NAME, null, MAX_WEIGHT);
+ }
+
+ @Test
+ public void getNameReturnsConstructorValue() {
+ Assert.assertEquals(CACHE_NAME, stats.getName());
+ }
+
+ @Test
+ public void hitCountIncreasesOnCacheHit() throws ExecutionException {
+ cache.put("k1", "v1");
+ cache.get("k1", () -> "v1"); // cache hit — callable not invoked
+ Assert.assertEquals(1, stats.getHitCount());
+ }
+
+ @Test
+ public void missCountIncreasesOnCacheMiss() {
+ cache.getIfPresent("absent");
+ Assert.assertEquals(1, stats.getMissCount());
+ }
+
+ @Test
+ public void requestCountIsSumOfHitsAndMisses() throws ExecutionException {
+ cache.put("k1", "v1");
+ cache.get("k1", () -> "v1"); // hit
+ cache.getIfPresent("absent"); // miss
+ Assert.assertEquals(2, stats.getRequestCount());
+ Assert.assertEquals(1, stats.getHitCount());
+ Assert.assertEquals(1, stats.getMissCount());
+ }
+
+ @Test
+ public void loadSuccessCountIncreasesOnCallableLoad() throws ExecutionException {
+ cache.get("k1", () -> "v1"); // miss + load
+ Assert.assertEquals(1, stats.getLoadSuccessCount());
+ Assert.assertEquals(1, stats.getLoadCount());
+ }
+
+ @Test
+ public void loadExceptionCountIncreasesOnFailedLoad() {
+ try {
+ cache.get("k1", () -> {
+ throw new RuntimeException("load failed");
+ });
+ } catch (ExecutionException ignored) {
+ }
+ Assert.assertEquals(1, stats.getLoadExceptionCount());
+ Assert.assertEquals(1, stats.getLoadCount());
+ Assert.assertEquals(0, stats.getLoadSuccessCount());
+ }
+
+ @Test
+ public void evictionCountIncreasesAfterCapacityEviction() {
+ // LIRS needs warm-up: create cache of size 5 and add 30 entries to ensure evictions
+ CacheLIRS<String, String> smallCache = CacheLIRS.<String, String>newBuilder()
+ .maximumSize(5)
+ .build();
+ CacheStats smallStats = new CacheStats(smallCache, "small", null, 5);
+ for (int i = 0; i < 30; i++) {
+ smallCache.put("k" + i, "v" + i);
+ }
+ Assert.assertTrue("evictionCount should be positive after capacity eviction",
+ smallStats.getEvictionCount() > 0);
+ }
+
+ @Test
+ public void maxTotalWeightReturnsConfiguredValue() {
+ Assert.assertEquals(MAX_WEIGHT, stats.getMaxTotalWeight());
+ }
+
+ @Test
+ public void elementCountReflectsCachedEntries() throws ExecutionException {
+ cache.get("k1", () -> "v1");
+ cache.get("k2", () -> "v2");
+ Assert.assertEquals(2, stats.getElementCount());
+ }
+
+ @Test
+ public void estimateCurrentWeightReturnsNegativeOneWhenNoWeigher() {
+ Assert.assertEquals(-1, stats.estimateCurrentWeight());
+ }
+
+ @Test
+ public void resetStatsClearsCountersButNotCacheContents() throws ExecutionException {
+ cache.get("k1", () -> "v1"); // miss + load
+ cache.get("k1", () -> "v1"); // hit
+ cache.getIfPresent("absent"); // miss
+
+ stats.resetStats();
+
+ Assert.assertEquals(0, stats.getRequestCount());
+ Assert.assertEquals(0, stats.getHitCount());
+ Assert.assertEquals(0, stats.getMissCount());
+ Assert.assertEquals(0, stats.getLoadCount());
+ Assert.assertEquals(0, stats.getLoadSuccessCount());
+ Assert.assertEquals(0, stats.getLoadExceptionCount());
+ Assert.assertEquals(0, stats.getEvictionCount());
+ Assert.assertEquals(0.0, stats.getLoadExceptionRate(), Double.MIN_VALUE);
+ Assert.assertEquals(0, stats.getTotalLoadTime());
+ // cache contents unchanged after reset
+ Assert.assertEquals(1, stats.getElementCount());
+ }
+
+ @Test
+ public void hitRateIsOneWhenAllAccessesAreHits() throws ExecutionException {
+ cache.put("k1", "v1");
+ cache.get("k1", () -> "v1"); // hit
+ Assert.assertEquals(1.0, stats.getHitRate(), Double.MIN_VALUE);
+ }
+
+ @Test
+ public void hitRateIsOneWhenNoRequestsYet() {
+ // by convention, hit rate is 1.0 when there are no requests
+ Assert.assertEquals(1.0, stats.getHitRate(), Double.MIN_VALUE);
+ }
+
+ @Test
+ public void cacheInfoAsStringContainsRequiredFields() throws ExecutionException {
+ cache.get("k1", () -> "v1");
+ String info = stats.cacheInfoAsString();
+ Assert.assertTrue("cacheInfoAsString should contain hitCount", info.contains("hitCount="));
+ Assert.assertTrue("cacheInfoAsString should contain missCount", info.contains("missCount="));
+ Assert.assertTrue("cacheInfoAsString should contain loadCount", info.contains("loadCount="));
+ Assert.assertTrue("cacheInfoAsString should contain elementCount", info.contains("elementCount=1"));
+ Assert.assertTrue("cacheInfoAsString should contain maxWeight", info.contains("maxWeight="));
+ }
+
+ @Test
+ public void timeInWordsIncludesMinAndSec() {
+ String result = AbstractCacheStats.timeInWords(0);
+ Assert.assertNotNull(result);
+ Assert.assertTrue("timeInWords should contain 'min'", result.contains("min"));
+ Assert.assertTrue("timeInWords should contain 'sec'", result.contains("sec"));
+ }
+
+ @Test
+ public void timeInWordsFormatsOneMinute() {
+ long oneMinuteNanos = 60L * 1_000_000_000L;
+ String result = AbstractCacheStats.timeInWords(oneMinuteNanos);
+ Assert.assertTrue("1-minute duration should contain '1 min'", result.contains("1 min"));
+ }
+
+ @Test
+ public void loadExceptionRateAfterMixedLoads() throws ExecutionException {
+ cache.get("success", () -> "v1"); // success
+ try {
+ cache.get("failure", () -> {
+ throw new RuntimeException("boom");
+ });
+ } catch (ExecutionException ignored) {
+ }
+ // 1 success + 1 exception = 2 loads; rate = 0.5
+ Assert.assertEquals(0.5, stats.getLoadExceptionRate(), 0.001);
+ }
+
+ @Test
+ public void loadExceptionRateIsZeroWhenNoLoads() {
+ Assert.assertEquals(0.0, stats.getLoadExceptionRate(), Double.MIN_VALUE);
+ }
+
+ @Test
+ public void totalLoadTimeIsPositiveAfterMeasuredLoad() throws ExecutionException {
+ cache.get("k1", () -> {
+ Thread.sleep(1);
+ return "v1";
+ });
+ Assert.assertTrue("totalLoadTime should be > 0 after a measured load", stats.getTotalLoadTime() > 0);
+ }
+
+ @Test
+ public void averageLoadPenaltyIsPositiveAfterMeasuredLoad() throws ExecutionException {
+ cache.get("k1", () -> {
+ Thread.sleep(1);
+ return "v1";
+ });
+ Assert.assertTrue("averageLoadPenalty should be > 0 after a measured load",
+ stats.getAverageLoadPenalty() > 0.0);
+ }
+
+ @Test
+ public void missRateIsOneWhenAllAccessesAreMisses() {
+ cache.getIfPresent("a");
+ cache.getIfPresent("b");
+ Assert.assertEquals(1.0, stats.getMissRate(), Double.MIN_VALUE);
+ }
+
+ @Test
+ public void statsAreAccumulatedAcrossMultipleLoads() throws ExecutionException {
+ cache.get("k1", () -> "v1");
+ cache.get("k2", () -> "v2");
+ cache.get("k3", () -> "v3");
+ Assert.assertEquals(3, stats.getLoadSuccessCount());
+ Assert.assertEquals(3, stats.getLoadCount());
+ }
+}
diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/CacheLIRSCompatibilityTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/CacheLIRSCompatibilityTest.java
new file mode 100644
index 0000000000..0351a80ee3
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/CacheLIRSCompatibilityTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.jackrabbit.oak.cache;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Compatibility tests for the Oak-visible {@link CacheLIRS} API surface.
+ * These assertions intentionally avoid third-party cache APIs so the same
+ * tests can run before and after the cache implementation migration.
+ */
+public class CacheLIRSCompatibilityTest {
+
+ @Test
+ public void getWithCallableCachesLoadedValue() throws ExecutionException {
+ // Load through the Oak-visible callable API, then repeat the same lookup
+ // with a different loader to prove the cached value wins on the second call.
+ CacheLIRS<String, String> cache = CacheLIRS.<String, String>newBuilder()
+ .maximumSize(10)
+ .build();
+ AtomicInteger loadCount = new AtomicInteger();
+
+ assertEquals("loaded", cache.get("k", () -> {
+ loadCount.incrementAndGet();
+ return "loaded";
+ }));
+ assertEquals("loaded", cache.get("k", () -> {
+ loadCount.incrementAndGet();
+ return "other";
+ }));
+
+ assertEquals(1, loadCount.get());
+ assertEquals("loaded", cache.getIfPresent("k"));
+ }
+
+ @Test
+ public void getWithCallableWrapsCheckedLoaderFailureInExecutionException() {
+ // Use a checked exception from the loader and verify the legacy
+ // ExecutionException shape is preserved for callers.
+ CacheLIRS<String, String> cache = CacheLIRS.<String, String>newBuilder()
+ .maximumSize(10)
+ .build();
+ Exception failure = new Exception("checked failure");
+
+ try {
+ cache.get("k", () -> {
+ throw failure;
+ });
+ fail("expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertEquals(failure, e.getCause());
+ assertEquals("checked failure", e.getCause().getMessage());
+ assertNull(cache.getIfPresent("k"));
+ }
+ }
+
+ @Test
+ public void invalidateAllClearsPreviouslyCachedEntries() throws ExecutionException {
+ // Populate two keys first, then clear the cache and verify both the
+ // size counters and direct lookups observe an empty cache.
+ CacheLIRS<String, String> cache = CacheLIRS.<String, String>newBuilder()
+ .maximumSize(10)
+ .build();
+
+ cache.get("k1", () -> "v1");
+ cache.get("k2", () -> "v2");
+ assertEquals(2, cache.size());
+
+ cache.invalidateAll();
+
+ assertEquals(0, cache.size());
+ assertNull(cache.getIfPresent("k1"));
+ assertNull(cache.getIfPresent("k2"));
+ assertTrue(cache.asMap().isEmpty());
+ }
+
+ @Test
+ public void evictionCallbackIsInvokedWhenEntryIsEvictedBySize() {
+ // Push the cache past capacity and capture the first callback so the
+ // test checks real size-based eviction instead of explicit invalidation.
+ AtomicInteger evictions = new AtomicInteger();
+ AtomicReference<String> firstEvictedKey = new AtomicReference<>();
+ AtomicReference<String> firstEvictedValue = new AtomicReference<>();
+ CacheLIRS<String, String> cache = CacheLIRS.<String, String>newBuilder()
+ .maximumSize(10)
+ .evictionCallback((key, value, cause) -> {
+ if (evictions.getAndIncrement() == 0) {
+ firstEvictedKey.set(key);
+ firstEvictedValue.set(value);
+ }
+ })
+ .build();
+
+ // LIRS requires cold-queue warm-up before evicting; 100× the cache capacity
+ // ensures at least one eviction on any conforming implementation.
+ for (int i = 0; i < 1000 && evictions.get() == 0; i++) {
+ cache.put("k" + i, "v" + i);
+ }
+
+ assertTrue("expected at least one eviction callback", evictions.get() > 0);
+ assertTrue(firstEvictedKey.get().startsWith("k"));
+ assertTrue(firstEvictedValue.get().startsWith("v"));
+ assertNull(cache.getIfPresent(firstEvictedKey.get()));
+ }
+}
diff --git a/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/EmpiricalWeigherTest.java b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/EmpiricalWeigherTest.java
new file mode 100644
index 0000000000..39209ad5d7
--- /dev/null
+++ b/oak-core-spi/src/test/java/org/apache/jackrabbit/oak/cache/EmpiricalWeigherTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.jackrabbit.oak.cache;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Tests for {@link EmpiricalWeigher}.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class EmpiricalWeigherTest {
+
+ private static final int ENTRY_OVERHEAD = 168;
+
+ private final EmpiricalWeigher weigher = new EmpiricalWeigher();
+
+ @Test
+ public void weighIncludesBaseOverheadForZeroMemoryValues() {
+ CacheValue key = () -> 0;
+ CacheValue value = () -> 0;
+ Assert.assertEquals(ENTRY_OVERHEAD, weigher.weigh(key, value));
+ }
+
+ @Test
+ public void weighAddsKeyAndValueMemoryToOverhead() {
+ CacheValue key = () -> 100;
+ CacheValue value = () -> 200;
+ Assert.assertEquals(ENTRY_OVERHEAD + 100 + 200, weigher.weigh(key, value));
+ }
+
+ @Test
+ public void weighWithOnlyKeyMemory() {
+ CacheValue key = () -> 50;
+ CacheValue value = () -> 0;
+ Assert.assertEquals(ENTRY_OVERHEAD + 50, weigher.weigh(key, value));
+ }
+
+ @Test
+ public void weighWithOnlyValueMemory() {
+ CacheValue key = () -> 0;
+ CacheValue value = () -> 300;
+ Assert.assertEquals(ENTRY_OVERHEAD + 300, weigher.weigh(key, value));
+ }
+
+ @Test
+ public void weighCapsAtIntegerMaxValue() {
+ // key + value + overhead overflows int
+ CacheValue key = () -> Integer.MAX_VALUE;
+ CacheValue value = () -> Integer.MAX_VALUE;
+ Assert.assertEquals(Integer.MAX_VALUE, weigher.weigh(key, value));
+ }
+
+ @Test
+ public void weighIsAlwaysPositive() {
+ CacheValue key = () -> 1;
+ CacheValue value = () -> 1;
+ Assert.assertTrue(weigher.weigh(key, value) > 0);
+ }
+
+ @Test
+ public void weighReturnsConsistentResultsForSameInput() {
+ CacheValue key = () -> 42;
+ CacheValue value = () -> 99;
+ int first = weigher.weigh(key, value);
+ int second = weigher.weigh(key, value);
+ Assert.assertEquals(first, second);
+ }
+
+ @Test
+ public void weighJustBelowOverflow() {
+ // total = 168 + (Integer.MAX_VALUE - 168 - 1) + 1 = Integer.MAX_VALUE
+ int keyMem = Integer.MAX_VALUE - ENTRY_OVERHEAD - 1;
+ int valueMem = 1;
+ CacheValue key = () -> keyMem;
+ CacheValue value = () -> valueMem;
+ Assert.assertEquals(Integer.MAX_VALUE, weigher.weigh(key, value));
+ }
+}
diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreHelperTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreHelperTest.java
new file mode 100644
index 0000000000..f188b7b0a7
--- /dev/null
+++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreHelperTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.jackrabbit.oak.plugins.document;
+
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.EmptyHook;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link DocumentNodeStoreHelper}.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class DocumentNodeStoreHelperTest {
+
+ private DocumentNodeStore store;
+
+ @Before
+ public void setUp() {
+ store = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder()
+ .setAsyncDelay(0)
+ .build();
+ }
+
+ @After
+ public void tearDown() {
+ if (store != null) {
+ store.dispose();
+ }
+ }
+
+ @Test
+ public void getNodesCacheReturnsNonNull() {
+ // This is a smoke test for the helper entry point: callers should always
+ // get back a usable cache handle for a live DocumentNodeStore.
+ Assert.assertNotNull(DocumentNodeStoreHelper.getNodesCache(store));
+ }
+
+ @Test
+ public void getNodesCacheExposesNonNullMapView() {
+ // This only asserts that the helper exposes a map view; contents and
+ // mutability semantics are intentionally out of scope here.
+ Assert.assertNotNull(DocumentNodeStoreHelper.getNodesCache(store).asMap());
+ }
+
+ @Test
+ public void getNodesCacheMapViewReflectsCachedNodeReads() throws Exception {
+ // Create content through the normal node-store API so the subsequent read
+ // exercises the production cache population path instead of a test-only insert.
+ NodeBuilder builder = store.getRoot().builder();
+ builder.child("a").child("b");
+ store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+ // Clear any startup entries first, then verify a real tree read repopulates
+ // the helper-visible map view.
+ DocumentNodeStoreHelper.getNodesCache(store).asMap().clear();
+ Assert.assertTrue(DocumentNodeStoreHelper.getNodesCache(store).asMap().isEmpty());
+ store.getRoot().getChildNode("a").getChildNode("b");
+
+ Assert.assertFalse(DocumentNodeStoreHelper.getNodesCache(store).asMap().isEmpty());
+ }
+}
diff --git a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexStatisticsCompatibilityTest.java b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexStatisticsCompatibilityTest.java
new file mode 100644
index 0000000000..a421c9aaa7
--- /dev/null
+++ b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexStatisticsCompatibilityTest.java
@@ -0,0 +1,321 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch.core.CountRequest;
+import co.elastic.clients.elasticsearch.core.CountResponse;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.function.IntSupplier;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Compatibility tests for {@link ElasticIndexStatistics}.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class ElasticIndexStatisticsCompatibilityTest {
+
+ @Mock
+ private ElasticConnection elasticConnectionMock;
+
+ @Mock
+ private ElasticIndexDefinition indexDefinitionMock;
+
+ @Mock
+ private ElasticsearchClient elasticClientMock;
+
+ private AutoCloseable closeable;
+
+ @Before
+ public void setUp() {
+ this.closeable = MockitoAnnotations.openMocks(this);
+ Mockito.when(indexDefinitionMock.getIndexAlias()).thenReturn("test-index");
+ Mockito.when(elasticConnectionMock.getClient()).thenReturn(elasticClientMock);
+ }
+
+ @After
+ public void releaseMocks() throws Exception {
+ closeable.close();
+ }
+
+ @Test
+ public void numDocsReturnsMockedCountFromElasticsearch() throws Exception {
+ // Baseline behavior: a cache miss should load the document count from Elasticsearch.
+ CountResponse countResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(countResponse.count()).thenReturn(42L);
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenReturn(countResponse);
+
+ ElasticIndexStatistics indexStatistics =
+ new ElasticIndexStatistics(elasticConnectionMock, indexDefinitionMock);
+ Assert.assertEquals(42, indexStatistics.numDocs());
+ }
+
+ @Test
+ public void numDocsCachesResultOnSubsequentCalls() throws Exception {
+ // Call numDocs() twice with the same descriptor and verify only the first
+ // call reaches Elasticsearch.
+ CountResponse countResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(countResponse.count()).thenReturn(99L);
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenReturn(countResponse);
+
+ ElasticIndexStatistics indexStatistics =
+ new ElasticIndexStatistics(elasticConnectionMock, indexDefinitionMock);
+ // first call loads from ES
+ Assert.assertEquals(99, indexStatistics.numDocs());
+ // second call should be served from cache (same value)
+ Assert.assertEquals(99, indexStatistics.numDocs());
+ // ES should only have been called once
+ Mockito.verify(elasticClientMock, Mockito.times(1)).count(ArgumentMatchers.any(CountRequest.class));
+ }
+
+ @Test
+ public void numDocsPropagatesIOExceptionAsRuntimeFailure() throws Exception {
+ // Use a checked IOException from the client and assert callers still see a
+ // runtime failure that preserves the original cause chain.
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenThrow(new IOException("ES down"));
+
+ ElasticIndexStatistics indexStatistics =
+ new ElasticIndexStatistics(elasticConnectionMock, indexDefinitionMock);
+ try {
+ indexStatistics.numDocs();
+ Assert.fail("expected RuntimeException when Elasticsearch is unavailable");
+ } catch (RuntimeException e) {
+ // The exact wrapper type is intentionally not asserted so this test
+ // can remain valid across cache implementations.
+ Assert.assertNotNull(findCause(e, IOException.class));
+ Assert.assertEquals("ES down", findCause(e, IOException.class).getMessage());
+ }
+ }
+
+ @Test
+ public void getDocCountForFieldReturnsMockedCount() throws Exception {
+ CountResponse countResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(countResponse.count()).thenReturn(10L);
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenReturn(countResponse);
+
+ ElasticIndexStatistics indexStatistics =
+ new ElasticIndexStatistics(elasticConnectionMock, indexDefinitionMock);
+ Assert.assertEquals(10, indexStatistics.getDocCountFor("someField"));
+ }
+
+ @Test
+ public void numDocsAndGetDocCountForUseIndependentCacheKeys() throws Exception {
+ // numDocs() and getDocCountFor(field) should not alias each other in the cache,
+ // so both lookups must hit Elasticsearch once.
+ CountResponse countResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(countResponse.count()).thenReturn(5L);
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenReturn(countResponse);
+
+ ElasticIndexStatistics indexStatistics =
+ new ElasticIndexStatistics(elasticConnectionMock, indexDefinitionMock);
+ indexStatistics.numDocs();
+ indexStatistics.getDocCountFor("someField");
+ // numDocs and getDocCountFor use different cache keys (different StatsRequestDescriptors)
+ Mockito.verify(elasticClientMock, Mockito.times(2)).count(ArgumentMatchers.any(CountRequest.class));
+ }
+
+ @Test
+ public void numDocsRefreshesValueAfterRefreshWindow() throws Exception {
+ // Advance a controllable clock past the refresh boundary, then release
+ // the blocked refresh and verify callers eventually observe the new value.
+ MutableClock clock = new MutableClock();
+ CountResponse initialResponse = Mockito.mock(CountResponse.class);
+ CountResponse refreshedResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(initialResponse.count()).thenReturn(100L);
+ Mockito.when(refreshedResponse.count()).thenReturn(1000L);
+ CountDownLatch refreshStarted = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ CountDownLatch refreshCompleted = new CountDownLatch(1);
+ AtomicInteger invocations = new AtomicInteger();
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenAnswer(invocation -> {
+ if (invocations.getAndIncrement() == 0) {
+ return initialResponse;
+ }
+ refreshStarted.countDown();
+ if (!releaseRefresh.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError("timed out waiting to release refresh");
+ }
+ refreshCompleted.countDown();
+ return refreshedResponse;
+ });
+
+ ElasticIndexStatistics indexStatistics =
+ newIndexStatistics(clock);
+
+ Assert.assertEquals(100, indexStatistics.numDocs());
+ Mockito.verify(elasticClientMock, Mockito.times(1)).count(ArgumentMatchers.any(CountRequest.class));
+
+ clock.advanceSeconds(2);
+ Assert.assertEquals(100, indexStatistics.numDocs());
+
+ Assert.assertTrue("expected refresh to start", refreshStarted.await(5, TimeUnit.SECONDS));
+ releaseRefresh.countDown();
+ Assert.assertTrue("expected refresh completion", refreshCompleted.await(5, TimeUnit.SECONDS));
+ assertEventuallyEquals(1000, indexStatistics::numDocs);
+ Mockito.verify(elasticClientMock, Mockito.atLeast(2)).count(ArgumentMatchers.any(CountRequest.class));
+ }
+
+ @Test
+ public void numDocsReturnsStaleValueWhileRefreshIsInFlight() throws Exception {
+ // Advance a controllable clock into the refresh window, then block the
+ // reload so the read path can prove it returns the stale cached value.
+ MutableClock clock = new MutableClock();
+ CountResponse initialResponse = Mockito.mock(CountResponse.class);
+ CountResponse refreshedResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(initialResponse.count()).thenReturn(100L);
+ Mockito.when(refreshedResponse.count()).thenReturn(1000L);
+
+ CountDownLatch refreshStarted = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ CountDownLatch refreshCompleted = new CountDownLatch(1);
+ AtomicInteger invocations = new AtomicInteger();
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenAnswer(invocation -> {
+ if (invocations.getAndIncrement() == 0) {
+ return initialResponse;
+ }
+ refreshStarted.countDown();
+ if (!releaseRefresh.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError("timed out waiting to release refresh");
+ }
+ refreshCompleted.countDown();
+ return refreshedResponse;
+ });
+
+ ElasticIndexStatistics indexStatistics =
+ newIndexStatistics(clock);
+
+ Assert.assertEquals(100, indexStatistics.numDocs());
+
+ clock.advanceSeconds(2);
+ Assert.assertEquals(100, indexStatistics.numDocs());
+ Assert.assertTrue("expected refresh to start", refreshStarted.await(5, TimeUnit.SECONDS));
+
+ releaseRefresh.countDown();
+ Assert.assertTrue("expected refresh completion", refreshCompleted.await(5, TimeUnit.SECONDS));
+ assertEventuallyEquals(1000, indexStatistics::numDocs);
+ }
+
+ @Test
+ public void numDocsKeepsCachedValueWhenRefreshFails() throws Exception {
+ // Advance a controllable clock into the refresh window, then make the
+ // asynchronous refresh fail and verify the cached value is preserved.
+ MutableClock clock = new MutableClock();
+ CountResponse initialResponse = Mockito.mock(CountResponse.class);
+ Mockito.when(initialResponse.count()).thenReturn(100L);
+
+ CountDownLatch refreshAttempted = new CountDownLatch(1);
+ AtomicInteger invocations = new AtomicInteger();
+ Mockito.when(elasticClientMock.count(ArgumentMatchers.any(CountRequest.class)))
+ .thenAnswer(invocation -> {
+ if (invocations.getAndIncrement() == 0) {
+ return initialResponse;
+ }
+ refreshAttempted.countDown();
+ throw new IOException("refresh failed");
+ });
+
+ ElasticIndexStatistics indexStatistics =
+ newIndexStatistics(clock);
+
+ Assert.assertEquals(100, indexStatistics.numDocs());
+
+ clock.advanceSeconds(2);
+ Assert.assertEquals(100, indexStatistics.numDocs());
+ Assert.assertTrue("expected refresh attempt", refreshAttempted.await(5, TimeUnit.SECONDS));
+ Assert.assertEquals(100, indexStatistics.numDocs());
+ }
+
+ private ElasticIndexStatistics newIndexStatistics(Clock clock) {
+ return new ElasticIndexStatistics(
+ elasticConnectionMock,
+ indexDefinitionMock,
+ ElasticIndexStatistics.setupCountCache(100, 30, 1, clock),
+ null);
+ }
+
+ private static void assertEventuallyEquals(int expected, IntSupplier supplier) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ int actual = supplier.getAsInt();
+ while (System.nanoTime() < deadline && actual != expected) {
+ TimeUnit.MILLISECONDS.sleep(25);
+ actual = supplier.getAsInt();
+ }
+ Assert.assertEquals(expected, actual);
+ }
+
+ private static Throwable findCause(Throwable throwable, Class<? extends Throwable> type) {
+ Throwable current = throwable;
+ while (current != null) {
+ if (type.isInstance(current)) {
+ return current;
+ }
+ current = current.getCause();
+ }
+ return null;
+ }
+
+ private static final class MutableClock extends Clock {
+ private final AtomicLong currentMillis = new AtomicLong();
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneId.of("UTC");
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return Instant.ofEpochMilli(currentMillis.get());
+ }
+
+ @Override
+ public long millis() {
+ return currentMillis.get();
+ }
+
+ private void advanceSeconds(long seconds) {
+ currentMillis.addAndGet(TimeUnit.SECONDS.toMillis(seconds));
+ }
+ }
+}
diff --git a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/ExtractedTextCacheTest.java b/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/ExtractedTextCacheTest.java
index c4ab7301ba..a2f8996dbb 100644
--- a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/ExtractedTextCacheTest.java
+++ b/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/ExtractedTextCacheTest.java
@@ -66,6 +66,17 @@ public class ExtractedTextCacheTest {
assertEquals("test hello", text);
}
+ @Test
+ public void cacheStatsTrackRequestsImplementationIndependently() throws Exception {
+ ExtractedTextCache cache = new ExtractedTextCache(10 * FileUtils.ONE_MB, 100);
+
+ Blob blob = new IdBlob("hello", "stats-id");
+ assertNull(cache.get("/a", "foo", blob, false));
+
+ assertEquals(1, cache.getCacheStats().getRequestCount());
+ assertEquals(1, cache.getCacheStats().getMissCount());
+ }
+
@Test
public void cacheEnabledNonIdBlob() throws Exception {
ExtractedTextCache cache = new ExtractedTextCache(10 * FileUtils.ONE_MB, 100);
diff --git a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/SegmentCacheTest.java b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/SegmentCacheTest.java
index 4b965ce07d..eb63dcd463 100644
--- a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/SegmentCacheTest.java
+++ b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/SegmentCacheTest.java
@@ -70,6 +70,21 @@ public class SegmentCacheTest {
assertEquals(segment1, cache.getSegment(id1, () -> failToLoad(id1)));
}
+ @Test
+ public void getSegmentWrapsCheckedLoaderFailureInExecutionException() {
+ Exception failure = new Exception("load failed");
+
+ try {
+ cache.getSegment(id1, () -> {
+ throw failure;
+ });
+ fail("expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertEquals(failure, e.getCause());
+ assertEquals("load failed", e.getCause().getMessage());
+ }
+ }
+
@Test
public void invalidateTests() throws ExecutionException {
cache.putSegment(segment1);
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/CommitValueResolverTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/CommitValueResolverTest.java
index 77a26568e2..de4f525545 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/CommitValueResolverTest.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/CommitValueResolverTest.java
@@ -207,6 +207,8 @@ public class CommitValueResolverTest {
@Test
public void cacheEmptyCommitValue() throws Exception {
+ // Remove a commit value after the change is written, then compare a resolver
+ // without negative caching to one that remembers old empty results.
addNode("/foo");
// add changes and remove commit value
NodeBuilder builder = ns.getRoot().builder();
@@ -234,6 +236,25 @@ public class CommitValueResolverTest {
assertThat(countDocumentLookUps(() -> cvr.resolve(commitRev, foo)), equalTo(0));
}
+ @Test
+ public void committedValueFromPreviousDocumentIsCached() throws Exception {
+ // Move a committed revision out of the main document into previous documents,
+ // resolve it once, then verify a caching resolver can answer again without
+ // additional document-store lookups.
+ CommitValueResolver cachingResolver = newCachingCommitValueResolver(100);
+ Revision revision = addNode("/foo");
+ assertTrue(getDocument("/").getLocalRevisions().containsKey(revision));
+ while (getDocument("/").getLocalRevisions().containsKey(revision)) {
+ someChange("/");
+ ns.runBackgroundUpdateOperations();
+ }
+
+ NodeDocument root = getDocument("/");
+ assertEquals("c", cachingResolver.resolve(revision, root));
+ NodeDocument cachedRoot = getDocument("/");
+ assertThat(countDocumentLookUps(() -> cachingResolver.resolve(revision, cachedRoot)), equalTo(0));
+ }
+
private int countDocumentLookUps(Callable<?> c) throws Exception {
int numCalls = store.getNumFindCalls(NODES);
c.call();
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilderTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilderTest.java
new file mode 100644
index 0000000000..ae10d8fd09
--- /dev/null
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilderTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.jackrabbit.oak.plugins.document;
+
+import java.lang.reflect.Method;
+
+import org.apache.jackrabbit.oak.cache.CacheStats;
+import org.apache.jackrabbit.oak.plugins.document.cache.NodeDocumentCache;
+import org.apache.jackrabbit.oak.plugins.document.locks.StripedNodeDocumentLocks;
+import org.apache.jackrabbit.oak.plugins.document.memory.MemoryDocumentStore;
+import org.apache.jackrabbit.oak.plugins.document.util.StringValue;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Tests for {@link DocumentNodeStoreBuilder} cache configuration.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class DocumentNodeStoreBuilderTest {
+
+ @Test
+ public void buildNodeDocumentCacheReturnsNonNull() {
+ // Verify the builder can construct the node-document cache with the default
+ // in-memory configuration and a plain in-memory document store.
+ DocumentStore store = new MemoryDocumentStore();
+ NodeDocumentCache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder()
+ .buildNodeDocumentCache(store, new StripedNodeDocumentLocks());
+ Assert.assertNotNull(cache);
+ }
+
+ @Test
+ public void buildNodeDocumentCacheStatsAreNonEmpty() {
+ // The builder wires cache stats as part of construction, so the returned
+ // cache should already expose at least one stats entry.
+ DocumentStore store = new MemoryDocumentStore();
+ NodeDocumentCache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder()
+ .buildNodeDocumentCache(store, new StripedNodeDocumentLocks());
+ Iterable<CacheStats> stats = cache.getCacheStats();
+ Assert.assertNotNull(stats);
+ Assert.assertTrue(stats.iterator().hasNext());
+ }
+
+ @Test
+ public void buildNodeDocumentCacheIsUsable() throws Exception {
+ // Round-trip a document through the built cache so this test checks
+ // observable put/get behavior instead of just construction.
+ DocumentStore docStore = new MemoryDocumentStore();
+ NodeDocumentCache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder()
+ .buildNodeDocumentCache(docStore, new StripedNodeDocumentLocks());
+ // put a document and verify it can be retrieved
+ NodeDocument doc = new NodeDocument(docStore, 1L);
+ doc.put(Document.ID, "test-id");
+ doc.put(Document.MOD_COUNT, 1L);
+ cache.put(doc);
+ NodeDocument result = cache.getIfPresent("test-id");
+ Assert.assertNotNull(result);
+ Assert.assertEquals(doc.getModCount(), result.getModCount());
+ }
+
+ @Test
+ public void buildNodeDocumentCacheWithZeroMemoryDistributionStillReturnsUsableCache() throws Exception {
+ DocumentStore docStore = new MemoryDocumentStore();
+ // This verifies builder behavior when all memory cache buckets are disabled.
+ // It does not assert cache-capacity semantics.
+ NodeDocumentCache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder()
+ .memoryCacheDistribution(0, 0, 0, 0, 0)
+ .buildNodeDocumentCache(docStore, new StripedNodeDocumentLocks());
+ NodeDocument doc = new NodeDocument(docStore, 2L);
+ doc.put(Document.ID, "zero-distribution-id");
+ doc.put(Document.MOD_COUNT, 2L);
+ cache.put(doc);
+
+ NodeDocument result = cache.getIfPresent("zero-distribution-id");
+ Assert.assertNotNull(result);
+ Assert.assertEquals(doc.getModCount(), result.getModCount());
+ }
+
+ @Test
+ public void buildDocumentCacheStoresAndRetrievesDocuments() throws Exception {
+ // buildDocumentCache() currently returns an implementation-specific cache type,
+ // so this test uses reflection and checks only the observable put/get contract.
+ DocumentStore store = new MemoryDocumentStore();
+ Object cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder().buildDocumentCache(store);
+ NodeDocument document = new NodeDocument(store, 1L);
+ StringValue key = StringValue.fromString("document-cache-id");
+ document.put(Document.ID, key.toString());
+ document.put(Document.MOD_COUNT, 7L);
+
+ invoke(cache, "put", Object.class, Object.class, key, document);
+ Object cached = invoke(cache, "getIfPresent", Object.class, key);
+
+ Assert.assertNotNull(cached);
+ Assert.assertTrue(cached instanceof NodeDocument);
+ Assert.assertEquals(document.getModCount(), ((NodeDocument) cached).getModCount());
+ }
+
+ private static Object invoke(Object target, String methodName, Class<?> parameterType, Object argument)
+ throws Exception {
+ Method method = target.getClass().getMethod(methodName, parameterType);
+ return method.invoke(target, argument);
+ }
+
+ private static Object invoke(Object target,
+ String methodName,
+ Class<?> firstType,
+ Class<?> secondType,
+ Object firstArgument,
+ Object secondArgument) throws Exception {
+ Method method = target.getClass().getMethod(methodName, firstType, secondType);
+ return method.invoke(target, firstArgument, secondArgument);
+ }
+}
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreTest.java
index ee7c09f2ef..ae89307b6a 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreTest.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreTest.java
@@ -3499,6 +3499,85 @@ public class DocumentNodeStoreTest {
assertNull(ns.getNodeCache().getIfPresent(new PathRev(path, before)));
}
+ @Test
+ public void getNodeConvertsNodeCacheLoaderFailures() throws Exception {
+ // Build a store that starts healthy, then make only the node read path fail
+ // to verify getNode() converts loader failures into DocumentStoreException.
+ AtomicBoolean failFind = new AtomicBoolean();
+ String fooId = Utils.getIdFromPath("/foo");
+ DocumentStore store = new DocumentStoreWrapper(new MemoryDocumentStore()) {
+ @Override
+ public <T extends Document> T find(Collection<T> collection, String key) {
+ if (collection == NODES && fooId.equals(key) && failFind.get()) {
+ throw new IllegalStateException("node lookup failed");
+ }
+ return super.find(collection, key);
+ }
+ };
+
+ DocumentNodeStore writer = builderProvider.newBuilder().setAsyncDelay(0)
+ .setDocumentStore(store).getNodeStore();
+ NodeBuilder builder = writer.getRoot().builder();
+ builder.child("foo");
+ merge(writer, builder);
+ writer.dispose();
+
+ DocumentNodeStore reader = builderProvider.newBuilder().setAsyncDelay(0)
+ .setDocumentStore(store).getNodeStore();
+ try {
+ failFind.set(true);
+ reader.getNode(Path.fromString("/foo"), reader.getHeadRevision());
+ fail("must fail with DocumentStoreException");
+ } catch (DocumentStoreException e) {
+ assertThat(e.getMessage(), containsString("node lookup failed"));
+ assertTrue(e.getCause() instanceof IllegalStateException);
+ } finally {
+ reader.dispose();
+ }
+ }
+
+ @Test
+ public void getChildrenConvertsNodeChildrenCacheLoaderFailures() throws Exception {
+ // Populate a parent/child structure first, then fail only the child query path
+ // so the test exercises getChildren() exception conversion through the cache loader.
+ AtomicBoolean failQuery = new AtomicBoolean();
+ DocumentStore store = new DocumentStoreWrapper(new MemoryDocumentStore()) {
+ @NotNull
+ @Override
+ public <T extends Document> List<T> query(Collection<T> collection,
+ String fromKey,
+ String toKey,
+ int limit) {
+ if (collection == NODES && failQuery.get()) {
+ throw new IllegalStateException("child query failed");
+ }
+ return super.query(collection, fromKey, toKey, limit);
+ }
+ };
+
+ DocumentNodeStore writer = builderProvider.newBuilder().setAsyncDelay(0)
+ .setDocumentStore(store).getNodeStore();
+ NodeBuilder builder = writer.getRoot().builder();
+ builder.child("parent").child("child");
+ merge(writer, builder);
+ writer.dispose();
+
+ DocumentNodeStore reader = builderProvider.newBuilder().setAsyncDelay(0)
+ .setDocumentStore(store).getNodeStore();
+ try {
+ DocumentNodeState parent = reader.getNode(Path.fromString("/parent"), reader.getHeadRevision());
+ assertNotNull(parent);
+ failQuery.set(true);
+ reader.getChildren(parent, "", 10);
+ fail("must fail with DocumentStoreException");
+ } catch (DocumentStoreException e) {
+ assertThat(e.getMessage(), containsString("Error occurred while fetching children for path /parent"));
+ assertTrue(e.getCause() instanceof IllegalStateException);
+ } finally {
+ reader.dispose();
+ }
+ }
+
// OAK-6351
@Test
public void inconsistentNodeChildrenCache() throws Exception {
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/LocalDiffCacheCompatibilityTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/LocalDiffCacheCompatibilityTest.java
new file mode 100644
index 0000000000..1fde5716c5
--- /dev/null
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/LocalDiffCacheCompatibilityTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.jackrabbit.oak.plugins.document;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.Rule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Compatibility tests for {@link LocalDiffCache}.
+ * These assertions stay at the {@link DiffCache} surface and do not reference
+ * the underlying cache implementation.
+ */
+public class LocalDiffCacheCompatibilityTest {
+
+ private static final int CLUSTER_ID = 1;
+
+ @Rule
+ public DocumentMKBuilderProvider builderProvider = new DocumentMKBuilderProvider();
+
+ private LocalDiffCache buildCache() {
+ return new LocalDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0));
+ }
+
+ @Test
+ public void getChangesReturnsEmptyStringForMissingPathInsideCachedDiff() {
+ // Cache only the root diff and then ask for an uncached child path from the
+ // same revision pair; LocalDiffCache should report that as an empty diff.
+ LocalDiffCache cache = buildCache();
+ RevisionVector from = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector to = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+
+ DiffCache.Entry entry = cache.newEntry(from, to, true);
+ entry.append(Path.ROOT, "^\"root\":{}");
+ entry.done();
+
+ assertEquals("", cache.getChanges(from, to, Path.fromString("/missing"), null));
+ }
+
+ @Test
+ public void getChangesDelegatesToLoaderWhenRevisionPairIsNotCached() {
+ // Start from a completely uncached revision pair and verify getChanges()
+ // falls back to the supplied loader instead of inventing a cached value.
+ LocalDiffCache cache = buildCache();
+ RevisionVector from = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector to = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ AtomicBoolean loaderCalled = new AtomicBoolean();
+
+ String result = cache.getChanges(from, to, Path.ROOT, () -> {
+ loaderCalled.set(true);
+ return "^\"loaded\":{}";
+ });
+
+ assertTrue(loaderCalled.get());
+ assertEquals("^\"loaded\":{}", result);
+ assertNull(cache.getChanges(from, to, Path.ROOT, null));
+ }
+}
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MemoryDiffCacheTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MemoryDiffCacheTest.java
index a62adbed71..a96980e08d 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MemoryDiffCacheTest.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MemoryDiffCacheTest.java
@@ -18,11 +18,14 @@ package org.apache.jackrabbit.oak.plugins.document;
import java.util.UUID;
+import org.apache.jackrabbit.oak.cache.CacheStats;
import org.junit.Rule;
import org.junit.Test;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
public class MemoryDiffCacheTest {
@@ -44,6 +47,60 @@ public class MemoryDiffCacheTest {
assertNull(cache.getChanges(from, to, Path.fromString("/foo"), null));
}
+ @Test
+ public void invalidateAllClearsAllCachedEntries() {
+ DiffCache cache = new MemoryDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0));
+ RevisionVector from = new RevisionVector(Revision.newRevision(1));
+ RevisionVector to = new RevisionVector(Revision.newRevision(1));
+ DiffCache.Entry entry = cache.newEntry(from, to, false);
+ entry.append(Path.ROOT, "^\"foo\":{}");
+ entry.done();
+
+ assertNotNull(cache.getChanges(from, to, Path.ROOT, null));
+ cache.invalidateAll();
+ assertNull(cache.getChanges(from, to, Path.ROOT, null));
+ }
+
+ @Test
+ public void getStatsReturnsNonEmptyIterable() {
+ DiffCache cache = new MemoryDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0));
+ Iterable<CacheStats> statsIterable = cache.getStats();
+ assertNotNull(statsIterable);
+ assertTrue(statsIterable.iterator().hasNext());
+ }
+
+ @Test
+ public void getChangesReturnsNullForUncachedRevisions() {
+ DiffCache cache = new MemoryDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0));
+ RevisionVector from = new RevisionVector(Revision.newRevision(1));
+ RevisionVector to = new RevisionVector(Revision.newRevision(1));
+ assertNull(cache.getChanges(from, to, Path.ROOT, null));
+ }
+
+ @Test
+ public void doneMakesRootPathChangesReadableFromCache() {
+ DiffCache cache = new MemoryDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0));
+ RevisionVector from = new RevisionVector(Revision.newRevision(1));
+ RevisionVector to = new RevisionVector(Revision.newRevision(1));
+ String rootPathChanges = "^\"foo\":{}";
+
+ DiffCache.Entry entry = cache.newEntry(from, to, false);
+ entry.append(Path.ROOT, rootPathChanges);
+ entry.done();
+
+ String actualChanges = cache.getChanges(from, to, Path.ROOT, null);
+ assertNotNull(actualChanges);
+ assertEquals(rootPathChanges, actualChanges);
+ }
+
private static String changes(int minLength) {
StringBuilder sb = new StringBuilder();
while (sb.length() < minLength) {
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/TieredDiffCacheTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/TieredDiffCacheTest.java
new file mode 100644
index 0000000000..0923ca8750
--- /dev/null
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/TieredDiffCacheTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.jackrabbit.oak.plugins.document;
+
+import java.lang.reflect.Field;
+
+import org.apache.jackrabbit.oak.cache.CacheStats;
+import org.junit.Rule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link TieredDiffCache}.
+ * These assertions intentionally avoid third-party cache types so the same
+ * tests can run across cache implementation changes.
+ */
+public class TieredDiffCacheTest {
+
+ private static final int CLUSTER_ID = 1;
+
+ @Rule
+ public DocumentMKBuilderProvider builderProvider = new DocumentMKBuilderProvider();
+
+ private TieredDiffCache buildCache() {
+ return new TieredDiffCache(builderProvider.newBuilder()
+ .setCacheSegmentCount(1)
+ .memoryCacheDistribution(0, 0, 0, 99, 0), CLUSTER_ID);
+ }
+
+ @Test
+ public void getChangesReturnsNullForUncachedRevision() {
+ TieredDiffCache cache = buildCache();
+ RevisionVector from = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector to = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ assertNull(cache.getChanges(from, to, Path.ROOT, null));
+ }
+
+ @Test
+ public void newEntryLocalPopulatesLocalCache() {
+ TieredDiffCache cache = buildCache();
+ RevisionVector from = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector to = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ String rootPathChanges = "^\"foo\":{}";
+
+ DiffCache.Entry entry = cache.newEntry(from, to, true);
+ entry.append(Path.ROOT, rootPathChanges);
+ entry.done();
+
+ assertEquals(rootPathChanges, getTier(cache, "localCache").getChanges(from, to, Path.ROOT, null));
+ assertNull(getTier(cache, "memoryCache").getChanges(from, to, Path.ROOT, null));
+ assertEquals(rootPathChanges, cache.getChanges(from, to, Path.ROOT, null));
+ }
+
+ @Test
+ public void newEntryExternalPopulatesMemoryCache() {
+ TieredDiffCache cache = buildCache();
+ RevisionVector from = new RevisionVector(Revision.newRevision(2));
+ RevisionVector to = new RevisionVector(Revision.newRevision(2));
+ String rootPathChanges = "^\"bar\":{}";
+
+ DiffCache.Entry entry = cache.newEntry(from, to, false);
+ entry.append(Path.ROOT, rootPathChanges);
+ entry.done();
+
+ assertNull(getTier(cache, "localCache").getChanges(from, to, Path.ROOT, null));
+ assertEquals(rootPathChanges, getTier(cache, "memoryCache").getChanges(from, to, Path.ROOT, null));
+ assertEquals(rootPathChanges, cache.getChanges(from, to, Path.ROOT, null));
+ }
+
+ @Test
+ public void getStatsReturnsNonEmptyIterable() {
+ TieredDiffCache cache = buildCache();
+ Iterable<CacheStats> stats = cache.getStats();
+ assertNotNull(stats);
+ assertTrue(stats.iterator().hasNext());
+ }
+
+ @Test
+ public void invalidateAllClearsCache() {
+ TieredDiffCache cache = buildCache();
+ RevisionVector localFrom = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector localTo = new RevisionVector(Revision.newRevision(CLUSTER_ID));
+ RevisionVector externalFrom = new RevisionVector(Revision.newRevision(2));
+ RevisionVector externalTo = new RevisionVector(Revision.newRevision(2));
+
+ DiffCache.Entry localEntry = cache.newEntry(localFrom, localTo, true);
+ localEntry.append(Path.ROOT, "^\"local\":{}");
+ localEntry.done();
+
+ DiffCache.Entry externalEntry = cache.newEntry(externalFrom, externalTo, false);
+ externalEntry.append(Path.ROOT, "^\"external\":{}");
+ externalEntry.done();
+
+ assertNotNull(cache.getChanges(localFrom, localTo, Path.ROOT, null));
+ assertNotNull(cache.getChanges(externalFrom, externalTo, Path.ROOT, null));
+ cache.invalidateAll();
+ assertNull(getTier(cache, "localCache").getChanges(localFrom, localTo, Path.ROOT, null));
+ assertNull(getTier(cache, "memoryCache").getChanges(externalFrom, externalTo, Path.ROOT, null));
+ assertNull(cache.getChanges(localFrom, localTo, Path.ROOT, null));
+ assertNull(cache.getChanges(externalFrom, externalTo, Path.ROOT, null));
+ }
+
+ private static DiffCache getTier(TieredDiffCache cache, String fieldName) {
+ try {
+ Field field = TieredDiffCache.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (DiffCache) field.get(cache);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError("Unable to access diff cache tier " + fieldName, e);
+ }
+ }
+}
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/NodeDocumentCacheTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/NodeDocumentCacheTest.java
index 6cb22db5c3..8ab1f82df8 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/NodeDocumentCacheTest.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/NodeDocumentCacheTest.java
@@ -16,6 +16,9 @@
*/
package org.apache.jackrabbit.oak.plugins.document.cache;
+import java.util.concurrent.ExecutionException;
+
+import org.apache.jackrabbit.oak.cache.CacheStats;
import org.apache.jackrabbit.oak.plugins.document.Document;
import org.apache.jackrabbit.oak.plugins.document.DocumentStore;
import org.apache.jackrabbit.oak.plugins.document.NodeDocument;
@@ -28,6 +31,10 @@ import org.junit.Test;
import static java.util.Collections.singleton;
import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
public class NodeDocumentCacheTest {
@@ -68,6 +75,69 @@ public class NodeDocumentCacheTest {
assertEquals(updated.getModCount(), cache.get(ID, () -> updated).getModCount());
}
+ @Test
+ public void getWithCallableLoadsDocumentOnMiss() throws Exception {
+ NodeDocument doc = createDocument(1L);
+ NodeDocument loaded = cache.get(ID, () -> doc);
+ assertEquals(doc.getModCount(), loaded.getModCount());
+ }
+
+ @Test
+ public void getWithCallableReturnsCachedDocumentOnHit() throws Exception {
+ NodeDocument doc = createDocument(1L);
+ cache.put(doc);
+ // loader should not be called since doc is already cached
+ NodeDocument loaded = cache.get(ID, () -> {
+ throw new RuntimeException("loader must not be called on cache hit");
+ });
+ assertEquals(doc.getModCount(), loaded.getModCount());
+ }
+
+ @Test
+ public void getWithCallableWrapsCheckedLoaderFailureInExecutionException() {
+ Exception failure = new Exception("simulated load failure");
+ try {
+ cache.get(ID, () -> {
+ // This verifies the existing Oak-visible checked-exception
+ // contract, not raw runtime propagation.
+ throw failure;
+ });
+ fail("expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertEquals(failure, e.getCause());
+ assertEquals("simulated load failure", e.getCause().getMessage());
+ }
+ }
+
+ @Test
+ public void getIfPresentReturnsNullForUncachedKey() {
+ assertNull(cache.getIfPresent("not-cached"));
+ }
+
+ @Test
+ public void putAndGetIfPresentReturnsDocument() {
+ NodeDocument doc = createDocument(5L);
+ cache.put(doc);
+ NodeDocument result = cache.getIfPresent(ID);
+ assertNotNull(result);
+ assertEquals(doc.getModCount(), result.getModCount());
+ }
+
+ @Test
+ public void invalidateRemovesDocumentFromCache() {
+ NodeDocument doc = createDocument(1L);
+ cache.put(doc);
+ cache.invalidate(ID);
+ assertNull(cache.getIfPresent(ID));
+ }
+
+ @Test
+ public void getCacheStatsReturnsNonEmptyIterable() {
+ Iterable<CacheStats> statsIterable = cache.getCacheStats();
+ assertNotNull(statsIterable);
+ assertTrue(statsIterable.iterator().hasNext());
+ }
+
private NodeDocument createDocument(long modCount) {
NodeDocument doc = new NodeDocument(store, modCount);
doc.put(Document.ID, ID);
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/persistentCache/PersistentCacheCompatibilityTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/persistentCache/PersistentCacheCompatibilityTest.java
new file mode 100644
index 0000000000..f9a52f6304
--- /dev/null
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/persistentCache/PersistentCacheCompatibilityTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.jackrabbit.oak.plugins.document.persistentCache;
+
+import java.io.File;
+import java.util.concurrent.ExecutionException;
+
+import org.apache.jackrabbit.oak.cache.CacheLIRS;
+import org.apache.jackrabbit.oak.plugins.document.MemoryDiffCache;
+import org.apache.jackrabbit.oak.plugins.document.Path;
+import org.apache.jackrabbit.oak.plugins.document.Revision;
+import org.apache.jackrabbit.oak.plugins.document.RevisionVector;
+import org.apache.jackrabbit.oak.plugins.document.util.StringValue;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+/**
+ * Compatibility tests for the persistent cache wrapping layer.
+ * These assertions stay on Oak-visible behavior and intentionally avoid direct
+ * use of third-party cache APIs in the test code.
+ */
+public class PersistentCacheCompatibilityTest {
+
+ @Rule
+ public final TemporaryFolder tempFolder = new TemporaryFolder(new File("target"));
+
+ @Test
+ public void wrapReturnsUsablePersistentDiffCache() throws Exception {
+ // Wrapping a DIFF cache should return a usable cache handle whose
+ // observable put/get behavior matches the in-memory base contract.
+ CacheHandle handle = openDiffCache("wrap");
+ try {
+ MemoryDiffCache.Key key = key(0);
+ StringValue value = new StringValue("value");
+ assertNotNull(handle.cache);
+ handle.cache.put(key, value);
+ assertEquals(value, handle.cache.getIfPresent(key));
+ } finally {
+ handle.close();
+ }
+ }
+
+ @Test
+ public void invalidateRemovesOnlyTheRequestedPersistedEntry() throws Exception {
+ // Persist two keys, invalidate only one after reopening, then reopen again
+ // to prove the removal was durable and did not affect the sibling entry.
+ MemoryDiffCache.Key first = key(1);
+ MemoryDiffCache.Key second = key(2);
+
+ CacheHandle initial = openDiffCache("invalidateOne");
+ try {
+ initial.cache.put(first, new StringValue("first"));
+ initial.cache.put(second, new StringValue("second"));
+ } finally {
+ initial.close();
+ }
+
+ CacheHandle reopened = openDiffCache("invalidateOne");
+ try {
+ assertEquals(new StringValue("first"), reopened.cache.getIfPresent(first));
+ assertEquals(new StringValue("second"), reopened.cache.getIfPresent(second));
+ reopened.cache.invalidate(first);
+ } finally {
+ reopened.close();
+ }
+
+ CacheHandle afterInvalidate = openDiffCache("invalidateOne");
+ try {
+ assertNull(afterInvalidate.cache.getIfPresent(first));
+ assertEquals(new StringValue("second"), afterInvalidate.cache.getIfPresent(second));
+ } finally {
+ afterInvalidate.close();
+ }
+ }
+
+ @Test
+ public void invalidateAllClearsPersistedEntriesAcrossReopen() throws Exception {
+ // Persist entries, clear the wrapped cache, and reopen the persistent layer
+ // to verify invalidateAll() removes the durable state as well.
+ MemoryDiffCache.Key first = key(1);
+ MemoryDiffCache.Key second = key(2);
+
+ CacheHandle initial = openDiffCache("invalidateAll");
+ try {
+ initial.cache.put(first, new StringValue("first"));
+ initial.cache.put(second, new StringValue("second"));
+ } finally {
+ initial.close();
+ }
+
+ CacheHandle reopened = openDiffCache("invalidateAll");
+ try {
+ assertEquals(new StringValue("first"), reopened.cache.getIfPresent(first));
+ assertEquals(new StringValue("second"), reopened.cache.getIfPresent(second));
+ reopened.cache.invalidateAll();
+ } finally {
+ reopened.close();
+ }
+
+ CacheHandle afterInvalidate = openDiffCache("invalidateAll");
+ try {
+ assertNull(afterInvalidate.cache.getIfPresent(first));
+ assertNull(afterInvalidate.cache.getIfPresent(second));
+ } finally {
+ afterInvalidate.close();
+ }
+ }
+
+ @Test
+ public void getWrapsCheckedLoaderFailureInExecutionException() throws Exception {
+ // Use a checked loader failure here because NodeCache exposes the same
+ // checked get(key, loader) contract as the in-memory cache underneath it.
+ CacheHandle handle = openDiffCache("loaderFailure");
+ Exception failure = new Exception("simulated persistent-cache load failure");
+
+ try {
+ handle.cache.get(key(7), () -> {
+ throw failure;
+ });
+ fail("expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertSame(failure, e.getCause());
+ assertEquals("simulated persistent-cache load failure", e.getCause().getMessage());
+ } finally {
+ handle.close();
+ }
+ }
+
+ private CacheHandle openDiffCache(String name) throws Exception {
+ File directory = new File(tempFolder.getRoot(), name);
+ directory.mkdirs();
+ PersistentCache persistentCache = new PersistentCache(directory.getAbsolutePath() + ",-async");
+ CacheLIRS<MemoryDiffCache.Key, StringValue> base = CacheLIRS.<MemoryDiffCache.Key, StringValue>newBuilder()
+ .maximumSize(16)
+ .build();
+ @SuppressWarnings("unchecked")
+ NodeCache<MemoryDiffCache.Key, StringValue> wrapped = (NodeCache<MemoryDiffCache.Key, StringValue>) persistentCache.wrap(
+ null, null, base, CacheType.DIFF);
+ return new CacheHandle(persistentCache, wrapped);
+ }
+
+ private static MemoryDiffCache.Key key(int id) {
+ RevisionVector from = new RevisionVector(new Revision(0, 0, id));
+ RevisionVector to = new RevisionVector(new Revision(1, 0, id));
+ return new MemoryDiffCache.Key(Path.fromString("/node-" + id), from, to);
+ }
+
+ private static final class CacheHandle {
+ private final PersistentCache persistentCache;
+ private final NodeCache<MemoryDiffCache.Key, StringValue> cache;
+
+ private CacheHandle(PersistentCache persistentCache,
+ NodeCache<MemoryDiffCache.Key, StringValue> cache) {
+ this.persistentCache = persistentCache;
+ this.cache = cache;
+ }
+
+ private void close() {
+ persistentCache.close();
+ }
+ }
+}