This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new dd774c3b3f [#12377] improvement(core): expire entity cache entries 
after write, not after access (#13099)
dd774c3b3f is described below

commit dd774c3b3fd256638c896be78516e32260dfa553
Author: Qi Yu <[email protected]>
AuthorDate: Mon Sep 21 16:57:58 2026 +0800

    [#12377] improvement(core): expire entity cache entries after write, not 
after access (#13099)
    
    ### What changes were proposed in this pull request?
    
    - `CaffeineEntityCache`: build the cache with `expireAfterWrite` instead
    of `expireAfterAccess`.
    - `Configs.CACHE_EXPIRATION_TIME`: fix a missing space in the doc
    string.
    - `docs/gravitino-server-config.md`: state that the TTL clock starts at
    write time and is not reset by reads, so `expireTimeInMs` is also the
    upper bound on stale reads in a multi-node deployment if a cross-node
    invalidation is ever missed.
    - New `TestCaffeineEntityCacheExpiration` asserting the Caffeine policy
    is write-based (and absent when the TTL is `0`).
    
    ### Why are the changes needed?
    
    `gravitino.cache.expireTimeInMs` and the docs both describe a TTL
    measured from the write, but the implementation used
    `expireAfterAccess`. With an access-based TTL, an entry that keeps being
    read never expires, so a single missed cross-node invalidation (lost
    `entity_change_log` row, stalled poller) becomes permanent staleness on
    exactly the hottest keys. A write-based TTL bounds that to
    `expireTimeInMs`.
    
    This is item 1 of #12377; the other items are left for follow-ups.
    
    Fix: #12377
    
    ### Does this PR introduce _any_ user-facing change?
    
    Behaviour change only: entries now expire `expireTimeInMs` after they
    were written, regardless of reads. Hot entries are reloaded from the DB
    once per TTL (default 1 hour). No config keys added or removed.
    
    ### How was this patch tested?
    
    `TestCaffeineEntityCacheExpiration` (new), plus the existing
    `org.apache.gravitino.cache.*` and `TestEntityCache*` unit tests.
    
    https://claude.ai/code/session_015v8chvQJLYFBuBv1MiHebo
---
 .../main/java/org/apache/gravitino/Configs.java    |  2 +-
 .../gravitino/cache/CaffeineEntityCache.java       |  7 ++-
 .../cache/TestCaffeineEntityCacheExpiration.java   | 68 ++++++++++++++++++++++
 docs/gravitino-server-config.md                    |  5 +-
 4 files changed, 79 insertions(+), 3 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/Configs.java 
b/core/src/main/java/org/apache/gravitino/Configs.java
index 23fd879df1..de6e2315c6 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -494,7 +494,7 @@ public class Configs {
   public static final ConfigEntry<Long> CACHE_EXPIRATION_TIME =
       new ConfigBuilder("gravitino.cache.expireTimeInMs")
           .doc(
-              "Time-to-live (TTL) for each cache entry after it is written, in 
milliseconds."
+              "Time-to-live (TTL) for each cache entry after it is written, in 
milliseconds. "
                   + "Default is 3,600,000 ms (1 hour).")
           .version(ConfigConstants.VERSION_1_0_0)
           .longConf()
diff --git 
a/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java 
b/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
index 2f79394dbc..0e117a3562 100644
--- a/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
+++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
@@ -335,7 +335,12 @@ public class CaffeineEntityCache extends BaseEntityCache {
     }
 
     if (cacheConfig.get(Configs.CACHE_EXPIRATION_TIME) > 0) {
-      builder.expireAfterAccess(
+      // Expire after write, not after access. The TTL is the safety net for a 
cross-node
+      // invalidation that never arrives (a lost entity_change_log row, a 
stalled poller). With an
+      // access-based TTL a stale entry that keeps being read would never 
expire, so a single missed
+      // invalidation would become permanent on exactly the hottest keys. A 
write-based TTL bounds
+      // that staleness to expireTimeInMs.
+      builder.expireAfterWrite(
           cacheConfig.get(Configs.CACHE_EXPIRATION_TIME), 
TimeUnit.MILLISECONDS);
     }
 
diff --git 
a/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheExpiration.java
 
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheExpiration.java
new file mode 100644
index 0000000000..6f451bfe12
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheExpiration.java
@@ -0,0 +1,68 @@
+/*
+ * 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.Policy;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the expiration policy of {@link CaffeineEntityCache}.
+ *
+ * <p>The cache must expire entries a fixed time after they were written, 
never after they were last
+ * read. In a multi-node deployment the TTL is the safety net for a cross-node 
invalidation that
+ * never arrives; an access-based TTL would keep a hot stale entry alive 
forever.
+ */
+public class TestCaffeineEntityCacheExpiration {
+
+  @Test
+  void testExpiresAfterWriteNotAfterAccess() {
+    Config config = new Config() {};
+    config.set(Configs.CACHE_EXPIRATION_TIME, 600_000L);
+
+    CaffeineEntityCache cache = new CaffeineEntityCache(config);
+    Policy<EntityCacheKey, ?> policy = cache.getCacheData().policy();
+
+    Assertions.assertTrue(policy.expireAfterWrite().isPresent());
+    Assertions.assertEquals(
+        Duration.ofMillis(600_000L), 
policy.expireAfterWrite().get().getExpiresAfter());
+    Assertions.assertFalse(
+        policy.expireAfterAccess().isPresent(),
+        "reads must not extend the lifetime of an entry: a stale entry that 
keeps being read "
+            + "would otherwise never expire");
+    Assertions.assertEquals(
+        600_000L, 
policy.expireAfterWrite().get().getExpiresAfter(TimeUnit.MILLISECONDS));
+  }
+
+  @Test
+  void testZeroExpirationDisablesTimeBasedEviction() {
+    Config config = new Config() {};
+    config.set(Configs.CACHE_EXPIRATION_TIME, 0L);
+
+    CaffeineEntityCache cache = new CaffeineEntityCache(config);
+    Policy<EntityCacheKey, ?> policy = cache.getCacheData().policy();
+
+    Assertions.assertFalse(policy.expireAfterWrite().isPresent());
+    Assertions.assertFalse(policy.expireAfterAccess().isPresent());
+  }
+}
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 96422306c8..824d29ebf3 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -302,7 +302,10 @@ by default, and the properties below tune what it holds 
and how it evicts.
 | `gravitino.cache.lockSegments`   | Number of lock segments used to reduce 
contention.                                  | `16`               |
 
 Two eviction limits apply at once. Time to live always applies: an entry older 
than
-`expireTimeInMs` expires and is cleaned up asynchronously. Alongside it, the 
cache bounds its size
+`expireTimeInMs` expires and is cleaned up asynchronously. The clock starts 
when the entry is
+written and is not reset by reads, so in a multi-node deployment 
`expireTimeInMs` is also the upper
+bound on how long a node can serve a stale entry if a cross-node invalidation 
is ever missed (see
+[Change Log Propagation](#change-log-propagation)). Alongside it, the cache 
bounds its size
 either by count or by weight. With `enableWeigher` disabled, Caffeine's 
W-TinyLFU policy evicts the
 least-used entries once `maxEntries` is reached. With `enableWeigher` enabled, 
each entity type
 carries a weight, larger for entities higher in the hierarchy, and eviction 
targets a total weight

Reply via email to