From 5a69325dc96af62510c246258eaa150b23f13350 Mon Sep 17 00:00:00 2001 From: rishabhdaim Date: Wed, 7 Oct 2026 09:27:19 +0530 Subject: [PATCH] OAK-12425: opt DocumentNodeStore caches into Caffeine SYNC Co-Authored-By: OpenAI Codex --- .../src/site/markdown/nodestore/documentmk.md | 8 + oak-doc/src/site/markdown/osgi_config.md | 4 +- .../oak/plugins/document/Configuration.java | 11 +- .../document/DocumentNodeStoreBuilder.java | 28 ++- .../document/DocumentNodeStoreService.java | 6 + .../DocumentCacheConcurrencyTest.java | 117 +++++++++ .../DocumentCacheFeatureLifecycleTest.java | 104 ++++++++ .../DocumentCacheMaintenanceTest.java | 153 ++++++++++++ .../DocumentNodeStoreBuilderTest.java | 230 +++++++++++++++++- .../DocumentNodeStoreServiceTest.java | 17 ++ specs/oak-12425-cache-maintenance/PLAN.md | 43 ++++ 11 files changed, 706 insertions(+), 15 deletions(-) create mode 100644 oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheConcurrencyTest.java create mode 100644 oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheFeatureLifecycleTest.java create mode 100644 oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheMaintenanceTest.java create mode 100644 specs/oak-12425-cache-maintenance/PLAN.md diff --git a/oak-doc/src/site/markdown/nodestore/documentmk.md b/oak-doc/src/site/markdown/nodestore/documentmk.md index c3a504b63b4..e447b7b6ddb 100644 --- a/oak-doc/src/site/markdown/nodestore/documentmk.md +++ b/oak-doc/src/site/markdown/nodestore/documentmk.md @@ -937,6 +937,14 @@ of concern. ### Cache Configuration +CacheLIRS remains the default. With OAK-12425, enable +`FT_CAFFEINE_CACHE_OAK-12425` or set `-Doak.documentMK.caffeineCache=true` to +select Caffeine with SYNC maintenance. Selection is fixed per builder; recreate +the builder/store or restart the service after changing the toggle. +The legacy `-Doak.documentMK.guavaCache=true` also selects Caffeine SYNC, +regardless of the new toggle; despite its name, it no longer selects Guava. +Setting both properties to `true` selects the same Caffeine SYNC implementation. + In a default setup the [DocumentNodeStoreService][osgi-config] takes a single config for `cache` which is internally distributed among the various caches above in following way diff --git a/oak-doc/src/site/markdown/osgi_config.md b/oak-doc/src/site/markdown/osgi_config.md index 6ee3e47fd0d..1902a19633f 100644 --- a/oak-doc/src/site/markdown/osgi_config.md +++ b/oak-doc/src/site/markdown/osgi_config.md @@ -260,8 +260,8 @@ prevDocCachePercentage | 4 | Percentage of `cache` allocated for `prevDocCache`. childrenCachePercentage | 15 (was 10 until 1.5.14) | Percentage of `cache` allocated for `childrenCache`. See [Caching][doc-cache] | 1.0.12 diffCachePercentage | 30 (was 5 until 1.5.14) | Percentage of `cache` allocated for `diffCache`. See [Caching][doc-cache] | 1.0.12 docChildrenCachePercentage | 0 (was 3 until 1.5.6) | Percentage of `cache` allocated for `docChildrenCache`. See [Caching][doc-cache] (Removed since 1.5.6) | 1.0.12 -cacheSegmentCount | 16 | The number of segments in the LIRS cache | 1.0.15, 1.2.3, 1.3.0 -cacheStackMoveDistance | 16 | The delay to move entries to the head of the queue in the LIRS cache | 1.0.15, 1.2.3, 1.3.0 +cacheSegmentCount | 16 | The number of segments in the LIRS cache (ignored by the opt-in Caffeine cache, see `FT_CAFFEINE_CACHE_OAK-12425`; toggling it requires a DocumentNodeStore restart) | 1.0.15, 1.2.3, 1.3.0 +cacheStackMoveDistance | 16 | The delay to move entries to the head of the queue in the LIRS cache (ignored by the opt-in Caffeine cache, see `FT_CAFFEINE_CACHE_OAK-12425`; toggling it requires a DocumentNodeStore restart) | 1.0.15, 1.2.3, 1.3.0 sharedDSRepoId | "" | Custom SharedDataStore repositoryId. Used when custom blobstore configured. Should be unique among the repositories sharing the datastore. | 1.2.11 blobTrackSnapshotIntervalInSecs | 0 | The blob ids cached/tracked locally are synchronized with the DataStore at this interval. Any additions and deletions will be visible to other cluster nodes or repositories connected to the shared DatStore after this. This should be less than the blobGcMaxAgeInSecs parameter above and the frequency of blob gc. A value of `0` disables blob ID tracking (default since Oak 2.4.0). See [Blob tracker][blobtracker]. | 1.5.6 updateLimit | 100000 | The number of updates kept in memory until changes are written to a branch in the DocumentStore | 1.7.0 diff --git a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/Configuration.java b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/Configuration.java index 9e3d5d798d5..bc2992e91ff 100644 --- a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/Configuration.java +++ b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/Configuration.java @@ -35,6 +35,7 @@ import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreBuilder.DEFAULT_PREV_DOC_CACHE_PERCENTAGE; import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreBuilder.DEFAULT_PREV_NO_PROP_CACHE_PERCENTAGE; import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreBuilder.DEFAULT_UPDATE_LIMIT; +import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE; import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreService.DEFAULT_AVOID_EXCLUSIVE_MERGE_LOCK; import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreService.DEFAULT_FULL_GC_ENABLED; import static org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreService.DEFAULT_EMBEDDED_VERIFICATION_ENABLED; @@ -194,7 +195,10 @@ name = "LIRS Cache Segment Count", description = "The number of segments in the LIRS cache " + "(default 16, a higher count means higher concurrency " + - "but slightly lower cache hit rate)") + "but slightly lower cache hit rate). " + + "Only used when the LIRS cache is enabled (feature toggle " + + FT_CAFFEINE_CACHE + " disabled); ignored when Caffeine is enabled. " + + "Toggling the feature takes effect only after the DocumentNodeStore is restarted.") int cacheSegmentCount() default DEFAULT_CACHE_SEGMENT_COUNT; @AttributeDefinition( @@ -202,7 +206,10 @@ description = "The delay to move entries to the head of the queue " + "in the LIRS cache " + "(default 16, a higher value means higher concurrency " + - "but slightly lower cache hit rate)") + "but slightly lower cache hit rate). " + + "Only used when the LIRS cache is enabled (feature toggle " + + FT_CAFFEINE_CACHE + " disabled); ignored when Caffeine is enabled. " + + "Toggling the feature takes effect only after the DocumentNodeStore is restarted.") int cacheStackMoveDistance() default DEFAULT_CACHE_STACK_MOVE_DISTANCE; @AttributeDefinition( diff --git a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilder.java b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilder.java index b4b554215e9..65e21489573 100644 --- a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilder.java +++ b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreBuilder.java @@ -32,6 +32,7 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.function.Supplier; @@ -104,10 +105,17 @@ public class DocumentNodeStoreBuilder> { "oak.documentMK.manyChildren", 50); /** - * Whether to use the CacheLIRS (default) or the Guava cache implementation. + * CacheLIRS is the default. The legacy guavaCache property selects the Caffeine + * SYNC fallback independently of the Caffeine feature toggle. */ private static final boolean LIRS_CACHE = !Boolean.getBoolean("oak.documentMK.guavaCache"); + static final String FT_CAFFEINE_CACHE = "FT_CAFFEINE_CACHE_OAK-12425"; + + // Changing the opt-in takes effect when a new builder constructs its caches. + static final AtomicBoolean FT_CAFFEINE_CACHE_ENABLED = new AtomicBoolean( + Boolean.getBoolean("oak.documentMK.caffeineCache")); + /** * Number of content updates that need to happen before the updates * are automatically purged to the private branch. @@ -130,6 +138,7 @@ public class DocumentNodeStoreBuilder> { private Feature cancelInvalidationFeature; private Feature docStoreAvoidMergeLockFeature; private Feature prevNoPropCacheFeature; + private final Supplier caffeineCacheEnabled = Suppliers.memoize(FT_CAFFEINE_CACHE_ENABLED::get); private Weigher weigher = new EmpiricalWeigher(); private long memoryCacheSize = DEFAULT_MEMORY_CACHE_SIZE; private int nodeCachePercentage = DEFAULT_NODE_CACHE_PERCENTAGE; @@ -1076,7 +1085,8 @@ private Cache buildCache( long maxWeight, final Set> listeners) { // do not use LIRS cache when maxWeight is zero (OAK-6953) - if (LIRS_CACHE && maxWeight > 0) { + boolean caffeine = caffeineCacheEnabled.get(); + if (maxWeight > 0 && !caffeine && LIRS_CACHE) { return CacheLIRS.newBuilder() .module(module) .weigher((key, value) -> weigher.weigh(key, value)) @@ -1094,15 +1104,15 @@ private Cache buildCache( } CacheBuilder builder = CacheBuilder.newBuilder() .maximumWeight(maxWeight) + .maintenanceMode(CacheBuilder.MaintenanceMode.SYNC) .weigher(weigher::weigh) .recordStats(); - if (!listeners.isEmpty()) { - builder = builder.evictionListener((k, v, cause) -> { - for (EvictionListener l : listeners) { - l.evicted(k, v, cause); - } - }); - } + // PersistentCache registers its listener after the memory cache is built. + builder.evictionListener((k, v, cause) -> { + for (EvictionListener l : listeners) { + l.evicted(k, v, cause); + } + }); return builder.build(); } diff --git a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreService.java b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreService.java index 7a289417bd4..d1086e3a74f 100644 --- a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreService.java +++ b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreService.java @@ -95,6 +95,7 @@ import org.apache.jackrabbit.oak.spi.state.RevisionGC; import org.apache.jackrabbit.oak.spi.state.RevisionGCMBean; import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; import org.apache.jackrabbit.oak.spi.whiteboard.AbstractServiceTracker; import org.apache.jackrabbit.oak.spi.whiteboard.Registration; import org.apache.jackrabbit.oak.spi.whiteboard.Tracker; @@ -299,6 +300,11 @@ protected void activate(ComponentContext context, Configuration config) throws E cancelInvalidationFeature = Feature.newFeature(FT_NAME_CANCEL_INVALIDATION, whiteboard); docStoreAvoidMergeLockFeature = Feature.newFeature(FT_NAME_AVOID_MERGE_LOCK, whiteboard); prevNoPropCacheFeature = Feature.newFeature(FT_NAME_PREV_NO_PROP_CACHE, whiteboard); + // Preserve the selected opt-in across the restart needed to change cache implementations. + addRegistration(whiteboard.register(FeatureToggle.class, + new FeatureToggle(DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE, + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED), + Collections.emptyMap())); registerNodeStoreIfPossible(); } diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheConcurrencyTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheConcurrencyTest.java new file mode 100644 index 00000000000..c04a9675238 --- /dev/null +++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheConcurrencyTest.java @@ -0,0 +1,117 @@ +/* + * 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.io.File; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.apache.jackrabbit.oak.plugins.document.memory.MemoryDocumentStore; +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.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Tests cross-cache loading in {@link DocumentNodeStore}. */ +public class DocumentCacheConcurrencyTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(new File("target")); + + /** Verifies a node read does not wait for an in-flight children load. */ + @Test + public void coldNodeReadCompletesDuringChildrenLoad() throws Exception { + boolean previous = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.get(); + try { + for (boolean caffeine : new boolean[] {false, true}) { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(caffeine); + verifyColdNodeRead(caffeine); + } + } finally { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(previous); + } + } + + private void verifyColdNodeRead(boolean caffeine) throws Exception { + PausingDocumentStore documents = new PausingDocumentStore(); + DocumentNodeStoreBuilder builder = new DocumentNodeStoreBuilder<>() + .setDocumentStore(documents) + .setAsyncDelay(0) + .setPersistentCache(temporaryFolder.newFolder().getAbsolutePath()); + DocumentNodeStore store = builder.build(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + NodeBuilder root = store.getRoot().builder(); + root.child("a"); + store.merge(root, EmptyHook.INSTANCE, CommitInfo.EMPTY); + DocumentNodeState parent = store.getRoot(); + store.getNodeCache().invalidateAll(); + store.getNodeChildrenCache().invalidateAll(); + documents.pause = true; + Future children = executor.submit(() -> store.getChildren(parent, "", 10)); + Assert.assertTrue("Children loader did not start: " + caffeine, + documents.entered.await(10, TimeUnit.SECONDS)); + Future node = executor.submit(() -> + store.getNode(Path.fromString("/a"), parent.getLastRevision())); + Assert.assertNotNull("Node read must complete while children load is paused: " + caffeine, + node.get(5, TimeUnit.SECONDS)); + documents.release.countDown(); + Assert.assertTrue(children.get(10, TimeUnit.SECONDS).children.contains("a")); + } finally { + // Stop the paused loader without entering the reverse dependency if the assertion fails. + documents.abort = true; + documents.release.countDown(); + executor.shutdownNow(); + Assert.assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + store.dispose(); + } + } + + private static final class PausingDocumentStore extends MemoryDocumentStore { + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private volatile boolean pause; + private volatile boolean abort; + + @Override + public List query(Collection collection, String fromKey, String toKey, int limit) { + if (pause && collection == Collection.NODES) { + entered.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to release children loader"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Children loader interrupted", e); + } + if (abort) { + return Collections.emptyList(); + } + } + return super.query(collection, fromKey, toKey, limit); + } + } +} diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheFeatureLifecycleTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheFeatureLifecycleTest.java new file mode 100644 index 00000000000..0d5afc6d612 --- /dev/null +++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheFeatureLifecycleTest.java @@ -0,0 +1,104 @@ +/* + * 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.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.sql.DataSource; + +import org.apache.jackrabbit.oak.cache.CacheValue; +import org.apache.jackrabbit.oak.cache.api.Cache; +import org.apache.jackrabbit.oak.cache.impl.caffeine.CaffeineCacheAdapter; +import org.apache.jackrabbit.oak.plugins.document.memory.MemoryDocumentStore; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.apache.sling.testing.mock.osgi.MockOsgi; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.h2.jdbcx.JdbcConnectionPool; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Tests the registered cache toggle in {@link DocumentNodeStoreService}. */ +public class DocumentCacheFeatureLifecycleTest { + @Rule + public OsgiContext context = new OsgiContext(); + + @Rule + public TemporaryFolder folder = new TemporaryFolder(new File("target")); + + /** Existing caches retain their policy and a new service samples the retained toggle. */ + @Test + public void registeredToggleIsSampledAtConstructionAndSurvivesReactivation() throws Exception { + JdbcConnectionPool dataSource = JdbcConnectionPool.create( + "jdbc:h2:" + folder.newFolder().getAbsolutePath() + "/repository", "sa", ""); + DocumentNodeStoreService service = null; + boolean previous = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.getAndSet(true); + try { + context.registerService(StatisticsProvider.class, StatisticsProvider.NOOP); + context.registerInjectActivateService(new DocumentNodeStoreService.Preset()); + context.registerService(DataSource.class, dataSource, Collections.singletonMap("datasource.name", "oak")); + Map config = new HashMap<>(); + config.put("documentStoreType", "RDB"); + config.put("persistentCache", "-"); + config.put("journalCache", "-"); + config.put("repository.home", folder.newFolder().getAbsolutePath()); + MockOsgi.setConfigForPid(context.bundleContext(), DocumentNodeStoreService.class.getName(), config); + service = activateService(); + Cache existing = context.getService(DocumentNodeStore.class).getNodeCache(); + Assert.assertTrue(existing instanceof CaffeineCacheAdapter); + toggle().setEnabled(false); + Assert.assertSame(existing, context.getService(DocumentNodeStore.class).getNodeCache()); + Assert.assertTrue(existing instanceof CaffeineCacheAdapter); + Cache newlyConstructed = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .buildDocumentCache(new MemoryDocumentStore()); + Assert.assertEquals("LirsLoadingCacheAdapter", newlyConstructed.getClass().getSimpleName()); + MockOsgi.deactivate(service, context.bundleContext()); + service = null; + Assert.assertFalse(Arrays.stream(context.getServices(FeatureToggle.class, null)) + .anyMatch(candidate -> DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE.equals(candidate.getName()))); + service = activateService(); + Assert.assertFalse(toggle().isEnabled()); + Assert.assertEquals("LirsLoadingCacheAdapter", + context.getService(DocumentNodeStore.class).getNodeCache().getClass().getSimpleName()); + } finally { + if (service != null) { + MockOsgi.deactivate(service, context.bundleContext()); + } + dataSource.dispose(); + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(previous); + } + } + + private DocumentNodeStoreService activateService() { + DocumentNodeStoreService service = new DocumentNodeStoreService(); + MockOsgi.injectServices(service, context.bundleContext()); + MockOsgi.activate(service, context.bundleContext()); + return service; + } + + private FeatureToggle toggle() { + return Arrays.stream(context.getServices(FeatureToggle.class, null)) + .filter(candidate -> DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE.equals(candidate.getName())) + .findFirst().orElseThrow(() -> new AssertionError("Cache feature toggle is not registered")); + } +} diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheMaintenanceTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheMaintenanceTest.java new file mode 100644 index 00000000000..316cc986463 --- /dev/null +++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentCacheMaintenanceTest.java @@ -0,0 +1,153 @@ +/* + * 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.io.File; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.jackrabbit.oak.cache.CacheValue; +import org.apache.jackrabbit.oak.cache.api.Cache; +import org.apache.jackrabbit.oak.plugins.document.persistentCache.EvictionListener; +import org.apache.jackrabbit.oak.plugins.document.persistentCache.PersistentCache; +import org.apache.jackrabbit.oak.plugins.document.util.StringValue; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; + +/** Tests per-cache construction policies in {@link DocumentNodeStoreBuilder}. */ +public class DocumentCacheMaintenanceTest { + @Rule + public final TemporaryFolder folder = new TemporaryFolder(new File("target")); + + /** Document diff callbacks run inline by default. */ + @Test + public void documentDiffCacheUsesSynchronousMaintenance() throws Exception { + checkCallbackThread(); + } + + private void checkCallbackThread() throws Exception { + RecordingCallback callbackThread = new RecordingCallback(); + PersistentCache persistence = Mockito.spy(new PersistentCache(folder.newFolder().getAbsolutePath() + ",+asyncDiff")); + Mockito.doAnswer(invocation -> { + Cache real = (Cache) invocation.callRealMethod(); + return Mockito.mock(Cache.class, Mockito.withSettings().extraInterfaces(EvictionListener.class) + .defaultAnswer(callback -> { + if (callback.getMethod().getName().equals("evicted")) { + callbackThread.record(); + } + return AdditionalAnswers.delegatesTo(real).answer(callback); + })); + }).when(persistence).wrap(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + DocumentNodeStoreBuilder builder = new RecordingBuilder(persistence) + .memoryCacheSize(1000).withWeigher((key, value) -> 1000); + boolean previous = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.getAndSet(true); + try { + Cache cache = builder.buildMemoryDiffCache(); + Thread caller = Thread.currentThread(); + cache.get(key(), k -> new StringValue("value")); + Assert.assertSame(caller, callbackThread.thread.get()); + } finally { + builder.getPersistentCache().close(); + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(previous); + } + } + + /** A failing SYNC eviction listener cannot fail cache reads or subsequent writes. */ + @Test + public void synchronousEvictionListenerFailureDoesNotFailCacheOperations() throws Exception { + AtomicReference callbackThread = new AtomicReference<>(); + AtomicInteger callbacks = new AtomicInteger(); + PersistentCache persistence = Mockito.mock(PersistentCache.class); + Mockito.doAnswer(invocation -> { + Cache memory = invocation.getArgument(2); + return Mockito.mock(Cache.class, Mockito.withSettings().extraInterfaces(EvictionListener.class) + .defaultAnswer(callback -> { + if (callback.getMethod().getName().equals("evicted")) { + callbackThread.set(Thread.currentThread()); + callbacks.incrementAndGet(); + throw new IllegalStateException("Simulated persistence callback failure"); + } + return AdditionalAnswers.delegatesTo(memory).answer(callback); + })); + }).when(persistence).wrap(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + DocumentNodeStoreBuilder builder = new RecordingBuilder(persistence) + .memoryCacheSize(1000).withWeigher((key, value) -> 1000); + boolean previous = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.getAndSet(true); + try { + Cache cache = builder.buildMemoryDiffCache(); + StringValue value = new StringValue("value"); + Assert.assertSame(value, cache.get(key(), key -> value)); + Assert.assertNull(cache.getIfPresent(key())); + cache.put(key(), value); + Assert.assertNull(cache.getIfPresent(key())); + Assert.assertEquals(2, callbacks.get()); + Assert.assertSame(Thread.currentThread(), callbackThread.get()); + } finally { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(previous); + } + } + + /** The legacy wrapper adds no entry overhead to custom weights. */ + @Test + public void customWeigherReceivesTheRawValueWithoutEntryOverhead() throws Exception { + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(100_000) + .setPersistentCache(folder.newFolder().getAbsolutePath() + ",+asyncDiff") + .withWeigher((key, value) -> { + Assert.assertTrue(value instanceof StringValue); + return 100; + }); + boolean previous = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.getAndSet(true); + try { + Cache cache = builder.buildMemoryDiffCache(); + cache.put(key(), new StringValue("value")); + cache.cleanUp(); + Assert.assertEquals(100, cache.getUsedWeight()); + } finally { + builder.getPersistentCache().close(); + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(previous); + } + } + + private static final class RecordingBuilder extends DocumentNodeStoreBuilder { + private final PersistentCache persistence; + + private RecordingBuilder(PersistentCache persistence) { + this.persistence = persistence; + } + + @Override + public PersistentCache getPersistentCache() { return persistence; } + } + + private static MemoryDiffCache.Key key() { + return new MemoryDiffCache.Key(Path.fromString("/key"), new RevisionVector(new Revision(1, 0, 1)), + new RevisionVector(new Revision(2, 0, 1))); + } + + private static final class RecordingCallback { + private final AtomicReference thread = new AtomicReference<>(); + + private void record() { + thread.set(Thread.currentThread()); + } + } +} 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 index dff4d9b7d0f..892f7bf0cee 100644 --- 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 @@ -18,35 +18,173 @@ import java.io.File; import java.util.HashMap; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import org.apache.jackrabbit.oak.cache.CacheValue; import org.apache.jackrabbit.oak.cache.AbstractCacheStats; import org.apache.jackrabbit.oak.cache.api.Cache; +import org.apache.jackrabbit.oak.cache.api.CacheBuilder; +import org.apache.jackrabbit.oak.cache.impl.caffeine.CaffeineCacheAdapter; +import org.apache.jackrabbit.oak.cache.impl.lirs.LirsLoadingCacheAdapter; +import org.apache.jackrabbit.oak.commons.concurrent.ExecutorCloser; 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.persistentCache.CacheType; +import org.apache.jackrabbit.oak.plugins.document.persistentCache.CacheMetadata; import org.apache.jackrabbit.oak.plugins.document.persistentCache.PersistentCache; import org.apache.jackrabbit.oak.plugins.document.persistentCache.PersistentCacheStats; import org.apache.jackrabbit.oak.plugins.document.util.RevisionsKey; import org.apache.jackrabbit.oak.plugins.document.util.StringValue; +import org.apache.jackrabbit.oak.stats.DefaultStatisticsProvider; +import org.apache.jackrabbit.oak.stats.MeterStats; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.apache.jackrabbit.oak.stats.StatsOptions; +import org.junit.After; import org.junit.Assert; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.junit.runner.JUnitCore; +import org.junit.runner.Request; +import org.junit.runner.Result; /** * Tests for {@link DocumentNodeStoreBuilder} cache configuration. - * These assertions intentionally avoid third-party cache types so the same - * tests can run across cache implementation changes. + * Apart from the cache implementation selection tests, these assertions + * intentionally avoid third-party cache types so the same tests can run + * across cache implementation changes. */ public class DocumentNodeStoreBuilderTest { @Rule public final TemporaryFolder temp = new TemporaryFolder(new File("target")); + private boolean caffeineCacheEnabled; + + @Before + public void captureCaffeineCacheFeature() { + caffeineCacheEnabled = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.get(); + } + + @After + public void resetCaffeineCacheFeature() { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(caffeineCacheEnabled); + } + + // Enabling the feature selects Caffeine with synchronous maintenance. + @Test + public void usesCaffeineCacheWhenFeatureIsEnabled() { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(true); + Cache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .buildDocumentCache(new MemoryDocumentStore()); + + Assert.assertTrue(cache instanceof CaffeineCacheAdapter); + } + + // Disabling the Caffeine toggle restores the LIRS cache. + @Test + public void usesLirsCacheWhenCaffeineFeatureIsDisabled() { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(false); + + Cache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .buildDocumentCache(new MemoryDocumentStore()); + + Assert.assertTrue(cache instanceof LirsLoadingCacheAdapter); + } + + /** Local diffs follow the bundle's implementation selection with their original weights. */ + @Test + public void localDiffUsesTheSelectedCacheImplementation() { + for (boolean enabled : new boolean[] {false, true}) { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(enabled); + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(16 * 1024 * 1024).setPersistentCache(null) + .withWeigher((key, value) -> value.getMemory()); + Cache cache = builder.buildLocalDiffCache(); + Assert.assertEquals(enabled ? CaffeineCacheAdapter.class : LirsLoadingCacheAdapter.class, cache.getClass()); + RevisionsKey key = new RevisionsKey(new RevisionVector(new Revision(1, 0, 1)), + new RevisionVector(new Revision(2, 0, 1))); + LocalDiffCache.Diff value = new LocalDiffCache.Diff(new HashMap<>(), 1200); + cache.put(key, value); + Assert.assertSame(value, cache.getIfPresent(key)); + Assert.assertEquals(1200, cache.getUsedWeight()); + } + } + + /** Caffeine admission may discard entries under pressure but must respect the local-diff budget. */ + @Test + public void localDiffCaffeineRespectsItsMemoryBudget() { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(true); + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(16 * 1024 * 1024).setPersistentCache(null) + .withWeigher((key, value) -> value.getMemory()); + Cache cache = builder.buildLocalDiffCache(); + for (int i = 0; i < 4015; i++) { + RevisionsKey key = new RevisionsKey(new RevisionVector(new Revision(i, 0, 1)), + new RevisionVector(new Revision(i + 1, 0, 1))); + cache.put(key, new LocalDiffCache.Diff(new HashMap<>(), i < 15 ? 178_000 : 1200)); + } + Assert.assertTrue("Workload must exceed the local diff memory budget", cache.stats().evictionCount() > 0); + Assert.assertTrue(cache.getUsedWeight() <= builder.getLocalDiffCacheSize()); + } + + /** A zero local-diff budget retains the existing no-cache behavior. */ + @Test + public void zeroWeightLocalDiffCacheDoesNotUseLirs() { + for (boolean enabled : new boolean[] {false, true}) { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(enabled); + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(0).setPersistentCache(null); + Cache cache = builder.buildLocalDiffCache(); + Assert.assertTrue(cache instanceof CaffeineCacheAdapter); + cache.put(new RevisionsKey(new RevisionVector(new Revision(1, 0, 1)), + new RevisionVector(new Revision(2, 0, 1))), new LocalDiffCache.Diff(new HashMap<>(), 1200)); + Assert.assertEquals(0, cache.estimatedSize()); + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(!enabled); + Cache later = builder.memoryCacheSize(1024 * 1024).buildMemoryDiffCache(); + Assert.assertEquals(enabled ? CaffeineCacheAdapter.class : LirsLoadingCacheAdapter.class, later.getClass()); + } + } + + /** The selection test restores the incoming feature state. */ + @Test + public void cacheSelectionTestShouldRestoreIncomingToggle() { + boolean incoming = DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.get(); + try { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(false); + Result result = new JUnitCore().run(Request.method(DocumentNodeStoreBuilderTest.class, + "usesLirsCacheWhenCaffeineFeatureIsDisabled")); + Assert.assertTrue(result.getFailures().toString(), result.wasSuccessful()); + Assert.assertFalse("Selection test leaked its toggle into subsequent tests", + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.get()); + } finally { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(incoming); + } + } + + /** A builder keeps its cache implementation after a toggle change. */ + @Test + public void implementationChangesApplyOnlyToNewBuilders() { + MemoryDocumentStore documents = new MemoryDocumentStore(); + for (boolean enabled : new boolean[] {false, true}) { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(enabled); + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder(); + Cache first = builder.buildDocumentCache(documents); + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(!enabled); + Cache second = builder.buildPrevDocumentsCache(documents); + Assert.assertEquals(first.getClass(), second.getClass()); + Cache replacement = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .buildDocumentCache(documents); + Assert.assertNotEquals(first.getClass(), replacement.getClass()); + } + } + @Test public void buildNodeDocumentCacheReturnsNonNull() { // Verify the builder can construct the node-document cache with the default @@ -123,6 +261,28 @@ public void buildDocumentCacheStoresAndRetrievesDocuments() throws Exception { Assert.assertEquals(document.getModCount(), ((NodeDocument) cached).getModCount()); } + /** Opted-in document caches evict on the caller thread. */ + @Test + public void documentCacheUsesSynchronousMaintenanceWhenEnabled() { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(true); + DocumentStore store = new MemoryDocumentStore(); + Cache cache = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(100) + .memoryCacheDistribution(0, 0, 0, 0, 0) + .withWeigher((key, value) -> 100) + .buildDocumentCache(store); + + NodeDocument first = new NodeDocument(store, 1L); + NodeDocument second = new NodeDocument(store, 2L); + CacheValue firstKey = StringValue.fromString("first"); + CacheValue secondKey = StringValue.fromString("second"); + cache.put(firstKey, first); + cache.put(secondKey, second); + + Assert.assertEquals("synchronous maintenance should enforce the selected cache's limit inline", + 1, cache.estimatedSize()); + } + @Test public void buildMemoryDiffCacheCreatesPersistentCacheStats() throws Exception { String cacheDir = temp.newFolder().getAbsolutePath() + ",-async"; @@ -195,6 +355,72 @@ public void buildLocalDiffCacheCreatesPersistentCacheStats() throws Exception { } } + // Size evictions reach the asynchronous persistent-cache write queue. + @Test + public void sizeEvictionReachesAsyncPersistentCache() throws Exception { + DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE_ENABLED.set(true); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + StatisticsProvider statsProvider = new DefaultStatisticsProvider(executor); + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(1024 * 1024) + .setStatisticsProvider(statsProvider) + .setPersistentCache(temp.newFolder().getAbsolutePath() + ",+asyncDiff"); + try { + Cache cache = builder.buildMemoryDiffCache(); + MeterStats persistedPuts = statsProvider.getMeter( + "PersistentCache.NodeCache.diff.CACHE_PUT", StatsOptions.DEFAULT); + String payload = "x".repeat(4096); + for (int i = 0; i < 100; i++) { + MemoryDiffCache.Key key = new MemoryDiffCache.Key( + Path.fromString("/evict-" + i), + new RevisionVector(new Revision(1, 0, 1)), + new RevisionVector(new Revision(2, 0, 1))); + cache.put(key, StringValue.fromString(payload)); + cache.getIfPresent(key); + } + // Persistent writes run asynchronously even with synchronous Caffeine maintenance. + long deadline = System.currentTimeMillis() + 10_000; + while (persistedPuts.getCount() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + + Assert.assertTrue("Expected evicted entries to be handed to the persistent cache", + persistedPuts.getCount() > 0); + } finally { + closeCaches(builder); + new ExecutorCloser(executor).close(); + } + } + + // Zero-capacity evictions remove metadata instead of leaking it. + @Test + public void zeroWeightEvictionReachesAsyncPersistentCache() throws Exception { + DocumentNodeStoreBuilder builder = DocumentNodeStoreBuilder.newDocumentNodeStoreBuilder() + .memoryCacheSize(0) + .setPersistentCache(temp.newFolder().getAbsolutePath() + ",+asyncDiff"); + try { + Cache cache = builder.buildMemoryDiffCache(); + Field metadataField = cache.getClass().getDeclaredField("memCacheMetadata"); + metadataField.setAccessible(true); + CacheMetadata metadata = (CacheMetadata) metadataField.get(cache); + Field entriesField = CacheMetadata.class.getDeclaredField("metadataMap"); + entriesField.setAccessible(true); + Map entries = (Map) entriesField.get(metadata); + for (int i = 0; i < 100; i++) { + cache.put(new MemoryDiffCache.Key( + Path.fromString("/evict-" + i), + new RevisionVector(new Revision(1, 0, 1)), + new RevisionVector(new Revision(2, 0, 1))), StringValue.fromString("x")); + Assert.assertTrue("Eviction must remove metadata before put returns", entries.isEmpty()); + } + Assert.assertEquals(0, cache.estimatedSize()); + Assert.assertTrue(cache.asMap().isEmpty()); + } finally { + closeCaches(builder); + } + } + + private static void closeCaches(DocumentNodeStoreBuilder builder) { PersistentCache cache = builder.getPersistentCache(); if (cache != null) { diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreServiceTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreServiceTest.java index 189e70f79b7..ea9fc7cb406 100644 --- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreServiceTest.java +++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/DocumentNodeStoreServiceTest.java @@ -19,6 +19,7 @@ import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -33,6 +34,7 @@ import org.apache.jackrabbit.oak.plugins.document.spi.lease.LeaseFailureHandler; import org.apache.jackrabbit.oak.spi.state.NodeStore; import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; import org.apache.jackrabbit.oak.stats.StatisticsProvider; import org.apache.sling.testing.mock.osgi.MockOsgi; import org.apache.sling.testing.mock.osgi.junit.OsgiContext; @@ -143,6 +145,21 @@ public void journalPropertyTracker() throws Exception { assertEquals(1, store.getJournalPropertyHandlerFactory().getServiceCount()); } + // Service activation registers the disabled Caffeine opt-in. + @Test + public void caffeineCacheFeatureMatchesStartupOptIn() { + MockOsgi.setConfigForPid(context.bundleContext(), PID, newConfig(repoHome)); + MockOsgi.activate(service, context.bundleContext()); + + FeatureToggle toggle = Arrays.stream(context.getServices(FeatureToggle.class, null)) + .filter(candidate -> DocumentNodeStoreBuilder.FT_CAFFEINE_CACHE.equals(candidate.getName())) + .findFirst() + .orElse(null); + + assertNotNull(toggle); + assertEquals(Boolean.getBoolean("oak.documentMK.caffeineCache"), toggle.isEnabled()); + } + @Test public void setUpdateLimit() throws Exception { Map config = newConfig(repoHome); diff --git a/specs/oak-12425-cache-maintenance/PLAN.md b/specs/oak-12425-cache-maintenance/PLAN.md new file mode 100644 index 00000000000..40e93093e27 --- /dev/null +++ b/specs/oak-12425-cache-maintenance/PLAN.md @@ -0,0 +1,43 @@ + + +# Caffeine cache plan + +## Implementation and PR order + +1. **OAK-12436 / #3185**: shared per-cache SYNC/ASYNC maintenance selection; preserve the existing ASYNC API default and caller behavior. +2. **OAK-12438 / #3186**: Segment Tar's independent bundle-local maintenance override. Remove obsolete shared toggle fields, update the internal cache API export and rebuild consumers together. +3. **OAK-12425 / #3176**: minimal DocumentNodeStore Caffeine migration, gated by `FT_CAFFEINE_CACHE_OAK-12425`, disabled by default. Standalone processes explicitly opt in with `-Doak.documentMK.caffeineCache=true`. Every Document Caffeine cache uses SYNC maintenance. A builder samples implementation selection once; changes require a replacement builder/store. Preserve the existing NodeCache, CacheMetadata, raw values, weights, live map and persistent-write behavior. Register the eviction listener before the persistent wrapper attaches its callback. Include further production fixes only when a test demonstrates a Caffeine SYNC regression. +4. **OAK-12437 / #3190**: ASYNC-only metadata work and per-CacheType selection, based on #3176. `FT_OAK-12437`, disabled by default, gates ASYNC maintenance and the entry-owned metadata path together. Caffeine must also be enabled. Standalone ASYNC requires both startup opt-ins. Each builder snapshots feature selection once; existing caches retain their implementation and mode until restart. Unconfigured caches remain SYNC. Only persistent caches actually selected as ASYNC use CacheEntry/CacheEntries and the ASYNC NodeCache wrapper. FT off or SYNC selection uses the unchanged #3176 NodeCache/CacheMetadata path, with no entry overhead, mutation monitors, guarded write protocol or metadata map adapter from the ASYNC implementation. Retain the persistent file format and raw public values. +5. **OAK-12426 / #3174**: compare CacheLIRS, Caffeine SYNC with the original metadata path, and Caffeine ASYNC with entry-owned metadata through the production DocumentNodeStore cache. Verify effective mode and implementation; reject mismatched labels. Keep weights, workloads, persistent-write settings and JVM settings equal except for the ASYNC entry overhead that is part of its production design. Rerun results whenever these paths change. + +Publish and review the native `gh stack` in order: **#3185 → #3186 → #3176 → #3190 → #3174**. Rebase each dependent PR onto its direct parent. + +## Behavioral coverage + +- #3176: disabled feature retains CacheLIRS; explicit opt-in selects Caffeine SYNC; all cache types retain the original metadata/weight representation. Existing builders retain implementation selection across feature changes. Service registration is removed on deactivation and preserves the selected opt-in across the restart required to apply it. +- #3176: size evictions reach existing persistent-cache callbacks inline. Existing persistence, broadcast, loader, raw map, custom weigher and cache statistics tests continue passing. Check cold node loading with CacheLIRS and Caffeine SYNC before adding any concurrency fix. +- #3190: all combinations of Caffeine FT, ASYNC FT and per-cache selection, including both toggle-transition directions, replacement builders and unconfigured caches. Verify effective mode and selected persistent wrapper. +- #3190: FT off gives the #3176 wrapper, metadata behavior and exact custom weight even when ASYNC is requested. With Caffeine off, ASYNC requests resolve to SYNC and keep CacheLIRS. +- #3190: entry metadata lives with its entry; delayed callbacks refer to the exact removed entry, retired/replaced values and invalidated generations cannot persist stale data, raw map mutation preserves metadata, and weights include entry overhead only on the ASYNC path. +- #3190: ASYNC persistent wrapper covers NODE, CHILDREN, DIFF, LOCAL_DIFF and PREV_DOCUMENT; DOCUMENT is not persisted and PREV_NOPROP stays SYNC. Reuse behavior tests across both wrappers and isolate tests of ASYNC internals to the ASYNC wrapper. +- #3190: cross-cache loading and guarded persistent-write changes are confined to the ASYNC path unless a separate SYNC reproduction demonstrates necessity. Cancelled writes do not inflate successful-put or disk-usage statistics. +- #3174: fresh-JVM opt-in rejection/acceptance, workload accounting and three-mode production cache measurements. Published results describe the actual selected implementation, mode and metadata path. + +## Jira scope + +OAK-12425 contains the FT-gated SYNC migration and necessary SYNC compatibility fixes only. OAK-12437 contains the optional ASYNC mode and entry-owned metadata implementation. OAK-12438 remains Segment Tar only. OAK-12426 covers comparative measurements after the rescope. Both Document features are disabled by default and require a new builder/store after changes.