From e08d00dcac0fa861f95af608489e796b51fe9c07 Mon Sep 17 00:00:00 2001
From: Vladimir Rodionov
+ * This method exists only to preserve compatibility with legacy {@link CacheConfig} callers that
+ * still use the {@link BlockCache} field. The active cache access path remains
+ * {@link CacheAccessService}. Code that needs cache behavior or diagnostics should use
+ * {@link CacheAccessService} capabilities instead of depending on the legacy block cache field.
+ *
+ * This method is used during the block cache migration when an existing first-level cache is
+ * moved under topology-backed orchestration. Legacy combined-cache construction wires L1 to L2
+ * through a victim cache. Once L1 and L2 are represented as independent topology engines, the
+ * topology must control L2 lookup and promotion directly, so the old victim-cache delegation must
+ * be disabled.
+ *
- * For regular {@link BlockCache} implementations, this returns a legacy
- * {@link BlockCacheBackedCacheAccessService}. For {@link CombinedBlockCache}, this returns a
- * topology-backed service using {@link TieredExclusiveTopology}. This moves combined L1/L2
- * orchestration to the new topology layer while keeping the existing combined block cache object
- * available for legacy {@link BlockCache}-facing APIs.
+ * All legacy block caches are adapted through {@link TopologyBackedCacheAccessService}. Plain
+ * single-tier block caches are represented by {@link SingleTierTopology}. Exclusive combined
+ * caches are represented by {@link TieredExclusiveTopology}. Inclusive combined caches are
+ * represented by {@link TieredInclusiveTopology}.
*
+ * {@link InclusiveCombinedBlockCache} is checked before {@link CombinedBlockCache} because the
+ * inclusive variant has different residency, promotion, and eviction semantics. Routing it
+ * through the exclusive topology would be incorrect.
+ *
@@ -140,15 +152,6 @@ public static CacheAccessService disabled() {
* @return optional iterable cached-block view
* @throws NullPointerException if {@code cacheAccessService} is {@code null}
*/
- @SuppressWarnings("unchecked")
- // public static Optional
+ * The current size may include cache metadata, allocator overhead, index structures, or other
+ * implementation-specific memory that is not part of the cached block payload. This value is
+ * intentionally distinct from {@link #getCurrentDataSize()}, which reports only cached data size.
+ *
+ * This tier is used by {@link SingleTierTopology}. It represents a topology with one active cache
+ * engine without assigning L1 or L2 semantics to that engine. A single-tier cache may be backed
+ * by an in-memory cache, a bucket cache, or another cache engine.
+ *
+ * Single-tier topology has only one active cache engine and therefore selects
+ * {@link CacheTier#SINGLE}. Tiered inclusive topology writes to both L1 and L2 because inclusive
+ * residency allows the same block to be present in multiple tiers. Tiered exclusive topology
+ * keeps the legacy combined-cache placement behavior where data blocks and non-data blocks may be
+ * placed in different tiers.
+ *
- * This topology wraps a single {@link CacheEngine}. It is primarily useful as a baseline topology
- * and as a simple bridge for cache configurations that do not use L1/L2 tiering.
- *
+ * A single-tier topology contains exactly one cache engine. It is used to represent legacy
+ * single-tier {@code BlockCache} implementations inside the topology-backed cache access framework.
+ * Unlike tiered topologies, this topology does not perform tier orchestration, promotion, or
+ * demotion. All cache operations are directed to the single L1 engine.
+ *
+ * This topology allows plain block caches to use the same {@link TopologyBackedCacheAccessService}
+ * path as combined caches while preserving the existing cache implementation underneath.
+ *
+ * A single-tier topology exposes its only engine through {@link CacheTier#SINGLE}. This avoids
+ * assigning L1 or L2 semantics to a cache configuration that has only one active engine.
+ *
+ * The only valid tier for this topology is {@link CacheTier#SINGLE}. The single tier does not
+ * imply L1 or L2 behavior; it only means that the topology has one active cache engine.
+ *
+ * Single-tier topology has no higher or lower tier, so promotion is not supported. The method
+ * returns {@code false} without modifying the cache.
+ *
+ * Single-tier topology has no lower tier, so demotion is not supported. The method returns
+ * {@code false} without modifying the cache.
+ *
- * This class is the topology-backed counterpart to {@link BlockCacheBackedCacheAccessService}. The - * block-cache-backed implementation is useful for incremental migration with no behavior change. - * This implementation is useful once callers are ready to exercise the new topology and engine - * abstractions directly through {@link CacheAccessService}. - *
- ** Representation selection is intentionally not invoked by this initial implementation. Until the * service can actually apply representation decisions safely, especially around HFileBlock * lifecycle and packed/unpacked storage, representation policy is left to a later integration step. *
*/ @InterfaceAudience.Private -public class TopologyBackedCacheAccessService implements CacheAccessService { +public class TopologyBackedCacheAccessService implements CacheAccessService, Iterable- * This method first asks the configured policy whether the block should be admitted. If admitted, - * the policy selects the target tier or tiers. The block is then inserted into each selected - * engine using {@link CacheEngine#cacheBlock(BlockCacheKey, Cacheable, boolean, boolean)}. + * Single-tier topology preserves the legacy direct block-cache behavior by writing admitted + * blocks to the only backing engine. Tiered topologies use the placement policy to select one or + * more target tiers. *
+ * @param cacheKey cache key identifying the block + * @param block block to cache + * @param context cache write context + * @throws NullPointerException if {@code cacheKey}, {@code block}, or {@code context} is + * {@code null} + */ + @Override + public void cacheBlock(BlockCacheKey cacheKey, Cacheable block, CacheWriteContext context) { + Objects.requireNonNull(cacheKey, "cacheKey must not be null"); + Objects.requireNonNull(block, "block must not be null"); + Objects.requireNonNull(context, "context must not be null"); + + if (topology.getType() == CacheTopologyType.SINGLE_TIER) { + cacheBlockToSingleTier(cacheKey, block, context); + return; + } + + cacheBlockToSelectedTiers(cacheKey, block, context); + } + + /** + * Caches a block into the only engine in a single-tier topology. *- * The policy's representation decision is intentionally not applied in this initial - * implementation. The current block object is passed through unchanged. + * This method preserves the behavior of the legacy {@link BlockCacheBackedCacheAccessService} + * path. A single-tier topology has no placement decision to make: admitted blocks are written to + * the only available engine, which is exposed as {@link CacheTier#SINGLE}. *
- * @param cacheKey block cache key - * @param block block contents + * @param cacheKey cache key identifying the block + * @param block block to cache * @param context cache write context + * @throws NullPointerException if {@code cacheKey}, {@code block}, or {@code context} is + * {@code null} */ + private void cacheBlockToSingleTier(BlockCacheKey cacheKey, Cacheable block, + CacheWriteContext context) { + Objects.requireNonNull(cacheKey, "cacheKey must not be null"); + Objects.requireNonNull(block, "block must not be null"); + Objects.requireNonNull(context, "context must not be null"); - @Override - public void cacheBlock(BlockCacheKey cacheKey, Cacheable block, CacheWriteContext context) { + AdmissionDecision admission = + policy.shouldAdmit(cacheKey, block, context, AdmissionPriority.NORMAL, topologyView); + if (!admission.isAdmitted()) { + return; + } + + Optional+ * This method is used for tiered topologies where the placement policy decides whether the block + * belongs in L1, L2, or multiple tiers. + *
+ * @param cacheKey cache key identifying the block + * @param block block to cache + * @param context cache write context + * @throws NullPointerException if {@code cacheKey}, {@code block}, or {@code context} is + * {@code null} + */ + private void cacheBlockToSelectedTiers(BlockCacheKey cacheKey, Cacheable block, + CacheWriteContext context) { Objects.requireNonNull(cacheKey, "cacheKey must not be null"); Objects.requireNonNull(block, "block must not be null"); Objects.requireNonNull(context, "context must not be null"); @@ -386,12 +437,23 @@ public long size() { } /** - * Returns aggregate occupied size of the block cache, in bytes. - * @return occupied space in cache, in bytes + * Returns the current total size of all cache engines in this topology. + *+ * This method aggregates {@link CacheEngine#getCurrentSize()} rather than + * {@link CacheEngine#getCurrentDataSize()} so topology-backed diagnostics preserve legacy + * {@link BlockCache#getCurrentSize()} semantics. For cache implementations such as + * {@code LruBlockCache}, current size may include metadata and cache overhead in addition to + * cached data bytes. + *
+ * @return aggregate current total cache size in bytes */ @Override public long getCurrentSize() { - return getCurrentDataSize(); + long currentSize = 0L; + for (CacheEngine engine : topology.getEngines()) { + currentSize += engine.getCurrentSize(); + } + return currentSize; } /** @@ -626,6 +688,22 @@ public void notifyFileCachingCompleted(Path path, int blockCount, int dataBlockC } } + /** + * Returns an iterable view over cached blocks exposed by the cache engines in this topology. + *+ * The returned iterable aggregates cached-block iterables from all engines that support this + * diagnostic capability. Engines that do not expose cached-block iteration are skipped. If no + * engine supports cached-block iteration, this method returns {@link Optional#empty()}. + *
+ *+ * The aggregate iterable uses each underlying iterable's {@link Iterable#iterator()} method + * instead of {@link Iterable#spliterator()}. This is intentional because some legacy cache + * implementations and Mockito-based test doubles expose iteration through {@code iterator()} but + * may not provide a usable {@code spliterator()}. + *
+ * @return an aggregated cached-block iterable when at least one engine supports this capability; + * otherwise {@link Optional#empty()} + */ public Optional+ * The inclusive combined cache must expose exactly two non-null legacy block caches. The first + * cache is adapted as L1 using a non-victim-delegating engine, and the second cache is adapted as + * L2. This prevents L1 misses from internally consulting L2 through the legacy victim-cache + * mechanism and lets the topology-backed service control tier lookup and promotion policy. + *
+ * @param combinedBlockCache inclusive combined block cache to adapt + * @return topology-backed cache access service using a tiered inclusive topology + * @throws NullPointerException if {@code combinedBlockCache} is {@code null} + * @throws IllegalArgumentException if the combined cache does not expose exactly two non-null + * block caches + */ + public static TopologyBackedCacheAccessService + fromInclusiveCombinedBlockCache(InclusiveCombinedBlockCache combinedBlockCache) { + Objects.requireNonNull(combinedBlockCache, "combinedBlockCache must not be null"); + + BlockCache[] blockCaches = combinedBlockCache.getBlockCaches(); + if (blockCaches == null || blockCaches.length != 2) { + throw new IllegalArgumentException( + "InclusiveCombinedBlockCache must expose exactly two block caches"); + } + if (blockCaches[0] == null || blockCaches[1] == null) { + throw new IllegalArgumentException( + "InclusiveCombinedBlockCache must expose non-null L1 and L2 block caches"); + } + + return fromTieredInclusiveBlockCaches("inclusive-combined", blockCaches[0], blockCaches[1], + DefaultHBaseCachePlacementAdmissionPolicy.INSTANCE); + } + + /** + * Creates a topology-backed cache access service from two legacy block caches using an inclusive + * tiered topology. *- * The topology-backed service owns lookup and placement orchestration, but existing - * {@link FirstLevelBlockCache} implementations still use a direct victim-cache reference to move - * evicted blocks from L1 to L2. Keep this wiring while L1 and L2 are still legacy - * {@link BlockCache} implementations. + * The first supplied block cache is treated as the L1 tier and the second supplied block cache is + * treated as the L2 tier. Both legacy caches are adapted to {@link CacheEngine} instances using + * {@link CacheEngines#fromBlockCache(BlockCache)} and then assembled into a + * {@link TieredInclusiveTopology}. *
- * @param l1 first-level block cache - * @param l2 second-level block cache + *+ * This helper is intended for compatibility with legacy inclusive combined-cache configurations + * while moving cache access and diagnostics to the {@link CacheAccessService} abstraction. + * Inclusive topology semantics differ from exclusive topology semantics: a block may exist in + * both tiers, and eviction from one tier does not necessarily imply eviction from the other tier. + *
+ * @param name topology name used for diagnostics + * @param l1 first-level block cache + * @param l2 second-level block cache + * @param policy cache placement and admission policy to use with the topology-backed service + * @return topology-backed cache access service backed by a tiered inclusive topology + * @throws NullPointerException if {@code name}, {@code l1}, {@code l2}, or {@code policy} is + * {@code null} */ - private static void wireVictimCache(BlockCache l1, BlockCache l2) { + public static TopologyBackedCacheAccessService fromTieredInclusiveBlockCaches(String name, + BlockCache l1, BlockCache l2, CachePlacementAdmissionPolicy policy) { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(l1, "l1 must not be null"); + Objects.requireNonNull(l2, "l2 must not be null"); + Objects.requireNonNull(policy, "policy must not be null"); if (l1 instanceof FirstLevelBlockCache) { - try { - ((FirstLevelBlockCache) l1).setVictimCache(l2); - } catch (IllegalArgumentException e) { - // ignore if already wired - } + ((FirstLevelBlockCache) l1).unsetVictimCache(); + } + CacheEngine l1Engine = CacheEngines.fromBlockCache(l1); + CacheEngine l2Engine = CacheEngines.fromBlockCache(l2); + CacheTopology topology = new TieredInclusiveTopology(name, l1Engine, l2Engine); + + return new TopologyBackedCacheAccessService(topology, policy); + } + + /** + * Creates a topology-backed cache access service for a single legacy {@link BlockCache}. + *+ * The supplied block cache is adapted to a {@link CacheEngine} and placed behind a + * {@link SingleTierTopology}. This makes single-tier caches use the same + * {@link TopologyBackedCacheAccessService} path as combined caches while preserving the existing + * block cache implementation underneath. + *
+ * @param name topology name used for diagnostics + * @param blockCache legacy block cache to adapt + * @param policy cache placement and admission policy + * @return topology-backed cache access service backed by a single-tier topology + * @throws NullPointerException if {@code name}, {@code blockCache}, or {@code policy} is + * {@code null} + */ + public static TopologyBackedCacheAccessService fromSingleBlockCache(String name, + BlockCache blockCache, CachePlacementAdmissionPolicy policy) { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(blockCache, "blockCache must not be null"); + Objects.requireNonNull(policy, "policy must not be null"); + + CacheEngine engine = CacheEngines.fromBlockCache(blockCache); + CacheTopology topology = new SingleTierTopology(name, engine); + return new TopologyBackedCacheAccessService(topology, policy); + } + + /** + * Returns the legacy {@link BlockCache} wrapped by the cache engine for the requested tier. + *+ * This helper is intended for tests that need to verify compatibility with legacy block cache + * implementations during the migration to topology-backed cache access. Production code should + * prefer {@link CacheAccessService} capability methods instead of unwrapping the underlying + * {@link BlockCache}. + *
+ *+ * The supplied service must be a {@link TopologyBackedCacheAccessService}. The requested tier + * must resolve to a {@link BlockCacheBackedCacheEngine}. If either condition is not true, this + * method fails fast with an {@link IllegalArgumentException}. + *
+ * @param cacheAccessService cache access service to inspect + * @param tier cache tier to unwrap + * @return legacy block cache wrapped by the cache engine for the requested tier + * @throws NullPointerException if {@code cacheAccessService} or {@code tier} is {@code null} + * @throws IllegalArgumentException if the service is not topology-backed, if the requested tier + * is not present, or if the tier is not backed by a + * {@link BlockCacheBackedCacheEngine} + */ + public static BlockCache getBlockCache(CacheAccessService cacheAccessService, CacheTier tier) { + Objects.requireNonNull(cacheAccessService, "cacheAccessService must not be null"); + Objects.requireNonNull(tier, "tier must not be null"); + + if (!(cacheAccessService instanceof TopologyBackedCacheAccessService)) { + throw new IllegalArgumentException( + "cacheAccessService must be a TopologyBackedCacheAccessService"); + } + + TopologyBackedCacheAccessService topologyBackedService = + (TopologyBackedCacheAccessService) cacheAccessService; + CacheTopology topology = topologyBackedService.getTopology(); + + CacheEngine engine = topology.getEngine(tier) + .orElseThrow(() -> new IllegalArgumentException("No cache engine found for tier " + tier)); + + if (!(engine instanceof BlockCacheBackedCacheEngine)) { + throw new IllegalArgumentException( + "Cache engine for tier " + tier + " must be a BlockCacheBackedCacheEngine"); + } + + return ((BlockCacheBackedCacheEngine) engine).getBlockCache(); + } + + /** + * Returns the legacy {@link BlockCache} wrapped by a single-tier topology-backed cache access + * service. + *+ * Single-tier topology exposes its only engine through {@link CacheTier#SINGLE}. The only active + * engine is not assumed to be L1 or L2 because a single-tier configuration may be backed by + * different concrete cache implementations, including bucket cache. + *
+ * @param cacheAccessService cache access service to inspect + * @return legacy block cache wrapped by the single-tier cache engine + * @throws NullPointerException if {@code cacheAccessService} is {@code null} + * @throws IllegalArgumentException if the supplied service is not a topology-backed single-tier + * cache service or if the single tier is not backed by a + * {@link BlockCacheBackedCacheEngine} + */ + public static BlockCache getBlockCache(CacheAccessService cacheAccessService) { + Objects.requireNonNull(cacheAccessService, "cacheAccessService must not be null"); + + if (!(cacheAccessService instanceof TopologyBackedCacheAccessService)) { + throw new IllegalArgumentException( + "cacheAccessService must be a TopologyBackedCacheAccessService"); } + + TopologyBackedCacheAccessService topologyBackedService = + (TopologyBackedCacheAccessService) cacheAccessService; + CacheTopology topology = topologyBackedService.getTopology(); + + if (topology.getType() != CacheTopologyType.SINGLE_TIER) { + throw new IllegalArgumentException( + "cacheAccessService must be backed by a single-tier topology"); + } + + return getBlockCache(cacheAccessService, CacheTier.SINGLE); } } diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheConfig.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheConfig.java index 1d7286dbe6c6..2aa5a8d69912 100644 --- a/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheConfig.java +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheConfig.java @@ -43,10 +43,11 @@ import org.apache.hadoop.hbase.io.ByteBuffAllocator; import org.apache.hadoop.hbase.io.hfile.BlockType.BlockCategory; import org.apache.hadoop.hbase.io.hfile.bucket.BucketCache; -import org.apache.hadoop.hbase.io.hfile.cache.BlockCacheBackedCacheAccessService; import org.apache.hadoop.hbase.io.hfile.cache.CacheAccessService; import org.apache.hadoop.hbase.io.hfile.cache.CacheAccessServiceTestFactory; import org.apache.hadoop.hbase.io.hfile.cache.NoOpCacheAccessService; +import org.apache.hadoop.hbase.io.hfile.cache.TopologyBackedCacheAccessService; +import org.apache.hadoop.hbase.io.hfile.cache.TopologyBackedCacheAccessServices; import org.apache.hadoop.hbase.io.util.MemorySizeUtil; import org.apache.hadoop.hbase.nio.ByteBuff; import org.apache.hadoop.hbase.testclassification.IOTests; @@ -314,7 +315,6 @@ private void doBucketCacheConfigTest() { // TODO: Assert sizes allocated are right and proportions. LruBlockCache lbc = (LruBlockCache) CacheAccessServiceTestFactory.getFirstLevelBlockCache(service); - ; assertEquals(MemorySizeUtil.getOnHeapCacheSize(this.conf), lbc.getMaxSize()); BucketCache bc = (BucketCache) CacheAccessServiceTestFactory.getSecondLevelBlockCache(service); // getMaxSize comes back in bytes but we specified size in MB @@ -381,19 +381,23 @@ public void testBucketCacheConfigL1L2Setup() throws Exception { BlockCache bc = CacheAccessServiceTestFactory.getSecondLevelBlockCache(service); // getMaxSize comes back in bytes but we specified size in MB assertEquals(bcExpectedSize, ((BucketCache) bc).getMaxSize()); - // Test the L1+L2 deploy works as we'd expect with blocks evicted from L1 going to L2. + /* + * The topology-backed cache path intentionally clears legacy L1 victim-cache wiring when L1 and + * L2 are adapted as independent topology engines. Direct calls to the unwrapped L1 cache should + * therefore not be used to verify L1-to-L2 victim movement. Tier placement, promotion, and + * lookup are now owned by TopologyBackedCacheAccessService. + */ long initialL1BlockCount = lbc.getBlockCount(); long initialL2BlockCount = bc.getBlockCount(); Cacheable c = new DataCacheEntry(); BlockCacheKey bck = new BlockCacheKey("bck", 0); + lbc.cacheBlock(bck, c, false); + assertEquals(initialL1BlockCount + 1, lbc.getBlockCount()); assertEquals(initialL2BlockCount, bc.getBlockCount()); - assertNotNull(lbc.getBlock(bck, true, false, true)); assertNull(bc.getBlock(bck, true, false, true)); - waitForAnyBlockToMoveFromL1ToL2(lbc, bc, initialL2BlockCount); - assertTrue(bc.getBlockCount() > initialL2BlockCount); } /** @@ -491,8 +495,8 @@ void testCacheAccessServiceBackedByBlockCacheWhenBlockCacheIsConfigured() { CacheAccessService service = cacheConfig.getCacheAccessService(); - assertInstanceOf(BlockCacheBackedCacheAccessService.class, service); - assertSame(blockCache, ((BlockCacheBackedCacheAccessService) service).getBlockCache()); + assertInstanceOf(TopologyBackedCacheAccessService.class, service); + assertSame(blockCache, TopologyBackedCacheAccessServices.getBlockCache(service)); } @Test diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheOnWrite.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheOnWrite.java index a016cbef03ab..75a03d425042 100644 --- a/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheOnWrite.java +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/TestCacheOnWrite.java @@ -50,10 +50,10 @@ import org.apache.hadoop.hbase.io.compress.Compression; import org.apache.hadoop.hbase.io.encoding.DataBlockEncoding; import org.apache.hadoop.hbase.io.hfile.bucket.BucketCache; -import org.apache.hadoop.hbase.io.hfile.cache.BlockCacheBackedCacheAccessService; import org.apache.hadoop.hbase.io.hfile.cache.CacheAccessService; import org.apache.hadoop.hbase.io.hfile.cache.CacheAccessServiceTestFactory; import org.apache.hadoop.hbase.io.hfile.cache.CacheAccessServices; +import org.apache.hadoop.hbase.io.hfile.cache.TopologyBackedCacheAccessServices; import org.apache.hadoop.hbase.regionserver.BloomType; import org.apache.hadoop.hbase.regionserver.HRegion; import org.apache.hadoop.hbase.regionserver.StoreFileWriter; @@ -202,7 +202,7 @@ public static Stream- * Only {@link BlockCacheBackedCacheAccessService} is supported. Services backed by future - * topology/cache-engine implementations are not required to expose a legacy {@link BlockCache}. - * Tests that use this method should therefore be treated as compatibility tests, not as tests of - * the final pluggable-cache architecture. - *
* @param cacheAccessService cache access service * @return backing legacy block cache * @throws NullPointerException if {@code cacheAccessService} is {@code null} @@ -663,11 +657,7 @@ public static CacheAccessServiceTestInstance+ * Current size includes implementation-specific overhead and is distinct from current data size. + * The topology-backed service should therefore aggregate {@link CacheEngine#getCurrentSize()} + * from all engines rather than using data-size counters. + *
+ */ + @Test + void testCurrentSizeAggregatesAllTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + + when(l1.getCurrentSize()).thenReturn(100L); + when(l2.getCurrentSize()).thenReturn(200L); + when(l1.getCurrentDataSize()).thenReturn(10L); + when(l2.getCurrentDataSize()).thenReturn(20L); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + assertEquals(300L, service.getCurrentSize()); + assertEquals(30L, service.getCurrentDataSize()); + } + + /** + * Verifies that tiered topology construction disables legacy L1 victim-cache delegation. + *+ * Legacy combined-cache construction wires the first-level cache to the second-level cache + * through a victim cache. Once the caches are adapted as independent topology engines, the + * topology-backed service must control L2 lookup directly. The factory therefore removes the + * legacy victim-cache wiring before adapting L1. + *
+ */ + @Test + void testTieredExclusiveFactoryUnsetsL1VictimCache() { + FirstLevelBlockCache l1 = mock(FirstLevelBlockCache.class); + BlockCache l2 = mock(BlockCache.class); + + TopologyBackedCacheAccessServices.fromTieredExclusiveBlockCaches("combined", l1, l2, + noPromotionPolicy()); + + verify(l1).unsetVictimCache(); + } + private static TopologyBackedCacheAccessService service(BlockCache l1, BlockCache l2, CachePlacementAdmissionPolicy policy) { return TopologyBackedCacheAccessServices.fromTieredExclusiveBlockCaches("combined", l1, l2, diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/cache/TestInclusiveCombinedBlockCacheCompatibleTopologyBackedCacheAccessService.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/cache/TestInclusiveCombinedBlockCacheCompatibleTopologyBackedCacheAccessService.java new file mode 100644 index 000000000000..a0a5185128a8 --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/io/hfile/cache/TestInclusiveCombinedBlockCacheCompatibleTopologyBackedCacheAccessService.java @@ -0,0 +1,433 @@ +/* + * 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.hadoop.hbase.io.hfile.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.apache.hadoop.hbase.io.hfile.BlockCache; +import org.apache.hadoop.hbase.io.hfile.BlockCacheKey; +import org.apache.hadoop.hbase.io.hfile.Cacheable; +import org.apache.hadoop.hbase.io.hfile.CachedBlock; +import org.apache.hadoop.hbase.io.hfile.FirstLevelBlockCache; +import org.apache.hadoop.hbase.io.hfile.InclusiveCombinedBlockCache; +import org.apache.hadoop.hbase.testclassification.IOTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag(IOTests.TAG) +@Tag(SmallTests.TAG) +public class TestInclusiveCombinedBlockCacheCompatibleTopologyBackedCacheAccessService { + + /** + * Verifies that {@link InclusiveCombinedBlockCache} is routed to a topology-backed cache access + * service using {@link CacheTopologyType#TIERED_INCLUSIVE}. + *+ * This protects against accidentally routing {@link InclusiveCombinedBlockCache} through the + * exclusive combined-cache topology path. Inclusive and exclusive combined caches have different + * residency, promotion, and eviction semantics, so they must be represented by different topology + * types. + *
+ */ + @Test + void testInclusiveCombinedBlockCacheUsesTieredInclusiveTopology() { + InclusiveCombinedBlockCache combinedBlockCache = mock(InclusiveCombinedBlockCache.class); + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + + when(combinedBlockCache.getBlockCaches()).thenReturn(new BlockCache[] { l1, l2 }); + + CacheAccessService service = CacheAccessServices.fromBlockCache(combinedBlockCache); + + assertTrue(service instanceof TopologyBackedCacheAccessService); + TopologyBackedCacheAccessService topologyBackedService = + (TopologyBackedCacheAccessService) service; + assertEquals(CacheTopologyType.TIERED_INCLUSIVE, topologyBackedService.getTopology().getType()); + } + + /** + * Verifies that inclusive topology lookup checks L1 first and returns the L1 block when it is + * present. + *+ * Inclusive caches prefer the first-level cache for reads. If a block is found in L1, the + * topology-backed service should return it without consulting L2. + *
+ */ + @Test + void testInclusiveL1HitReturnsBlockWithoutCheckingL2() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + when(l1.getBlock(key, true, false, true)).thenReturn(block); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + assertSame(block, service.getBlock(key, requestContext())); + + verify(l1).getBlock(key, true, false, true); + verify(l2, never()).getBlock(any(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + /** + * Verifies that inclusive topology lookup checks L2 after an L1 miss. + *+ * Unlike the exclusive combined-cache compatibility path, inclusive lookup does not need the L1 + * membership shortcut. A normal ordered tier scan is appropriate: L1 is checked first, and L2 is + * checked only if L1 does not contain the block. + *
+ */ + @Test + void testInclusiveL2HitReturnsBlockAfterL1Miss() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + when(l1.getBlock(key, true, false, true)).thenReturn(null); + when(l2.getBlock(key, true, false, true)).thenReturn(block); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + assertSame(block, service.getBlock(key, requestContext())); + + verify(l1).getBlock(key, true, false, true); + verify(l2).getBlock(key, true, false, true); + } + + /** + * Verifies that promotion in an inclusive topology copies a block to L1 without evicting it from + * L2. + *+ * This is the key semantic difference from the exclusive topology. In an exclusive topology, + * promotion moves the block from L2 to L1 and removes the L2 copy. In an inclusive topology, the + * block may remain resident in both tiers. + *
+ */ + @Test + void testInclusivePromotionCopiesBlockToL1WithoutEvictingL2() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + when(l1.getBlock(key, true, false, true)).thenReturn(null); + when(l2.getBlock(key, true, false, true)).thenReturn(block); + + TopologyBackedCacheAccessService service = service(l1, l2, promoteL2HitToL1Policy()); + + assertSame(block, service.getBlock(key, requestContext())); + + verify(l1).getBlock(key, true, false, true); + verify(l2).getBlock(key, true, false, true); + verify(l1).cacheBlock(key, block); + verify(l2, never()).evictBlock(key); + } + + /** + * Verifies that service-level eviction for an inclusive topology evicts from all tiers. + *+ * An inclusive cache may contain the same block in both L1 and L2. Evicting only the first + * matching tier could leave another resident copy behind, so the topology-backed service must ask + * both tiers to evict the key. + *
+ */ + @Test + void testInclusiveEvictBlockEvictsFromBothTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + + when(l1.evictBlock(key)).thenReturn(true); + when(l2.evictBlock(key)).thenReturn(true); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + assertTrue(service.evictBlock(key)); + + verify(l1).evictBlock(key); + verify(l2).evictBlock(key); + } + + /** + * Verifies that an inclusive topology can cache a block into both tiers when the placement policy + * selects both L1 and L2. + *+ * This covers the inclusive cache residency model where the same block can be intentionally + * present in multiple tiers. + *
+ */ + @Test + void testInclusiveCacheBlockToBothTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + TopologyBackedCacheAccessService service = + service(l1, l2, admitToTiersPolicy(CacheTier.L1, CacheTier.L2)); + + service.cacheBlock(key, block, writeContext()); + + verify(l1).cacheBlock(key, block, false, false); + verify(l2).cacheBlock(key, block, false, false); + } + + /** + * Verifies that cached-block iteration is aggregated across both tiers for inclusive topology. + *+ * Diagnostic code and compatibility tests use + * {@link CacheAccessServices#asCachedBlockIterable(CacheAccessService)} to enumerate cached + * blocks through the active cache access service. Since an inclusive topology has multiple + * backing engines, the service must expose cached blocks from both L1 and L2. + *
+ */ + @Test + void testInclusiveCachedBlockIterableAggregatesBothTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + CachedBlock l1Block = mock(CachedBlock.class); + CachedBlock l2Block = mock(CachedBlock.class); + + when(l1.iterator()).thenReturn(Arrays.asList(l1Block).iterator()); + when(l2.iterator()).thenReturn(Arrays.asList(l2Block).iterator()); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + Optional+ * The topology-backed service owns the topology-level lifecycle. For a two-tier inclusive + * topology, shutdown should be delegated to both L1 and L2 engines. + *
+ */ + @Test + void testInclusiveShutdownShutsDownBothTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + + TopologyBackedCacheAccessService service = service(l1, l2, noPromotionPolicy()); + + service.shutdown(); + + verify(l1).shutdown(); + verify(l2).shutdown(); + } + + /** + * Verifies that a real {@link InclusiveCombinedBlockCache} is adapted so topology-backed lookup + * controls L1 and L2 access explicitly. + *+ * The legacy inclusive combined-cache constructor wires the first-level cache to the second-level + * cache through the victim-cache path. When the cache is adapted to the topology-backed model, + * that legacy victim wiring must be removed so an L1 miss does not internally delegate to L2. + *
+ *+ * After the wiring is removed, the topology-backed service should perform a normal inclusive + * lookup: query L1 first, observe the miss, then query L2 explicitly. + *
+ */ + @Test + void testRealInclusiveCombinedCacheDoesNotDelegateL1MissThroughVictimCache() { + FirstLevelBlockCache l1 = mock(FirstLevelBlockCache.class); + BlockCache l2 = mock(BlockCache.class); + InclusiveCombinedBlockCache combinedBlockCache = new InclusiveCombinedBlockCache(l1, l2); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + when(l1.getBlock(key, true, false, true)).thenReturn(null); + when(l2.getBlock(key, true, false, true)).thenReturn(block); + + CacheAccessService service = CacheAccessServices.fromBlockCache(combinedBlockCache); + + assertSame(block, service.getBlock(key, requestContext())); + + verify(l1).unsetVictimCache(); + verify(l1).getBlock(key, true, false, true); + verify(l2).getBlock(key, true, false, true); + } + + /** + * Verifies that the default placement policy writes inclusive topology blocks to both tiers. + *+ * Inclusive topology allows the same block to be resident in L1 and L2, so default placement + * should not split blocks by data or metadata type the way the exclusive combined-cache path + * does. + *
+ */ + @Test + void testDefaultPolicyWritesInclusiveBlocksToBothTiers() { + BlockCache l1 = mock(BlockCache.class); + BlockCache l2 = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + TopologyBackedCacheAccessService service = + service(l1, l2, DefaultHBaseCachePlacementAdmissionPolicy.INSTANCE); + + service.cacheBlock(key, block, writeContext()); + + verify(l1).cacheBlock(key, block, false, false); + verify(l2).cacheBlock(key, block, false, false); + } + + /** + * Verifies that inclusive topology construction disables legacy L1 victim-cache delegation. + *+ * Inclusive combined-cache construction may wire L1 to L2 before the cache is adapted to the + * topology-backed model. The topology-backed inclusive service needs L1 and L2 as independent + * engines so L2 lookup and promotion policy are applied by the topology. + *
+ */ + @Test + void testTieredInclusiveFactoryUnsetsL1VictimCache() { + FirstLevelBlockCache l1 = mock(FirstLevelBlockCache.class); + BlockCache l2 = mock(BlockCache.class); + + TopologyBackedCacheAccessServices.fromTieredInclusiveBlockCaches("inclusive-combined", l1, l2, + noPromotionPolicy()); + + verify(l1).unsetVictimCache(); + } + + /** + * Creates a topology-backed cache access service using a tiered inclusive topology. + * @param l1 first-level block cache + * @param l2 second-level block cache + * @param policy cache placement and admission policy + * @return topology-backed cache access service using the inclusive topology path + */ + private static TopologyBackedCacheAccessService service(BlockCache l1, BlockCache l2, + CachePlacementAdmissionPolicy policy) { + return TopologyBackedCacheAccessServices.fromTieredInclusiveBlockCaches("inclusive-combined", + l1, l2, policy); + } + + /** + * Creates a cache request context used by read-path tests. + *+ * The returned context enables caching, marks the request as non-repeat, and asks the cache to + * update cache metrics. These values match the read-path behavior covered by the topology-backed + * cache access service tests. + *
+ * @return cache request context for read-path tests + */ + private static CacheRequestContext requestContext() { + return CacheRequestContext.newBuilder().withCaching(true).withRepeat(false) + .withUpdateCacheMetrics(true).build(); + } + + /** + * Creates a cache write context used by cache population tests. + *+ * The returned context uses the default non-in-memory and non-blocking write behavior expected by + * the existing compatibility tests. + *
+ * @return cache write context for cache population tests + */ + private static CacheWriteContext writeContext() { + return CacheWriteContext.newBuilder().withInMemory(false).withWaitWhenCache(false).build(); + } + + /** + * Creates a placement policy that never promotes a block after a cache hit. + *+ * This policy is useful for lookup tests that need to verify only the lookup order and returned + * block without introducing promotion side effects. + *
+ * @return cache placement and admission policy that disables promotion + */ + private static CachePlacementAdmissionPolicy noPromotionPolicy() { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldPromote(any(), any(), any(), any(), any())) + .thenReturn(PromotionDecision.none()); + return policy; + } + + /** + * Creates a placement policy that promotes an L2 hit to L1. + *+ * The policy returns no promotion for L1 hits and requests promotion to L1 for L2 hits. In an + * inclusive topology, this should copy the block into L1 without evicting it from L2. + *
+ * @return cache placement and admission policy that promotes L2 hits to L1 + */ + private static CachePlacementAdmissionPolicy promoteL2HitToL1Policy() { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldPromote(any(), any(), eq(CacheTier.L1), any(), any())) + .thenReturn(PromotionDecision.none()); + when(policy.shouldPromote(any(), any(), eq(CacheTier.L2), any(), any())) + .thenReturn(PromotionDecision.promoteTo(CacheTier.L1, false)); + return policy; + } + + /** + * Creates a placement policy that admits writes to the supplied tiers. + *+ * The returned policy admits every block and returns a multi-tier placement decision containing + * the tiers supplied by the caller. + *
+ * @param tiers cache tiers selected by the policy + * @return cache placement and admission policy that writes to the supplied tiers + */ + private static CachePlacementAdmissionPolicy admitToTiersPolicy(CacheTier... tiers) { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldAdmit(any(), any(), any(), any(), any())) + .thenReturn(AdmissionDecision.admit()); + when(policy.selectTier(any(), any(), any(), any())) + .thenReturn(TierDecision.multiple(Arrays.asList(tiers))); + return policy; + } + + /** + * Copies an iterable of cached blocks into a list. + *+ * The helper makes cached-block iterable assertions deterministic and easy to compare with the + * expected tier order. + *
+ * @param iterable cached-block iterable to copy + * @return list containing all cached blocks produced by the iterable + */ + private static List+ * HBASE-30329 routes legacy block caches through {@link TopologyBackedCacheAccessService}. A + * plain non-combined block cache should be represented by {@link SingleTierTopology}, not by the + * old {@link BlockCacheBackedCacheAccessService} runtime path. + *
+ */ + @Test + void testSingleTierBlockCacheUsesTopologyBackedAccessService() { + BlockCache blockCache = mock(BlockCache.class); + + CacheAccessService service = CacheAccessServices.fromBlockCache(blockCache); + + assertTrue(service instanceof TopologyBackedCacheAccessService); + TopologyBackedCacheAccessService topologyBackedService = + (TopologyBackedCacheAccessService) service; + assertEquals(CacheTopologyType.SINGLE_TIER, topologyBackedService.getTopology().getType()); + } + + /** + * Verifies that a single-tier topology exposes the expected topology metadata. + *+ * The topology should contain exactly one engine, expose only the SINGLE tier, and return the + * same engine for {@link CacheTier#SINGLE}. Other tiers should not resolve to an engine. + *
+ */ + @Test + void testSingleTierTopologyMetadata() { + CacheEngine engine = mock(CacheEngine.class); + SingleTierTopology topology = new SingleTierTopology("single", engine); + + assertEquals("single", topology.getName()); + assertEquals(CacheTopologyType.SINGLE_TIER, topology.getType()); + assertEquals(Arrays.asList(engine), topology.getEngines()); + assertEquals(Arrays.asList(CacheTier.SINGLE), topology.getTiers()); + assertSame(engine, topology.getEngine(CacheTier.SINGLE).orElseThrow()); + assertFalse(topology.getEngine(CacheTier.L1).isPresent()); + assertFalse(topology.getEngine(CacheTier.L2).isPresent()); + } + + /** + * Verifies that a single-tier topology-backed service reads blocks from the single backing cache. + *+ * Since there is only one tier, the access service should delegate the read to the L1 engine and + * return the block supplied by the wrapped block cache. + *
+ */ + @Test + void testGetBlockDelegatesToSingleTier() { + BlockCache blockCache = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + when(blockCache.getBlock(key, true, false, true)).thenReturn(block); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + assertSame(block, service.getBlock(key, requestContext())); + + verify(blockCache).getBlock(key, true, false, true); + } + + /** + * Verifies that a single-tier topology-backed service returns {@code null} when the backing cache + * misses. + *+ * The service should not attempt any tier fallback because the topology contains only one cache + * engine. + *
+ */ + @Test + void testGetBlockReturnsNullOnMiss() { + BlockCache blockCache = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + + when(blockCache.getBlock(key, true, false, true)).thenReturn(null); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + assertSame(null, service.getBlock(key, requestContext())); + + verify(blockCache).getBlock(key, true, false, true); + } + + /** + * Verifies that cache population for a single-tier topology writes to the single backing cache. + *+ * The placement policy selects L1. Since single-tier topology exposes only L1, the service should + * delegate the cache write to the wrapped block cache. + *
+ */ + @Test + void testCacheBlockDelegatesToSingleTier() { + BlockCache blockCache = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + TopologyBackedCacheAccessService service = + service(blockCache, admitToTiersPolicy(CacheTier.SINGLE)); + + service.cacheBlock(key, block, writeContext()); + + verify(blockCache).cacheBlock(key, block, false, false); + } + + /** + * Verifies that a rejected block is not written to the single backing cache. + *+ * When the placement and admission policy rejects the write, the topology-backed service should + * not call any {@code cacheBlock} overload on the wrapped block cache. + *
+ */ + @Test + void testRejectedBlockIsNotCached() { + BlockCache blockCache = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + + TopologyBackedCacheAccessService service = service(blockCache, rejectPolicy()); + + service.cacheBlock(key, block, writeContext()); + + verify(blockCache, never()).cacheBlock(any(), any()); + verify(blockCache, never()).cacheBlock(any(), any(), anyBoolean(), anyBoolean()); + } + + /** + * Verifies that service-level eviction for a single-tier topology delegates to the backing cache. + *+ * A single-tier topology has only one possible resident tier, so eviction should be a direct + * delegation to that tier. + *
+ */ + @Test + void testEvictBlockDelegatesToSingleTier() { + BlockCache blockCache = mock(BlockCache.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + + when(blockCache.evictBlock(key)).thenReturn(true); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + assertTrue(service.evictBlock(key)); + + verify(blockCache).evictBlock(key); + } + + /** + * Verifies that single-tier cached-block iteration is exposed through the topology-backed access + * service. + *+ * Diagnostic callers use {@link CacheAccessServices#asCachedBlockIterable(CacheAccessService)} + * rather than unwrapping the legacy block cache. The single-tier topology-backed path should + * expose the same cached blocks as the wrapped block cache. + *
+ */ + @Test + void testCachedBlockIterableDelegatesToSingleTier() { + BlockCache blockCache = mock(BlockCache.class); + CachedBlock firstBlock = mock(CachedBlock.class); + CachedBlock secondBlock = mock(CachedBlock.class); + + when(blockCache.iterator()).thenReturn(Arrays.asList(firstBlock, secondBlock).iterator()); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + Optional+ * The topology itself does not aggregate multiple tiers, so its statistics should be exactly the + * statistics exposed by the single cache engine. + *
+ */ + @Test + void testTopologyStatsDelegatesToSingleEngine() { + CacheEngine engine = mock(CacheEngine.class); + CacheStats stats = mock(CacheStats.class); + + when(engine.getStats()).thenReturn(stats); + + SingleTierTopology topology = new SingleTierTopology("single", engine); + + assertSame(stats, topology.getStats()); + } + + /** + * Verifies that promotion is not supported by a single-tier topology. + *+ * Promotion requires a source tier and a different target tier. Since this topology has only one + * tier, promotion is a no-op and should return {@code false}. + *
+ */ + @Test + void testSingleTierTopologyDoesNotPromote() { + CacheEngine engine = mock(CacheEngine.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + SingleTierTopology topology = new SingleTierTopology("single", engine); + + assertFalse(topology.promote(key, block, engine, engine)); + + verify(engine, never()).cacheBlock(any(), any()); + verify(engine, never()).evictBlock(any()); + } + + /** + * Verifies that demotion is not supported by a single-tier topology. + *+ * Demotion requires a source tier and a different lower target tier. Since this topology has only + * one tier, demotion is a no-op and should return {@code false}. + *
+ */ + @Test + void testSingleTierTopologyDoesNotDemote() { + CacheEngine engine = mock(CacheEngine.class); + BlockCacheKey key = new BlockCacheKey("file", 1L); + Cacheable block = mock(Cacheable.class); + SingleTierTopology topology = new SingleTierTopology("single", engine); + + assertFalse(topology.demote(key, block, engine, engine)); + + verify(engine, never()).cacheBlock(any(), any()); + verify(engine, never()).evictBlock(any()); + } + + /** + * Verifies that shutting down a single-tier topology-backed service shuts down the backing cache. + *+ * The topology-backed service owns the topology lifecycle. For a single-tier topology, shutdown + * should be delegated to the only backing engine. + *
+ */ + @Test + void testShutdownDelegatesToSingleTier() { + BlockCache blockCache = mock(BlockCache.class); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + service.shutdown(); + + verify(blockCache).shutdown(); + } + + /** + * Verifies that single-tier topology-backed cache access preserves legacy current-size reporting. + *+ * The old {@link BlockCacheBackedCacheAccessService} path delegated + * {@link CacheAccessService#getCurrentSize()} to {@link BlockCache#getCurrentSize()}. After + * routing plain block caches through {@link TopologyBackedCacheAccessService}, the same value + * must still be reported through the cache access service. This is intentionally different from + * {@link CacheAccessService#getCurrentDataSize()} because some cache implementations include + * metadata or allocator overhead in their current size. + *
+ */ + @Test + void testSingleTierCurrentSizeDelegatesToBlockCacheCurrentSize() { + BlockCache blockCache = mock(BlockCache.class); + + when(blockCache.getCurrentSize()).thenReturn(1234L); + when(blockCache.getCurrentDataSize()).thenReturn(1000L); + + TopologyBackedCacheAccessService service = service(blockCache, noPromotionPolicy()); + + assertEquals(1234L, service.getCurrentSize()); + assertEquals(1000L, service.getCurrentDataSize()); + } + + /** + * Creates a topology-backed cache access service using a single-tier topology. + * @param blockCache legacy block cache backing the single tier + * @param policy cache placement and admission policy + * @return topology-backed cache access service using a single-tier topology + */ + private static TopologyBackedCacheAccessService service(BlockCache blockCache, + CachePlacementAdmissionPolicy policy) { + return TopologyBackedCacheAccessServices.fromSingleBlockCache("single", blockCache, policy); + } + + /** + * Creates a cache request context used by read-path tests. + *+ * The returned context enables caching, marks the request as non-repeat, and asks the cache to + * update cache metrics. These values match the read-path behavior covered by the topology-backed + * cache access service tests. + *
+ * @return cache request context for read-path tests + */ + private static CacheRequestContext requestContext() { + return CacheRequestContext.newBuilder().withCaching(true).withRepeat(false) + .withUpdateCacheMetrics(true).build(); + } + + /** + * Creates a cache write context used by cache population tests. + *+ * The returned context uses the default non-in-memory and non-blocking write behavior expected by + * the existing topology-backed cache access service tests. + *
+ * @return cache write context for cache population tests + */ + private static CacheWriteContext writeContext() { + return CacheWriteContext.newBuilder().withInMemory(false).withWaitWhenCache(false).build(); + } + + /** + * Creates a placement policy that never promotes a block after a cache hit. + *+ * This policy is useful for lookup tests that need to verify only lookup delegation and returned + * block behavior without introducing promotion side effects. + *
+ * @return cache placement and admission policy that disables promotion + */ + private static CachePlacementAdmissionPolicy noPromotionPolicy() { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldPromote(any(), any(), any(), any(), any())) + .thenReturn(PromotionDecision.none()); + return policy; + } + + /** + * Creates a placement policy that admits writes to the supplied tiers. + *+ * The returned policy admits every block and returns a multi-tier placement decision containing + * the tiers supplied by the caller. + *
+ * @param tiers cache tiers selected by the policy + * @return cache placement and admission policy that writes to the supplied tiers + */ + private static CachePlacementAdmissionPolicy admitToTiersPolicy(CacheTier... tiers) { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldAdmit(any(), any(), any(), any(), any())) + .thenReturn(AdmissionDecision.admit()); + when(policy.selectTier(any(), any(), any(), any())) + .thenReturn(TierDecision.multiple(Arrays.asList(tiers))); + return policy; + } + + /** + * Creates a placement policy that rejects every block. + *+ * The returned policy is used to verify that rejected cache writes are not delegated to the + * backing cache. + *
+ * @return cache placement and admission policy that rejects every block + */ + private static CachePlacementAdmissionPolicy rejectPolicy() { + CachePlacementAdmissionPolicy policy = mock(CachePlacementAdmissionPolicy.class); + when(policy.shouldAdmit(any(), any(), any(), any(), any())) + .thenReturn(AdmissionDecision.reject("test rejection")); + return policy; + } + + /** + * Copies an iterable of cached blocks into a list. + *+ * The helper makes cached-block iterable assertions deterministic and easy to compare with the + * expected order. + *
+ * @param iterable cached-block iterable to copy + * @return list containing all cached blocks produced by the iterable + */ + private static List