From a673aaffc1c27a443de987c40e6db71aaa4536f9 Mon Sep 17 00:00:00 2001 From: yangjie01 Date: Sun, 13 Sep 2026 07:48:53 +0800 Subject: [PATCH 1/4] [common] Bound the memory cache's file-size memo map LocalMemoryCacheManager memoized file sizes in an unbounded ConcurrentHashMap with only prefix invalidation removing entries: in memory-cache mode, a job reading many distinct whitelisted files grew it without limit (a long map of path strings on the heap). Cap the memo at 65536 entries with insertion-order eviction, guarded by the manager's existing lock (an access-ordered LinkedHashMap also keeps repeated hits on the same entries). A dropped memo only costs one extra getFileStatus on the next open. Assisted-by: GLM-5.3 --- .../fs/cache/LocalMemoryCacheManager.java | 26 ++++++++++++++----- .../paimon/fs/cache/CachingFileIOTest.java | 18 +++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java index e92cb88412fe..0d02d92c7639 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java @@ -24,16 +24,21 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; /** Block-level in-memory cache with LRU eviction. Thread-safe. */ public class LocalMemoryCacheManager implements LocalCacheManager { + /** + * File-size memos are tiny but per-path; bound them so reading millions of distinct files + * cannot grow the heap without limit. A dropped memo only costs one extra getFileStatus. + */ + private static final int MAX_FILE_SIZE_ENTRIES = 65536; + private final long maxSizeBytes; private final int blockSize; private final Object lock = new Object(); private final LinkedHashMap cache; - private final ConcurrentHashMap fileSizeCache = new ConcurrentHashMap<>(); + private final LinkedHashMap fileSizeCache = new LinkedHashMap<>(64, 0.75f, true); private long currentSize; @@ -80,13 +85,22 @@ public void putBlock(String filePath, int blockIndex, byte[] data) { @Override public long getFileSize(String filePath) { - Long size = fileSizeCache.get(filePath); - return size != null ? size : -1; + synchronized (lock) { + Long size = fileSizeCache.get(filePath); + return size != null ? size : -1; + } } @Override public void putFileSize(String filePath, long size) { - fileSizeCache.put(filePath, size); + synchronized (lock) { + fileSizeCache.put(filePath, size); + while (fileSizeCache.size() > MAX_FILE_SIZE_ENTRIES) { + Iterator it = fileSizeCache.keySet().iterator(); + it.next(); + it.remove(); + } + } } @Override @@ -100,8 +114,8 @@ public void invalidate(String filePathPrefix) { iterator.remove(); } } + fileSizeCache.keySet().removeIf(filePath -> filePath.startsWith(filePathPrefix)); } - fileSizeCache.keySet().removeIf(filePath -> filePath.startsWith(filePathPrefix)); } private static class BlockKey { diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java index 05ddd7cad08f..7eb674d0a812 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java @@ -212,6 +212,24 @@ void testShortRemoteReadIsNotCachedAsZeroPaddedBlock() throws IOException { } } + @Test + void fileSizeMemoIsBounded() { + LocalMemoryCacheManager cache = new LocalMemoryCacheManager(Long.MAX_VALUE, 64); + long entries = 70000; + + cache.putFileSize("file-0", 100L); + for (long i = 1; i <= entries; i++) { + cache.putFileSize("file-" + i, i); + } + + // the oldest memos are evicted; a lost memo only costs one re-stat + assertThat(cache.getFileSize("file-0")).isEqualTo(-1L); + assertThat(cache.getFileSize("file-" + entries)).isEqualTo(entries); + // invalidation by prefix still works on the bounded map + cache.invalidate("file-" + entries); + assertThat(cache.getFileSize("file-" + entries)).isEqualTo(-1L); + } + @Test void testMetaFileIsCached() throws IOException { byte[] data = "snapshot data".getBytes(); From 8aa1dc41b9948e81090eef7d4218f44bdf578eb5 Mon Sep 17 00:00:00 2001 From: yangjie01 Date: Sun, 13 Sep 2026 15:29:18 +0800 Subject: [PATCH 2/4] fix: bound the disk cache's file-size memo too The memo lives in both cache managers and which one runs is decided purely by whether local-cache.dir is set, so bounding only the in-memory one left the same unbounded map on the path the javadoc was describing: a long-lived process reading millions of distinct files. Extract the bounded LRU memo and use it in both, with the disk manager's accesses under the lock it already holds. Co-Authored-By: Claude Code --- .../apache/paimon/fs/cache/FileSizeMemo.java | 58 +++++++++++++++++++ .../fs/cache/LocalDiskCacheManager.java | 12 ++-- .../fs/cache/LocalMemoryCacheManager.java | 25 +++----- .../paimon/fs/cache/CachingFileIOTest.java | 27 +++++++-- 4 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java new file mode 100644 index 000000000000..260254e093cd --- /dev/null +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java @@ -0,0 +1,58 @@ +/* + * 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.paimon.fs.cache; + +import java.util.Iterator; +import java.util.LinkedHashMap; + +/** + * Least-recently-used memo of file sizes, bounded by entry count. + * + *

A memo is tiny but there is one per path, so an unbounded map grows with the number of + * distinct files a long-lived process reads. Losing one only costs the extra {@code getFileStatus} + * that would have been made anyway, and the files these caches accept are immutable, so a re-read + * returns the same size. + * + *

Not thread-safe: callers hold their own lock. Access order means {@link #get} mutates the map, + * so even a read has to be inside it. + */ +class FileSizeMemo { + + private static final int MAX_ENTRIES = 65536; + + private final LinkedHashMap sizes = new LinkedHashMap<>(64, 0.75f, true); + + long get(String filePath) { + Long size = sizes.get(filePath); + return size != null ? size : -1; + } + + void put(String filePath, long size) { + sizes.put(filePath, size); + Iterator iterator = sizes.keySet().iterator(); + while (sizes.size() > MAX_ENTRIES && iterator.hasNext()) { + iterator.next(); + iterator.remove(); + } + } + + void invalidate(String filePathPrefix) { + sizes.keySet().removeIf(filePath -> filePath.startsWith(filePathPrefix)); + } +} diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java index d020feac427e..4f05b9415fc1 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java @@ -38,7 +38,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** Block-level local disk cache with LRU eviction. Thread-safe. */ public class LocalDiskCacheManager implements LocalCacheManager { @@ -50,7 +49,7 @@ public class LocalDiskCacheManager implements LocalCacheManager { private final long maxSizeBytes; private final int blockSize; private final Object lock = new Object(); - private final ConcurrentHashMap fileSizeCache = new ConcurrentHashMap<>(); + private final FileSizeMemo fileSizeMemo = new FileSizeMemo(); // LRU-ordered index: key -> size. Access order so get() moves entry to tail. private final LinkedHashMap entryIndex; @@ -240,12 +239,15 @@ long currentSize() { @Override public long getFileSize(String filePath) { - Long size = fileSizeCache.get(filePath); - return size != null ? size : -1; + synchronized (lock) { + return fileSizeMemo.get(filePath); + } } @Override public void putFileSize(String filePath, long size) { - fileSizeCache.put(filePath, size); + synchronized (lock) { + fileSizeMemo.put(filePath, size); + } } } diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java index 0d02d92c7639..541940e668dc 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java @@ -25,20 +25,17 @@ import java.util.Map; import java.util.Objects; -/** Block-level in-memory cache with LRU eviction. Thread-safe. */ +/** + * In-memory cache with LRU eviction, holding data blocks bounded by total bytes and a {@link + * FileSizeMemo} bounded by entry count. Thread-safe. + */ public class LocalMemoryCacheManager implements LocalCacheManager { - /** - * File-size memos are tiny but per-path; bound them so reading millions of distinct files - * cannot grow the heap without limit. A dropped memo only costs one extra getFileStatus. - */ - private static final int MAX_FILE_SIZE_ENTRIES = 65536; - private final long maxSizeBytes; private final int blockSize; private final Object lock = new Object(); private final LinkedHashMap cache; - private final LinkedHashMap fileSizeCache = new LinkedHashMap<>(64, 0.75f, true); + private final FileSizeMemo fileSizeMemo = new FileSizeMemo(); private long currentSize; @@ -86,20 +83,14 @@ public void putBlock(String filePath, int blockIndex, byte[] data) { @Override public long getFileSize(String filePath) { synchronized (lock) { - Long size = fileSizeCache.get(filePath); - return size != null ? size : -1; + return fileSizeMemo.get(filePath); } } @Override public void putFileSize(String filePath, long size) { synchronized (lock) { - fileSizeCache.put(filePath, size); - while (fileSizeCache.size() > MAX_FILE_SIZE_ENTRIES) { - Iterator it = fileSizeCache.keySet().iterator(); - it.next(); - it.remove(); - } + fileSizeMemo.put(filePath, size); } } @@ -114,7 +105,7 @@ public void invalidate(String filePathPrefix) { iterator.remove(); } } - fileSizeCache.keySet().removeIf(filePath -> filePath.startsWith(filePathPrefix)); + fileSizeMemo.invalidate(filePathPrefix); } } diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java index 7eb674d0a812..fb8146f5383f 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java @@ -214,7 +214,15 @@ void testShortRemoteReadIsNotCachedAsZeroPaddedBlock() throws IOException { @Test void fileSizeMemoIsBounded() { - LocalMemoryCacheManager cache = new LocalMemoryCacheManager(Long.MAX_VALUE, 64); + // both cache managers keep this memo, and either one is picked purely by whether + // local-cache.dir is set, so the bound has to hold for both + assertFileSizeMemoIsBounded(new LocalMemoryCacheManager(Long.MAX_VALUE, 64)); + assertFileSizeMemoIsBounded( + new LocalDiskCacheManager( + tempDir.resolve("memo-bound").toString(), Long.MAX_VALUE, 64)); + } + + private static void assertFileSizeMemoIsBounded(LocalCacheManager cache) { long entries = 70000; cache.putFileSize("file-0", 100L); @@ -225,9 +233,20 @@ void fileSizeMemoIsBounded() { // the oldest memos are evicted; a lost memo only costs one re-stat assertThat(cache.getFileSize("file-0")).isEqualTo(-1L); assertThat(cache.getFileSize("file-" + entries)).isEqualTo(entries); - // invalidation by prefix still works on the bounded map - cache.invalidate("file-" + entries); - assertThat(cache.getFileSize("file-" + entries)).isEqualTo(-1L); + } + + @Test + void memoryCacheInvalidatesFileSizeMemoByPrefix() { + // only the memory manager overrides invalidate; the disk one inherits the no-op default, + // which this PR does not change + LocalMemoryCacheManager cache = new LocalMemoryCacheManager(Long.MAX_VALUE, 64); + cache.putFileSize("ns/a", 1L); + cache.putFileSize("other/a", 2L); + + cache.invalidate("ns/"); + + assertThat(cache.getFileSize("ns/a")).isEqualTo(-1L); + assertThat(cache.getFileSize("other/a")).isEqualTo(2L); } @Test From fde63122db4c233e71a02151a769b1ae12263804 Mon Sep 17 00:00:00 2001 From: yangjie01 Date: Sun, 13 Sep 2026 16:03:35 +0800 Subject: [PATCH 3/4] test: pin the memo bound itself, not just that eviction happens The assertion only checked that the oldest entry was gone and the newest was present, which holds for any bound at all. Count the survivors against MAX_ENTRIES so moving the bound fails the test. Co-Authored-By: Claude Code --- .../org/apache/paimon/fs/cache/FileSizeMemo.java | 2 +- .../apache/paimon/fs/cache/CachingFileIOTest.java | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java index 260254e093cd..bf076ccc1d7b 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java @@ -34,7 +34,7 @@ */ class FileSizeMemo { - private static final int MAX_ENTRIES = 65536; + static final int MAX_ENTRIES = 65536; private final LinkedHashMap sizes = new LinkedHashMap<>(64, 0.75f, true); diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java index fb8146f5383f..8b1e8355cdc7 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java @@ -223,14 +223,22 @@ void fileSizeMemoIsBounded() { } private static void assertFileSizeMemoIsBounded(LocalCacheManager cache) { - long entries = 70000; + long entries = FileSizeMemo.MAX_ENTRIES + 4464L; cache.putFileSize("file-0", 100L); for (long i = 1; i <= entries; i++) { cache.putFileSize("file-" + i, i); } - // the oldest memos are evicted; a lost memo only costs one re-stat + // exactly the bound survives, so the test fails if the bound moves rather than only if + // eviction stops happening; the oldest go first and a lost memo costs one re-stat + long survivors = 0; + for (long i = 0; i <= entries; i++) { + if (cache.getFileSize("file-" + i) != -1L) { + survivors++; + } + } + assertThat(survivors).isEqualTo(FileSizeMemo.MAX_ENTRIES); assertThat(cache.getFileSize("file-0")).isEqualTo(-1L); assertThat(cache.getFileSize("file-" + entries)).isEqualTo(entries); } From 1d14d4ff46164cb67481c0bc1af2175398da4786 Mon Sep 17 00:00:00 2001 From: yangjie01 Date: Sun, 13 Sep 2026 18:38:03 +0800 Subject: [PATCH 4/4] test: pin the memo bound on the write path, its eviction order and its prefix scope Reads the bound through a method so it is not inlined into the test, adds a unit test on FileSizeMemo itself, and states the bound each case needs rather than assuming an even one. --- .../apache/paimon/fs/cache/FileSizeMemo.java | 12 +- .../paimon/fs/cache/CachingFileIOTest.java | 13 +- .../paimon/fs/cache/FileSizeMemoTest.java | 115 ++++++++++++++++++ 3 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 paimon-common/src/test/java/org/apache/paimon/fs/cache/FileSizeMemoTest.java diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java index bf076ccc1d7b..5691596d3624 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/FileSizeMemo.java @@ -34,10 +34,20 @@ */ class FileSizeMemo { - static final int MAX_ENTRIES = 65536; + private static final int MAX_ENTRIES = 65536; private final LinkedHashMap sizes = new LinkedHashMap<>(64, 0.75f, true); + /** Read through a method, not the constant: a constant is inlined into the test's bytecode. */ + static int maxEntries() { + return MAX_ENTRIES; + } + + /** Entry count, so a test can observe the bound without reading an entry. */ + int size() { + return sizes.size(); + } + long get(String filePath) { Long size = sizes.get(filePath); return size != null ? size : -1; diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java index 8b1e8355cdc7..d3ae9d06633e 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java @@ -223,22 +223,15 @@ void fileSizeMemoIsBounded() { } private static void assertFileSizeMemoIsBounded(LocalCacheManager cache) { - long entries = FileSizeMemo.MAX_ENTRIES + 4464L; + // more puts than the bound, so eviction has to run. FileSizeMemoTest pins the count and + // the eviction order; this only checks that the manager routes through a bounded memo. + long entries = FileSizeMemo.maxEntries() + 1024L; cache.putFileSize("file-0", 100L); for (long i = 1; i <= entries; i++) { cache.putFileSize("file-" + i, i); } - // exactly the bound survives, so the test fails if the bound moves rather than only if - // eviction stops happening; the oldest go first and a lost memo costs one re-stat - long survivors = 0; - for (long i = 0; i <= entries; i++) { - if (cache.getFileSize("file-" + i) != -1L) { - survivors++; - } - } - assertThat(survivors).isEqualTo(FileSizeMemo.MAX_ENTRIES); assertThat(cache.getFileSize("file-0")).isEqualTo(-1L); assertThat(cache.getFileSize("file-" + entries)).isEqualTo(entries); } diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/cache/FileSizeMemoTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/cache/FileSizeMemoTest.java new file mode 100644 index 000000000000..d7f9326494d9 --- /dev/null +++ b/paimon-common/src/test/java/org/apache/paimon/fs/cache/FileSizeMemoTest.java @@ -0,0 +1,115 @@ +/* + * 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.paimon.fs.cache; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Tests for {@link FileSizeMemo}. */ +class FileSizeMemoTest { + + @Test + void putsAloneBoundTheMemo() { + int bound = FileSizeMemo.maxEntries(); + FileSizeMemo memo = new FileSizeMemo(); + + for (int i = 0; i < bound; i++) { + memo.put("file-" + i, i); + } + assertThat(memo.size()).isEqualTo(bound); + + // check the first overflow on its own: a step that evicts the wrong number of entries + // shows up here, where no later put can bring the count back to the bound + memo.put("over-0", 0L); + assertThat(memo.size()).isEqualTo(bound); + + for (int i = 1; i < 1024; i++) { + memo.put("over-" + i, i); + } + // no read anywhere above, so the write path is what has to bound it + assertThat(memo.size()).isEqualTo(bound); + } + + @Test + void aReadEntryOutlivesAnUnreadOne() { + int bound = FileSizeMemo.maxEntries(); + // below 4 the four assertions below are not four distinct keys + assertThat(bound).isGreaterThanOrEqualTo(4); + int read = bound / 2; + int unread = bound - read; + FileSizeMemo memo = new FileSizeMemo(); + for (int i = 0; i < bound; i++) { + memo.put("file-" + i, i); + } + + // reading the older entries makes them the recently used ones + for (int i = 0; i < read; i++) { + assertThat(memo.get("file-" + i)).isEqualTo(i); + } + // exactly as many new entries as were left unread, so those are what eviction takes + for (int i = bound; i < bound + unread; i++) { + memo.put("file-" + i, i); + } + + assertThat(memo.get("file-0")).isEqualTo(0L); + assertThat(memo.get("file-" + (read - 1))).isEqualTo(read - 1L); + assertThat(memo.get("file-" + read)).isEqualTo(-1L); + assertThat(memo.get("file-" + (bound - 1))).isEqualTo(-1L); + } + + @Test + void puttingAnEntryAgainRefreshesIt() { + int bound = FileSizeMemo.maxEntries(); + // at a bound of 1 the loop below never runs, so nothing would pin the position half + assertThat(bound).isGreaterThanOrEqualTo(2); + FileSizeMemo memo = new FileSizeMemo(); + for (int i = 0; i < bound; i++) { + memo.put("file-" + i, i); + } + + memo.put("file-0", 100L); + for (int i = bound; i < bound + bound - 1; i++) { + memo.put("file-" + i, i); + } + + // the re-put carried both the newer value and the newer position + assertThat(memo.get("file-0")).isEqualTo(100L); + assertThat(memo.get("file-1")).isEqualTo(-1L); + } + + @Test + void invalidateRemovesOnlyTheMatchingPrefix() { + // the fixture below holds four entries, and none of them may be evicted + assertThat(FileSizeMemo.maxEntries()).isGreaterThanOrEqualTo(4); + FileSizeMemo memo = new FileSizeMemo(); + memo.put("/a/one", 1L); + memo.put("/a/two", 2L); + memo.put("/b/three", 3L); + // carries the prefix, but not at the front + memo.put("/b/a/four", 4L); + + memo.invalidate("/a/"); + + assertThat(memo.get("/a/one")).isEqualTo(-1L); + assertThat(memo.get("/a/two")).isEqualTo(-1L); + assertThat(memo.get("/b/three")).isEqualTo(3L); + assertThat(memo.get("/b/a/four")).isEqualTo(4L); + } +}