This is an automated email from the ASF dual-hosted git repository. yuqi4733 pushed a commit to branch feat/cache-jcasbin-id-mapping in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit edaa7bd6fef3eb7c3d1b619e458b0ea1eb298e3d Author: yuqi <[email protected]> AuthorDate: Thu Apr 16 23:26:27 2026 +0800 feat(cache): add GravitinoCache interface with Caffeine and NoOps implementations - GravitinoCache<K,V>: general-purpose cache interface for auth subsystem - getIfPresent, put, invalidate, invalidateAll, invalidateByPrefix, size - invalidateByPrefix enables hierarchical cascade invalidation for metadataIdCache - CaffeineGravitinoCache<K,V>: Caffeine-backed implementation with configurable TTL and max size - NoOpsGravitinoCache<K,V>: no-op implementation for testing and disabled-cache environments - TestGravitinoCache: comprehensive tests covering put/get, invalidate, invalidateAll, invalidateByPrefix (hierarchical cascade and leaf), overwrite, non-string keys, and NoOps Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../gravitino/cache/CaffeineGravitinoCache.java | 88 ++++++++++ .../org/apache/gravitino/cache/GravitinoCache.java | 74 ++++++++ .../gravitino/cache/NoOpsGravitinoCache.java | 66 +++++++ .../apache/gravitino/cache/TestGravitinoCache.java | 189 +++++++++++++++++++++ 4 files changed, 417 insertions(+) diff --git a/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java new file mode 100644 index 0000000000..5d52aafd8f --- /dev/null +++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java @@ -0,0 +1,88 @@ +/* + * 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.gravitino.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * A Caffeine-backed implementation of {@link GravitinoCache}. Supports configurable TTL and maximum + * size. + * + * @param <K> the key type + * @param <V> the value type + */ +public class CaffeineGravitinoCache<K, V> implements GravitinoCache<K, V> { + + private final Cache<K, V> cache; + + /** + * Creates a new CaffeineGravitinoCache with the given TTL and maximum size. + * + * @param ttlMs the time-to-live in milliseconds for cache entries (safety-net TTL) + * @param maxSize the maximum number of entries in the cache + */ + public CaffeineGravitinoCache(long ttlMs, long maxSize) { + this.cache = + Caffeine.newBuilder() + .expireAfterWrite(ttlMs, TimeUnit.MILLISECONDS) + .maximumSize(maxSize) + .build(); + } + + @Override + public Optional<V> getIfPresent(K key) { + V value = cache.getIfPresent(key); + return Optional.ofNullable(value); + } + + @Override + public void put(K key, V value) { + cache.put(key, value); + } + + @Override + public void invalidate(K key) { + cache.invalidate(key); + } + + @Override + public void invalidateAll() { + cache.invalidateAll(); + } + + @Override + public void invalidateByPrefix(String prefix) { + cache.asMap().keySet().removeIf(k -> k.toString().startsWith(prefix)); + } + + @Override + public long size() { + cache.cleanUp(); + return cache.estimatedSize(); + } + + @Override + public void close() { + cache.invalidateAll(); + cache.cleanUp(); + } +} diff --git a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java new file mode 100644 index 0000000000..3beaad2e5d --- /dev/null +++ b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java @@ -0,0 +1,74 @@ +/* + * 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.gravitino.cache; + +import java.io.Closeable; +import java.util.Optional; + +/** + * A general-purpose cache interface used by the authorization subsystem. Implementations include a + * Caffeine-backed cache and a no-op cache for testing. + * + * @param <K> the key type + * @param <V> the value type + */ +public interface GravitinoCache<K, V> extends Closeable { + + /** + * Returns the value associated with the key, or empty if not present. + * + * @param key the cache key + * @return an Optional containing the cached value, or empty if absent + */ + Optional<V> getIfPresent(K key); + + /** + * Associates the value with the key in the cache. + * + * @param key the cache key + * @param value the value to cache + */ + void put(K key, V value); + + /** + * Removes the entry for the given key. + * + * @param key the cache key to invalidate + */ + void invalidate(K key); + + /** Removes all entries from the cache. */ + void invalidateAll(); + + /** + * Evicts all entries whose key (as a String) starts with the given prefix. Only meaningful when K + * = String. Used by metadataIdCache for hierarchical cascade invalidation: dropping a catalog + * evicts the catalog entry plus all schema/table/fileset/... entries beneath it. + * + * @param prefix the prefix to match against key strings + */ + void invalidateByPrefix(String prefix); + + /** + * Returns the approximate number of entries in the cache. + * + * @return the cache size + */ + long size(); +} diff --git a/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java new file mode 100644 index 0000000000..8c41eabeef --- /dev/null +++ b/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java @@ -0,0 +1,66 @@ +/* + * 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.gravitino.cache; + +import java.util.Optional; + +/** + * A no-op implementation of {@link GravitinoCache} that never caches anything. Useful for testing + * and for environments where caching is disabled. + * + * @param <K> the key type + * @param <V> the value type + */ +public class NoOpsGravitinoCache<K, V> implements GravitinoCache<K, V> { + + @Override + public Optional<V> getIfPresent(K key) { + return Optional.empty(); + } + + @Override + public void put(K key, V value) { + // no-op + } + + @Override + public void invalidate(K key) { + // no-op + } + + @Override + public void invalidateAll() { + // no-op + } + + @Override + public void invalidateByPrefix(String prefix) { + // no-op + } + + @Override + public long size() { + return 0; + } + + @Override + public void close() { + // no-op + } +} diff --git a/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java new file mode 100644 index 0000000000..11304ef6e3 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java @@ -0,0 +1,189 @@ +/* + * 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.gravitino.cache; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests for {@link CaffeineGravitinoCache} and {@link NoOpsGravitinoCache}. */ +public class TestGravitinoCache { + + @Test + void testCaffeinePutAndGet() { + CaffeineGravitinoCache<String, Long> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put("key1", 100L); + cache.put("key2", 200L); + + Optional<Long> val1 = cache.getIfPresent("key1"); + Assertions.assertTrue(val1.isPresent()); + Assertions.assertEquals(100L, val1.get()); + + Optional<Long> val2 = cache.getIfPresent("key2"); + Assertions.assertTrue(val2.isPresent()); + Assertions.assertEquals(200L, val2.get()); + + Optional<Long> missing = cache.getIfPresent("nonexistent"); + Assertions.assertFalse(missing.isPresent()); + + Assertions.assertEquals(2, cache.size()); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineInvalidate() { + CaffeineGravitinoCache<String, String> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put("a", "val-a"); + cache.put("b", "val-b"); + + cache.invalidate("a"); + Assertions.assertFalse(cache.getIfPresent("a").isPresent()); + Assertions.assertTrue(cache.getIfPresent("b").isPresent()); + + Assertions.assertEquals(1, cache.size()); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineInvalidateAll() { + CaffeineGravitinoCache<String, Integer> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put("x", 1); + cache.put("y", 2); + cache.put("z", 3); + + cache.invalidateAll(); + Assertions.assertEquals(0, cache.size()); + Assertions.assertFalse(cache.getIfPresent("x").isPresent()); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineInvalidateByPrefix() { + CaffeineGravitinoCache<String, Long> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + // Simulate hierarchical keys: metalake::catalog::schema:: + cache.put("lake1::cat1::", 1L); + cache.put("lake1::cat1::s1::", 2L); + cache.put("lake1::cat1::s1::t1::TABLE", 3L); + cache.put("lake1::cat1::s1::t2::TABLE", 4L); + cache.put("lake1::cat1::s2::", 5L); + cache.put("lake1::cat2::", 6L); + cache.put("lake2::cat3::", 7L); + + Assertions.assertEquals(7, cache.size()); + + // Drop catalog cat1 — should invalidate cat1 and all children + cache.invalidateByPrefix("lake1::cat1::"); + + Assertions.assertEquals(2, cache.size()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::").isPresent()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::s1::").isPresent()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::s1::t1::TABLE").isPresent()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::s1::t2::TABLE").isPresent()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::s2::").isPresent()); + + // cat2 and lake2 should be unaffected + Assertions.assertTrue(cache.getIfPresent("lake1::cat2::").isPresent()); + Assertions.assertTrue(cache.getIfPresent("lake2::cat3::").isPresent()); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineInvalidateByPrefixLeaf() { + CaffeineGravitinoCache<String, Long> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put("lake1::cat1::s1::t1::TABLE", 1L); + cache.put("lake1::cat1::s1::t2::TABLE", 2L); + cache.put("lake1::cat1::s1::f1::FILESET", 3L); + + // Drop specific table — only t1 should be invalidated + cache.invalidateByPrefix("lake1::cat1::s1::t1::TABLE"); + + Assertions.assertEquals(2, cache.size()); + Assertions.assertFalse(cache.getIfPresent("lake1::cat1::s1::t1::TABLE").isPresent()); + Assertions.assertTrue(cache.getIfPresent("lake1::cat1::s1::t2::TABLE").isPresent()); + Assertions.assertTrue(cache.getIfPresent("lake1::cat1::s1::f1::FILESET").isPresent()); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineOverwrite() { + CaffeineGravitinoCache<String, Long> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put("k", 1L); + Assertions.assertEquals(1L, cache.getIfPresent("k").get()); + + cache.put("k", 2L); + Assertions.assertEquals(2L, cache.getIfPresent("k").get()); + + Assertions.assertEquals(1, cache.size()); + } finally { + cache.close(); + } + } + + @Test + void testNoOpsCache() { + NoOpsGravitinoCache<String, Long> cache = new NoOpsGravitinoCache<>(); + try { + cache.put("key1", 100L); + Assertions.assertFalse(cache.getIfPresent("key1").isPresent()); + Assertions.assertEquals(0, cache.size()); + + // All operations are no-ops, should not throw + cache.invalidate("key1"); + cache.invalidateAll(); + cache.invalidateByPrefix("any"); + } finally { + cache.close(); + } + } + + @Test + void testCaffeineWithNonStringKeys() { + CaffeineGravitinoCache<Long, String> cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + cache.put(1L, "role1"); + cache.put(2L, "role2"); + cache.put(3L, "role3"); + + Assertions.assertEquals("role1", cache.getIfPresent(1L).get()); + Assertions.assertEquals(3, cache.size()); + + cache.invalidate(2L); + Assertions.assertFalse(cache.getIfPresent(2L).isPresent()); + Assertions.assertEquals(2, cache.size()); + } finally { + cache.close(); + } + } +}
