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 f3a2551a61 [#11736] fix(core): retry failed entity change log 
listeners and do not remove logs when polling. (#11739)
f3a2551a61 is described below

commit f3a2551a61c42101e59b013cc354be50d8db4bca
Author: Qi Yu <[email protected]>
AuthorDate: Wed Jul 29 11:06:24 2026 +0800

    [#11736] fix(core): retry failed entity change log listeners and do not 
remove logs when polling. (#11739)
    
    ### What changes were proposed in this pull request?
    
    This PR makes `EntityChangeLogPoller` retry a batch when a listener
    fails, with a bounded number of retries:
    
    - Keep the fetched batch until every registered listener applies it.
    - Retry only listeners that have not succeeded, and stop waiting for one
    that is unregistered.
    - Advance the high-water cursor after all pending listeners succeed.
    - Bound the retries with `gravitino.entityChangeLog.listenerMaxRetries`
    (default 10). Once a listener exhausts them,
    `gravitino.entityChangeLog.listenerFailureAction` decides what happens:
    `EXIT` (default) stops this server, because its local caches are known
    to be stale; `SKIP` drops the batch for that listener and keeps serving.
    `EXIT` goes through `System.exit`, so the server shutdown hook still
    stops the server gracefully.
    - Keep `CatalogChangeLogListener` swallowing its own failures instead of
    propagating them. `CatalogManager.consumeLocalMutation` is single-shot,
    so re-delivering an already-applied batch would invalidate a catalog
    this process mutated itself and close its in-use `IsolatedClassLoader`.
    A dropped invalidation only costs staleness bounded by the catalog cache
    TTL.
    - Increase the fetch batch size from 500 to 2000.
    - Log poller startup, consumed ID ranges, cursor advancement, retries,
    and listener failures.
    - Move cleanup out of the polling path into a dedicated single-threaded
    cleaner, and run its first pass after a short randomized delay so that a
    frequently restarted server still prunes and HA nodes do not delete the
    same rows at once.
    - Retain change logs for 30 days by default and run cleanup daily.
    - Use database time for both insertion and expiration, and commit each
    1000-row cleanup batch.
    - Close every entity store component even if an earlier one fails or was
    never created.
    
    This PR intentionally does not address the commit-ordering gap or
    cache-expiration behavior tracked by #11736.
    
    ### Why are the changes needed?
    
    Previously, the poller advanced its cursor even when a listener threw,
    permanently dropping that batch for the failed listener. Retrying
    without a bound has the opposite problem: a permanently failing listener
    would freeze cache invalidation for the whole process, so the retries
    are now bounded and the outcome is explicit. Cleanup was coupled to
    polling, used a non-committing session (so deletions were never
    committed), and calculated expiration from each server's JVM clock.
    
    Fixed: #11736.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    - New config `gravitino.entityChangeLog.listenerMaxRetries` (default
    `10`).
    - New config `gravitino.entityChangeLog.listenerFailureAction` (default
    `EXIT`). With the default, a server whose change log listener keeps
    failing after its retries stops itself instead of serving from caches it
    knows to be stale. Set it to `SKIP` to prefer availability.
    - The default change-log retention increases from 1 day to 30 days, and
    the default cleanup interval changes from 1 hour to 1 day.
    
    Both new configs are documented in `docs/gravitino-server-config.md`.
    
    ### How was this patch tested?
    
    - `./gradlew :core:test -PskipITs -PskipDockerTests=false`
    - Added coverage for failed-listener retries, the retry bound under both
    `EXIT` and `SKIP`, a 2001-record backlog, a paused batch blocking new
    fetches, catalog-listener failure containment, the cleaner lifecycle and
    its initial delay, independent batched cleanup, database-time SQL, and
    committed cleanup visibility from a new SQL session.
    - The pruning statements are exercised against H2. The PostgreSQL
    variant is only asserted at the SQL-string level, matching the existing
    PostgreSQL provider tests in `core`; verifying it against a real
    PostgreSQL would need a new docker-tagged integration test.
---
 .../fileset/TestFilesetCatalogOperations.java      |   4 +
 .../catalog/kafka/TestKafkaCatalogOperations.java  |   4 +
 .../generic/TestGenericCatalogOperations.java      |   4 +
 .../catalog/model/TestModelCatalogOperations.java  |   4 +
 .../cache/it/AbstractEntityStorageBenchmark.java   |   4 +
 .../main/java/org/apache/gravitino/Configs.java    |  37 ++-
 .../catalog/CatalogChangeLogListener.java          |  29 +-
 .../apache/gravitino/catalog/CatalogManager.java   |   4 +-
 .../storage/relational/EntityChangeLogCleaner.java | 173 ++++++++++
 .../relational/EntityChangeLogListener.java        |   4 +
 .../storage/relational/EntityChangeLogPoller.java  | 354 +++++++++++++++++----
 .../storage/relational/RelationalEntityStore.java  |  57 +++-
 .../relational/mapper/EntityChangeLogMapper.java   |   5 +-
 .../mapper/EntityChangeLogSQLProviderFactory.java  |   4 +-
 .../mapper/provider/DatabaseTimeSQL.java           |  47 +++
 .../base/EntityChangeLogBaseSQLProvider.java       |  42 ++-
 .../EntityChangeLogPostgreSQLProvider.java         |  19 +-
 .../authorization/TestAccessControlManager.java    |   4 +
 .../gravitino/authorization/TestOwnerManager.java  |   4 +
 .../catalog/TestCatalogChangeLogListener.java      |  92 ++++++
 .../gravitino/hook/TestFilesetHookDispatcher.java  |   4 +
 .../apache/gravitino/policy/TestPolicyManager.java |   4 +
 .../gravitino/stats/TestStatisticManager.java      |   4 +
 .../storage/AbstractEntityStorageTest.java         |   4 +
 .../relational/TestEntityChangeLogCleaner.java     | 185 +++++++++++
 .../relational/TestEntityChangeLogPoller.java      | 257 ++++++++++-----
 .../provider/base/TestEntityChangeLogMapper.java   |  51 ++-
 .../org/apache/gravitino/tag/TestTagManager.java   |   4 +
 docs/gravitino-server-config.md                    |   6 +-
 29 files changed, 1217 insertions(+), 197 deletions(-)

diff --git 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
index 77dbf1f319..1a55d19792 100644
--- 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
+++ 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.catalog.fileset;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -244,6 +246,8 @@ public class TestFilesetCatalogOperations {
     when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 1000L);
     when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 60 * 
60L);
     when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 * 
60L);
     // Fix cache config for test
diff --git 
a/catalogs/catalog-kafka/src/test/java/org/apache/gravitino/catalog/kafka/TestKafkaCatalogOperations.java
 
b/catalogs/catalog-kafka/src/test/java/org/apache/gravitino/catalog/kafka/TestKafkaCatalogOperations.java
index 16b4951236..dd30df96f3 100644
--- 
a/catalogs/catalog-kafka/src/test/java/org/apache/gravitino/catalog/kafka/TestKafkaCatalogOperations.java
+++ 
b/catalogs/catalog-kafka/src/test/java/org/apache/gravitino/catalog/kafka/TestKafkaCatalogOperations.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.catalog.kafka;
 import static org.apache.gravitino.Catalog.Type.MESSAGING;
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -163,6 +165,8 @@ public class TestKafkaCatalogOperations extends 
KafkaClusterEmbedded {
     when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 1000L);
     when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 60 * 
60L);
     when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 * 
60L);
     // Fix cache config for test
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
index fe749a18a3..c1baa184bf 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.catalog.lakehouse.generic;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -108,6 +110,8 @@ public class TestGenericCatalogOperations {
     when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 1000L);
     when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 60 * 
60L);
     when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 * 
60L);
     Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(false);
diff --git 
a/catalogs/catalog-model/src/test/java/org/apache/gravtitino/catalog/model/TestModelCatalogOperations.java
 
b/catalogs/catalog-model/src/test/java/org/apache/gravtitino/catalog/model/TestModelCatalogOperations.java
index 3237e96105..a95892b088 100644
--- 
a/catalogs/catalog-model/src/test/java/org/apache/gravtitino/catalog/model/TestModelCatalogOperations.java
+++ 
b/catalogs/catalog-model/src/test/java/org/apache/gravtitino/catalog/model/TestModelCatalogOperations.java
@@ -20,6 +20,8 @@ package org.apache.gravtitino.catalog.model;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -127,6 +129,8 @@ public class TestModelCatalogOperations {
     when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 1000L);
     when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 60 * 
60L);
     when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 * 
60L);
     // Fix cache config for test
diff --git 
a/core/src/jmh/java/org/apache/gravitino/cache/it/AbstractEntityStorageBenchmark.java
 
b/core/src/jmh/java/org/apache/gravitino/cache/it/AbstractEntityStorageBenchmark.java
index ba733f5f16..3fa058e3b5 100644
--- 
a/core/src/jmh/java/org/apache/gravitino/cache/it/AbstractEntityStorageBenchmark.java
+++ 
b/core/src/jmh/java/org/apache/gravitino/cache/it/AbstractEntityStorageBenchmark.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.cache.it;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -294,6 +296,8 @@ public class AbstractEntityStorageBenchmark<E extends 
Entity & HasIdentifier> {
     
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java 
b/core/src/main/java/org/apache/gravitino/Configs.java
index 08702ae734..fb1b0a0c7c 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -20,6 +20,7 @@ package org.apache.gravitino;
 
 import com.google.common.collect.Lists;
 import java.io.File;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
@@ -29,6 +30,7 @@ import org.apache.gravitino.config.ConfigBuilder;
 import org.apache.gravitino.config.ConfigConstants;
 import org.apache.gravitino.config.ConfigEntry;
 import org.apache.gravitino.stats.storage.JdbcPartitionStatisticStorageFactory;
+import org.apache.gravitino.storage.relational.EntityChangeLogPoller;
 import org.apache.gravitino.utils.FileFetcher;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 
@@ -186,8 +188,10 @@ public class Configs {
           .createWithDefault(60 * 60 * 1000L);
 
   public static final long DEFAULT_ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS = 3L;
-  public static final long DEFAULT_ENTITY_CHANGE_LOG_RETENTION_SECS = 24 * 60 
* 60L;
-  public static final long DEFAULT_ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS = 
60 * 60L;
+  public static final int DEFAULT_ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES = 10;
+  public static final String DEFAULT_ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION 
= "EXIT";
+  public static final long DEFAULT_ENTITY_CHANGE_LOG_RETENTION_SECS = 30 * 24 
* 60 * 60L;
+  public static final long DEFAULT_ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS = 
24 * 60 * 60L;
 
   public static final ConfigEntry<Long> ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS =
       new ConfigBuilder("gravitino.entityChangeLog.pollIntervalSecs")
@@ -197,6 +201,31 @@ public class Configs {
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
           .createWithDefault(DEFAULT_ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS);
 
+  public static final ConfigEntry<Integer> 
ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES =
+      new ConfigBuilder("gravitino.entityChangeLog.listenerMaxRetries")
+          .doc(
+              "The number of times the poller retries a change log batch for a 
failing listener"
+                  + " before applying 
gravitino.entityChangeLog.listenerFailureAction")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .intConf()
+          .checkValue(value -> value >= 0, 
ConfigConstants.NON_NEGATIVE_NUMBER_ERROR_MSG)
+          .createWithDefault(DEFAULT_ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES);
+
+  public static final ConfigEntry<String> 
ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION =
+      new ConfigBuilder("gravitino.entityChangeLog.listenerFailureAction")
+          .doc(
+              "What the poller does when a listener exhausted its retries: 
EXIT stops this server"
+                  + " because its local caches are known to be stale, SKIP 
drops the batch for that"
+                  + " listener and keeps serving")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .stringConf()
+          .checkValue(
+              value ->
+                  
Arrays.stream(EntityChangeLogPoller.ListenerFailureAction.values())
+                      .anyMatch(action -> 
action.name().equalsIgnoreCase(value)),
+              "The value must be either EXIT or SKIP")
+          
.createWithDefault(DEFAULT_ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION);
+
   public static final ConfigEntry<Long> ENTITY_CHANGE_LOG_RETENTION_SECS =
       new ConfigBuilder("gravitino.entityChangeLog.retentionSecs")
           .doc("The retention time in seconds for entity change logs. Set 0 to 
disable cleanup")
@@ -207,7 +236,9 @@ public class Configs {
 
   public static final ConfigEntry<Long> 
ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS =
       new ConfigBuilder("gravitino.entityChangeLog.cleanupIntervalSecs")
-          .doc("The interval in seconds for pruning expired entity change 
logs")
+          .doc(
+              "The interval in seconds for independently cleaning expired 
entity change logs on a"
+                  + " dedicated thread")
           .version(ConfigConstants.VERSION_1_3_0)
           .longConf()
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogChangeLogListener.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogChangeLogListener.java
index 218e2d1319..9d7443ca88 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogChangeLogListener.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogChangeLogListener.java
@@ -33,6 +33,13 @@ import org.slf4j.LoggerFactory;
  *
  * <p>This listener is called <em>synchronously</em> in the poller thread. 
Implementations must not
  * block or perform expensive I/O; only fast, in-memory cache invalidations 
are permitted.
+ *
+ * <p>This listener never propagates a failure to the poller, so the poller 
never retries a batch
+ * for it. That is deliberate: local-mutation de-duplication ({@link
+ * CatalogManager#consumeLocalMutation}) is single-shot, so re-delivering an 
already-applied batch
+ * would invalidate a catalog this process mutated itself and close its 
still-in-use {@code
+ * IsolatedClassLoader}. Dropping an invalidation is the cheaper failure: the 
catalog cache expires
+ * on access, so staleness is bounded by {@code 
gravitino.catalog.cache.evictionIntervalMs}.
  */
 public class CatalogChangeLogListener implements EntityChangeLogListener {
 
@@ -64,17 +71,33 @@ public class CatalogChangeLogListener implements 
EntityChangeLogListener {
         NameIdentifier ident = identOpt.get();
 
         if (catalogManager.consumeLocalMutation(ident)) {
-          LOG.debug("Skipping catalog cache invalidation for local mutation: 
{}", ident);
+          LOG.debug(
+              "Skipping catalog cache invalidation for local mutation: {}, 
change log id {}",
+              ident,
+              change.getId());
           continue;
         }
 
-        LOG.debug("Invalidating catalog cache due to entity change log: {}", 
ident);
+        // Logged at INFO on purpose: this tears down the cached catalog, 
including its connection
+        // pool and isolated classloader, and it is the main cross-node effect 
of the change log.
+        // CatalogManager logs the matching "Closing catalog" line when the 
eviction runs.
+        LOG.info(
+            "Invalidating catalog cache for {} due to a remote {} recorded in 
change log id {}",
+            ident,
+            change.getOperateType(),
+            change.getId());
         catalogManager.getCatalogCache().invalidate(ident);
       } catch (RuntimeException e) {
+        // Deliberately not rethrown: see the class javadoc. A dropped 
invalidation only costs
+        // bounded staleness here, while a retry of an already-applied batch 
can tear down a
+        // catalog that is still in use.
         LOG.warn(
-            "Failed to process catalog change log record: fullName={}, 
entityType={}",
+            "Failed to process catalog change log record: id={}, fullName={}, 
entityType={}, "
+                + "operateType={}",
+            change.getId(),
             change.getFullName(),
             change.getEntityType(),
+            change.getOperateType(),
             e);
       }
     }
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index 35d0c3fdb5..17b0d58bcf 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -461,7 +461,9 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
     if (!trackLocalMutations) {
       return;
     }
-    localMutationCounts.computeIfAbsent(ident, k -> new 
AtomicInteger()).incrementAndGet();
+    int pending =
+        localMutationCounts.computeIfAbsent(ident, k -> new 
AtomicInteger()).incrementAndGet();
+    LOG.debug("Marked a local mutation for catalog {}, {} pending marker(s)", 
ident, pending);
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogCleaner.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogCleaner.java
new file mode 100644
index 0000000000..29c3ca6bd1
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogCleaner.java
@@ -0,0 +1,173 @@
+/*
+ * 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.storage.relational;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Periodically removes expired rows from {@code entity_change_log} on a 
dedicated thread.
+ *
+ * <p>Cleanup is intentionally independent from polling. The database 
calculates expiration using
+ * its own clock, which is also the clock used when change records are 
inserted.
+ *
+ * <p>Retention is validated against the poll interval. A retention shorter 
than a few poll cycles
+ * lets this cleaner delete records before every node had a chance to consume 
them, which loses
+ * invalidations silently, so such a configuration is rejected at startup 
instead.
+ */
+public class EntityChangeLogCleaner implements AutoCloseable {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(EntityChangeLogCleaner.class);
+
+  /** Upper bound for the delay before the first cleanup run. */
+  private static final long MAX_INITIAL_DELAY_MS = 
TimeUnit.MINUTES.toMillis(10);
+
+  /**
+   * How many poll cycles a change record must survive at minimum. A record 
has to outlive more than
+   * one cycle, because a node can miss cycles while it is restarting, stalled 
in a long GC pause,
+   * or paused retrying a failed listener.
+   */
+  private static final long MIN_RETENTION_POLL_CYCLES = 10;
+
+  private final long retentionMs;
+  private final long cleanupIntervalMs;
+
+  private ScheduledExecutorService scheduler;
+
+  /**
+   * Creates an entity change log cleaner.
+   *
+   * @param retentionMs retention time in milliseconds, or 0 to disable cleanup
+   * @param cleanupIntervalMs interval between cleanup runs in milliseconds
+   * @param pollIntervalMs interval between poller cycles in milliseconds, 
used to reject a
+   *     retention that would drop records before the pollers can consume them
+   */
+  public EntityChangeLogCleaner(long retentionMs, long cleanupIntervalMs, long 
pollIntervalMs) {
+    Preconditions.checkArgument(retentionMs >= 0, "retentionMs must be 
non-negative");
+    Preconditions.checkArgument(cleanupIntervalMs > 0, "cleanupIntervalMs must 
be positive");
+    Preconditions.checkArgument(pollIntervalMs > 0, "pollIntervalMs must be 
positive");
+    // Divide rather than multiply: pollIntervalMs comes from configuration 
and a multiplication
+    // could overflow.
+    Preconditions.checkArgument(
+        retentionMs == 0 || retentionMs / MIN_RETENTION_POLL_CYCLES >= 
pollIntervalMs,
+        "retentionMs (%s) must be 0 to disable cleanup, or at least %s times 
the poll interval "
+            + "(%s ms); a shorter retention can prune change records before 
every node consumed "
+            + "them and silently lose cache invalidations",
+        retentionMs,
+        MIN_RETENTION_POLL_CYCLES,
+        pollIntervalMs);
+    this.retentionMs = retentionMs;
+    this.cleanupIntervalMs = cleanupIntervalMs;
+  }
+
+  /** Starts the dedicated cleanup thread when automatic cleanup is enabled. */
+  public void start() {
+    if (retentionMs == 0) {
+      LOG.info("Automatic entity change log cleanup is disabled");
+      return;
+    }
+
+    scheduler =
+        Executors.newSingleThreadScheduledExecutor(
+            runnable -> {
+              Thread thread = new Thread(runnable);
+              thread.setName("Gravitino-EntityChangeLogCleaner");
+              thread.setDaemon(true);
+              return thread;
+            });
+    long initialDelayMs = initialDelayMs();
+    scheduler.scheduleWithFixedDelay(
+        this::cleanExpiredChanges, initialDelayMs, cleanupIntervalMs, 
TimeUnit.MILLISECONDS);
+    LOG.info(
+        "Starting entity change log cleaner with retention {} ms, cleanup 
interval {} ms and "
+            + "initial delay {} ms",
+        retentionMs,
+        cleanupIntervalMs,
+        initialDelayMs);
+  }
+
+  /**
+   * Returns a randomized short delay before the first run. It is deliberately 
much smaller than the
+   * cleanup interval, otherwise a server restarted more often than that 
interval would never clean
+   * up. The randomization spreads the first run of the HA nodes, which all 
delete from the same
+   * rows.
+   */
+  @VisibleForTesting
+  long initialDelayMs() {
+    long bound = Math.min(cleanupIntervalMs, MAX_INITIAL_DELAY_MS);
+    return ThreadLocalRandom.current().nextLong(bound) + 1;
+  }
+
+  @VisibleForTesting
+  void cleanExpiredChanges() {
+    if (retentionMs == 0) {
+      return;
+    }
+
+    long totalPrunedRows = 0;
+    try {
+      int prunedRows;
+      do {
+        prunedRows =
+            SessionUtils.doWithCommitAndFetchResult(
+                EntityChangeLogMapper.class, mapper -> 
mapper.pruneOldEntityChanges(retentionMs));
+        totalPrunedRows += prunedRows;
+      } while (prunedRows == 
EntityChangeLogMapper.ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE
+          && !Thread.currentThread().isInterrupted());
+
+      if (totalPrunedRows > 0) {
+        LOG.info(
+            "Pruned {} entity change log record(s) older than {} ms, measured 
with database time",
+            totalPrunedRows,
+            retentionMs);
+      } else {
+        // Without this line a cleaner that never runs looks exactly like one 
that finds nothing.
+        LOG.debug("No entity change log record older than {} ms to prune", 
retentionMs);
+      }
+    } catch (Exception e) {
+      LOG.warn(
+          "Failed to clean expired entity change logs after pruning {} 
record(s)",
+          totalPrunedRows,
+          e);
+    }
+  }
+
+  @Override
+  public void close() {
+    if (scheduler != null) {
+      scheduler.shutdown();
+      try {
+        if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+          scheduler.shutdownNow();
+        }
+      } catch (InterruptedException e) {
+        scheduler.shutdownNow();
+        Thread.currentThread().interrupt();
+      }
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogListener.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogListener.java
index bd9d54601b..b57d24310b 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogListener.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogListener.java
@@ -28,6 +28,10 @@ public interface EntityChangeLogListener {
   /**
    * Handles a batch of entity changes.
    *
+   * <p>If this method throws, the poller may retry the same batch for this 
listener.
+   * Implementations must make the callback atomic or tolerate retrying 
changes that were applied
+   * before the exception.
+   *
    * @param changes the entity changes fetched in one poller cycle
    */
   void onEntityChange(List<EntityChangeRecord> changes);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogPoller.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogPoller.java
index 5719cea9f6..34c7e04b1c 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogPoller.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityChangeLogPoller.java
@@ -20,13 +20,13 @@ package org.apache.gravitino.storage.relational;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
-import java.util.Collections;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
-import java.util.function.LongSupplier;
+import javax.annotation.Nullable;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -37,71 +37,101 @@ import org.slf4j.LoggerFactory;
  * Global poller for {@code entity_change_log}.
  *
  * <p>The poller owns the single high-water mark for a Gravitino server 
process and dispatches each
- * consumed batch to registered listeners. Listeners should only perform 
idempotent local cache
- * invalidation. The cursor always advances after dispatch regardless of 
individual listener
- * failures, so a faulty listener cannot block other listeners or prevent 
pruning.
+ * consumed batch to registered listeners. The cursor advances only after 
every listener applies the
+ * batch. If a listener throws, the immutable batch stays in memory and the 
next poll retries only
+ * the listeners that have not succeeded yet.
+ *
+ * <p>A listener that throws after partially applying a batch receives the 
whole batch again, so
+ * listeners must make each callback atomic or tolerate retrying changes they 
already applied. A
+ * listener that cannot satisfy that contract must swallow its own failures, 
as {@code
+ * CatalogChangeLogListener} does.
+ *
+ * <p>Retries are bounded by {@code maxListenerRetries}. While a batch is 
paused no new batch is
+ * fetched, so a permanently failing listener would otherwise freeze cache 
invalidation for the
+ * whole process. When the bound is reached, {@link ListenerFailureAction} 
decides what happens:
+ * {@code EXIT} stops this server (the local caches are known to be stale, so 
serving from them
+ * would trade correctness for availability), {@code SKIP} drops the failed 
listeners from the batch
+ * and advances the cursor.
  */
 public class EntityChangeLogPoller implements AutoCloseable {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(EntityChangeLogPoller.class);
 
-  /** Max entity-change rows to fetch per poller cycle. */
-  private static final int ENTITY_CHANGE_POLLER_MAX_ROWS = 500;
+  /**
+   * Max entity-change rows to fetch per batch. A paused batch is retained in 
memory until every
+   * listener applies it, so this also bounds the poller's retained heap.
+   */
+  private static final int ENTITY_CHANGE_POLLER_MAX_ROWS = 2000;
+
+  /** Max records rendered in a batch summary log line. */
+  private static final int MAX_SUMMARIZED_RECORDS = 20;
+
+  /** What the poller does when a listener keeps failing after {@code 
maxListenerRetries}. */
+  public enum ListenerFailureAction {
+    /** Stop this server process, because its local caches are known to be 
stale. */
+    EXIT,
+    /** Drop the failing listener from the batch, advance the cursor and keep 
serving. */
+    SKIP
+  }
 
   private final List<EntityChangeLogListener> listeners = new 
CopyOnWriteArrayList<>();
   private final long pollIntervalSecs;
-  private final long retentionMs;
-  private final long cleanupIntervalMs;
-  private final LongSupplier clockMs;
+  private final int maxListenerRetries;
+  private final ListenerFailureAction listenerFailureAction;
+  private final Runnable exitHandler;
+
+  @Nullable private BatchDelivery pendingDelivery;
 
   private ScheduledExecutorService scheduler;
   private volatile long entityPollHighWaterId = 0;
-  private volatile long lastCleanupMs = Long.MIN_VALUE;
-
-  /**
-   * Creates an {@link EntityChangeLogPoller}.
-   *
-   * @param pollIntervalSecs interval between successive polling cycles
-   */
-  public EntityChangeLogPoller(long pollIntervalSecs) {
-    this(
-        pollIntervalSecs,
-        TimeUnit.DAYS.toMillis(1),
-        TimeUnit.HOURS.toMillis(1),
-        System::currentTimeMillis);
-  }
 
   /**
    * Creates an {@link EntityChangeLogPoller}.
    *
    * @param pollIntervalSecs interval between successive polling cycles
-   * @param retentionMs entity change retention in milliseconds, or 0 to 
disable cleanup
-   * @param cleanupIntervalMs interval between successive cleanup attempts in 
milliseconds
+   * @param maxListenerRetries how many times a failing listener is retried 
for the same batch
+   *     before {@code listenerFailureAction} is applied
+   * @param listenerFailureAction what to do once a listener exhausted its 
retries
    */
-  public EntityChangeLogPoller(long pollIntervalSecs, long retentionMs, long 
cleanupIntervalMs) {
-    this(pollIntervalSecs, retentionMs, cleanupIntervalMs, 
System::currentTimeMillis);
+  public EntityChangeLogPoller(
+      long pollIntervalSecs, int maxListenerRetries, ListenerFailureAction 
listenerFailureAction) {
+    // System.exit() runs the JVM shutdown hooks, which is where 
GravitinoServer performs its
+    // graceful stop, so in-flight requests still get a chance to finish.
+    this(pollIntervalSecs, maxListenerRetries, listenerFailureAction, () -> 
System.exit(1));
   }
 
   @VisibleForTesting
   EntityChangeLogPoller(
-      long pollIntervalSecs, long retentionMs, long cleanupIntervalMs, 
LongSupplier clockMs) {
+      long pollIntervalSecs,
+      int maxListenerRetries,
+      ListenerFailureAction listenerFailureAction,
+      Runnable exitHandler) {
     Preconditions.checkArgument(pollIntervalSecs > 0, "pollIntervalSecs must 
be positive");
-    Preconditions.checkArgument(retentionMs >= 0, "retentionMs must be 
non-negative");
-    Preconditions.checkArgument(cleanupIntervalMs > 0, "cleanupIntervalMs must 
be positive");
+    Preconditions.checkArgument(maxListenerRetries >= 0, "maxListenerRetries 
must be non-negative");
+    Preconditions.checkArgument(
+        listenerFailureAction != null, "listenerFailureAction cannot be null");
     this.pollIntervalSecs = pollIntervalSecs;
-    this.retentionMs = retentionMs;
-    this.cleanupIntervalMs = cleanupIntervalMs;
-    this.clockMs = clockMs;
+    this.maxListenerRetries = maxListenerRetries;
+    this.listenerFailureAction = listenerFailureAction;
+    this.exitHandler = exitHandler;
   }
 
   /**
    * Registers a listener to receive future entity change batches.
    *
+   * <p>A listener only receives batches fetched after it was registered. In 
particular, if a batch
+   * is currently paused by a failing listener, the newly registered listener 
does not receive that
+   * batch and the cursor moves past it once the batch completes.
+   *
    * @param listener the listener to register
    */
   public void registerListener(EntityChangeLogListener listener) {
     Preconditions.checkArgument(listener != null, "listener cannot be null");
     listeners.add(listener);
+    LOG.info(
+        "Registered entity change log listener {}, {} listener(s) active",
+        listener.getClass().getName(),
+        listeners.size());
   }
 
   /**
@@ -111,7 +141,12 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
    */
   public void unregisterListener(EntityChangeLogListener listener) {
     Preconditions.checkArgument(listener != null, "listener cannot be null");
-    listeners.remove(listener);
+    if (listeners.remove(listener)) {
+      LOG.info(
+          "Unregistered entity change log listener {}, {} listener(s) active",
+          listener.getClass().getName(),
+          listeners.size());
+    }
   }
 
   /**
@@ -128,6 +163,14 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
         getOrDefault(
             SessionUtils.getWithoutCommit(
                 EntityChangeLogMapper.class, 
EntityChangeLogMapper::selectMaxChangeId));
+    LOG.info(
+        "Starting entity change log poller at high-water id {} with a {} 
second interval, "
+            + "{} listener(s) registered, maxListenerRetries={}, 
listenerFailureAction={}",
+        entityPollHighWaterId,
+        pollIntervalSecs,
+        listeners.size(),
+        maxListenerRetries,
+        listenerFailureAction);
 
     scheduler =
         Executors.newSingleThreadScheduledExecutor(
@@ -154,6 +197,19 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
         Thread.currentThread().interrupt();
       }
     }
+
+    // The final cursor tells where this node stopped consuming, which is the 
starting point when
+    // comparing nodes after an incident.
+    LOG.info(
+        "Stopped entity change log poller at high-water id {}{}",
+        entityPollHighWaterId,
+        pendingDelivery == null
+            ? ""
+            : ", with an unapplied batch id range ["
+                + pendingDelivery.firstChangeId()
+                + ", "
+                + pendingDelivery.lastChangeId
+                + "]");
   }
 
   @VisibleForTesting
@@ -164,35 +220,79 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
       if (handleInterruptIfAny(e, "Entity change poll")) {
         return;
       }
-      LOG.warn("Entity change poll failed", e);
+      LOG.warn("Entity change poll failed at high-water id {}", 
entityPollHighWaterId, e);
     }
   }
 
   private synchronized void doPollChanges() {
-    List<EntityChangeRecord> changes = fetchEntityChanges();
-    if (changes.isEmpty()) {
-      pruneExpiredChangesIfNeeded();
+    BatchDelivery delivery = pendingDelivery;
+    if (delivery != null) {
+      LOG.info(
+          "Retrying entity change log batch with {} record(s), id range [{}, 
{}], attempt {} of "
+              + "{}, for {} pending listener(s)",
+          delivery.changes.size(),
+          delivery.firstChangeId(),
+          delivery.lastChangeId,
+          delivery.attempts,
+          maxListenerRetries + 1,
+          delivery.pendingListeners.size());
+      deliver(delivery);
       return;
     }
 
-    long maxSeenId = entityPollHighWaterId;
-    for (EntityChangeRecord change : changes) {
-      if (change.getId() > maxSeenId) {
-        maxSeenId = change.getId();
-      }
+    delivery = fetchNextDelivery();
+    if (delivery != null) {
+      deliver(delivery);
     }
+  }
 
-    List<EntityChangeRecord> dispatchedChanges = 
Collections.unmodifiableList(changes);
-    for (EntityChangeLogListener listener : listeners) {
-      try {
-        listener.onEntityChange(dispatchedChanges);
-      } catch (Exception e) {
-        LOG.warn("Entity change listener {} failed", 
listener.getClass().getName(), e);
-      }
+  @Nullable
+  private BatchDelivery fetchNextDelivery() {
+    List<EntityChangeRecord> changes = fetchEntityChanges();
+    if (changes.isEmpty()) {
+      return null;
     }
 
-    entityPollHighWaterId = maxSeenId;
-    pruneExpiredChangesIfNeeded();
+    List<EntityChangeRecord> immutableChanges = List.copyOf(changes);
+    long lastChangeId = immutableChanges.get(immutableChanges.size() - 
1).getId();
+    BatchDelivery delivery =
+        new BatchDelivery(immutableChanges, lastChangeId, 
List.copyOf(listeners), 1);
+    LOG.debug(
+        "Fetched {} entity change log record(s) after cursor {}, id range [{}, 
{}]: {}",
+        immutableChanges.size(),
+        entityPollHighWaterId,
+        delivery.firstChangeId(),
+        delivery.lastChangeId,
+        summarize(immutableChanges));
+    return delivery;
+  }
+
+  /**
+   * Renders a batch as {@code id:ENTITY_TYPE:OPERATE_TYPE:fullName} entries, 
so a DEBUG log answers
+   * "which invalidations did this node actually see" without querying the 
database. Long batches
+   * are truncated, because the whole batch shares one id range that is 
already logged.
+   */
+  private static String summarize(List<EntityChangeRecord> changes) {
+    StringBuilder builder = new StringBuilder();
+    int limit = Math.min(changes.size(), MAX_SUMMARIZED_RECORDS);
+    for (int i = 0; i < limit; i++) {
+      EntityChangeRecord change = changes.get(i);
+      if (i > 0) {
+        builder.append(", ");
+      }
+      builder
+          .append(change.getId())
+          .append(':')
+          .append(change.getEntityType())
+          .append(':')
+          .append(change.getOperateType())
+          .append(':')
+          .append(change.getFullName());
+    }
+    if (changes.size() > limit) {
+      builder.append(", ... ").append(changes.size() - limit).append(" more");
+    }
+    return builder.toString();
   }
 
   private List<EntityChangeRecord> fetchEntityChanges() {
@@ -218,31 +318,145 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
     return false;
   }
 
-  private void pruneExpiredChangesIfNeeded() {
-    if (retentionMs <= 0) {
+  private static long getOrDefault(Long value) {
+    return value == null ? 0L : value;
+  }
+
+  private void deliver(BatchDelivery delivery) {
+    List<EntityChangeLogListener> failedListeners = notifyListeners(delivery);
+    if (failedListeners.isEmpty()) {
+      advanceCursor(delivery);
       return;
     }
 
-    long now = clockMs.getAsLong();
-    if (lastCleanupMs != Long.MIN_VALUE && now - lastCleanupMs < 
cleanupIntervalMs) {
+    if (delivery.attempts > maxListenerRetries) {
+      handleExhaustedRetries(delivery, failedListeners);
       return;
     }
 
-    long before = now - retentionMs;
-    try {
-      SessionUtils.doWithoutCommit(
-          EntityChangeLogMapper.class, mapper -> 
mapper.pruneOldEntityChanges(before));
-    } catch (Exception e) {
-      LOG.warn("Failed to prune expired entity change logs before {}", before, 
e);
-    } finally {
-      // Always advance the cursor regardless of success or failure. A 
transient DB error
-      // should not cause repeated prune attempts on every poll cycle (every 
few seconds)
-      // until one eventually succeeds — the next cleanup will happen after 
cleanupIntervalMs.
-      lastCleanupMs = now;
+    pendingDelivery = delivery.retryOnly(failedListeners);
+    LOG.error(
+        "Entity change log cursor is paused at id {} because {} listener(s) 
failed to apply batch "
+            + "id range [{}, {}] (attempt {} of {})",
+        entityPollHighWaterId,
+        failedListeners.size(),
+        delivery.firstChangeId(),
+        delivery.lastChangeId,
+        delivery.attempts,
+        maxListenerRetries + 1);
+  }
+
+  private void advanceCursor(BatchDelivery delivery) {
+    long previousHighWaterId = entityPollHighWaterId;
+    entityPollHighWaterId = delivery.lastChangeId;
+    pendingDelivery = null;
+    LOG.info(
+        "Consumed {} entity change log record(s), id range [{}, {}]; cursor 
advanced from {} to {}; "
+            + "newest record is ~{} ms old",
+        delivery.changes.size(),
+        delivery.firstChangeId(),
+        delivery.lastChangeId,
+        previousHighWaterId,
+        entityPollHighWaterId,
+        delivery.approximateLagMs());
+  }
+
+  private void handleExhaustedRetries(
+      BatchDelivery delivery, List<EntityChangeLogListener> failedListeners) {
+    List<String> failedListenerNames = new ArrayList<>();
+    for (EntityChangeLogListener listener : failedListeners) {
+      failedListenerNames.add(listener.getClass().getName());
+    }
+
+    if (listenerFailureAction == ListenerFailureAction.EXIT) {
+      LOG.error(
+          "Stopping this server: listener(s) {} failed to apply entity change 
log batch id range "
+              + "[{}, {}] after {} attempt(s), so local caches are stale and 
cannot be trusted",
+          failedListenerNames,
+          delivery.firstChangeId(),
+          delivery.lastChangeId,
+          delivery.attempts);
+      exitHandler.run();
+      return;
     }
+
+    LOG.error(
+        "Dropping entity change log batch id range [{}, {}] for listener(s) {} 
after {} attempt(s);"
+            + " their local caches may be stale until the affected entries 
expire",
+        delivery.firstChangeId(),
+        delivery.lastChangeId,
+        failedListenerNames,
+        delivery.attempts);
+    advanceCursor(delivery);
   }
 
-  private static long getOrDefault(Long value) {
-    return value == null ? 0L : value;
+  private List<EntityChangeLogListener> notifyListeners(BatchDelivery 
delivery) {
+    List<EntityChangeLogListener> failedListeners = new ArrayList<>();
+    for (EntityChangeLogListener listener : delivery.pendingListeners) {
+      if (!listeners.contains(listener)) {
+        LOG.debug(
+            "Skipping unregistered entity change log listener {} for batch id 
range [{}, {}]",
+            listener.getClass().getName(),
+            delivery.firstChangeId(),
+            delivery.lastChangeId);
+        continue;
+      }
+
+      try {
+        listener.onEntityChange(delivery.changes);
+        LOG.debug(
+            "Entity change log listener {} consumed batch id range [{}, {}]",
+            listener.getClass().getName(),
+            delivery.firstChangeId(),
+            delivery.lastChangeId);
+      } catch (Exception e) {
+        failedListeners.add(listener);
+        LOG.warn(
+            "Entity change log listener {} failed to consume batch id range 
[{}, {}]",
+            listener.getClass().getName(),
+            delivery.firstChangeId(),
+            delivery.lastChangeId,
+            e);
+      }
+    }
+    return failedListeners;
+  }
+
+  private static class BatchDelivery {
+    private final List<EntityChangeRecord> changes;
+    private final long lastChangeId;
+    private final List<EntityChangeLogListener> pendingListeners;
+
+    /** How many times this batch has been dispatched, starting at 1 for the 
initial dispatch. */
+    private final int attempts;
+
+    private BatchDelivery(
+        List<EntityChangeRecord> changes,
+        long lastChangeId,
+        List<EntityChangeLogListener> pendingListeners,
+        int attempts) {
+      this.changes = changes;
+      this.lastChangeId = lastChangeId;
+      this.pendingListeners = pendingListeners;
+      this.attempts = attempts;
+    }
+
+    private BatchDelivery retryOnly(List<EntityChangeLogListener> 
failedListeners) {
+      return new BatchDelivery(changes, lastChangeId, 
List.copyOf(failedListeners), attempts + 1);
+    }
+
+    private long firstChangeId() {
+      return changes.get(0).getId();
+    }
+
+    /**
+     * How far this node is behind the newest record of the batch, in 
milliseconds. It compares the
+     * DB-generated {@code created_at} with the local JVM clock, so it is only 
an estimate and can
+     * even be negative when the two clocks disagree. It is still the quickest 
way to spot a node
+     * that fell behind: a steadily growing value means this node is not 
keeping up.
+     */
+    private long approximateLagMs() {
+      return System.currentTimeMillis() - changes.get(changes.size() - 
1).getCreatedAt();
+    }
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index a45d4b7d96..f14f41c9a1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -26,10 +26,12 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
@@ -77,6 +79,7 @@ public class RelationalEntityStore
   private RelationalBackend backend;
   private RelationalGarbageCollector garbageCollector;
   private EntityChangeLogPoller entityChangeLogPoller;
+  private EntityChangeLogCleaner entityChangeLogCleaner;
   private EntityCache cache;
 
   @VisibleForTesting
@@ -99,16 +102,23 @@ public class RelationalEntityStore
     this.garbageCollector = new RelationalGarbageCollector(backend, config);
     this.garbageCollector.start();
 
-    // The change-log poller is a side module of the entity store: it polls 
the entity_change_log
-    // table this store writes to, dispatches batches to registered listeners 
(e.g. for cross-node
-    // cache invalidation), and prunes expired rows. Like the garbage 
collector, it is owned and
-    // lifecycle-managed by the store itself.
+    // Polling and cleanup use separate single-threaded schedulers. Polling 
only dispatches changes
+    // to local listeners, while cleanup independently removes records beyond 
the retention period.
     this.entityChangeLogPoller =
         new EntityChangeLogPoller(
             config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS),
+            config.get(Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES),
+            EntityChangeLogPoller.ListenerFailureAction.valueOf(
+                config
+                    .get(Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)
+                    .toUpperCase(Locale.ROOT)));
+    this.entityChangeLogCleaner =
+        new EntityChangeLogCleaner(
             
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_RETENTION_SECS)),
-            
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)));
+            
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)),
+            
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)));
     this.entityChangeLogPoller.start();
+    this.entityChangeLogCleaner.start();
   }
 
   private RelationalBackend createRelationalEntityBackend(Config config) {
@@ -276,10 +286,39 @@ public class RelationalEntityStore
 
   @Override
   public void close() throws IOException {
-    cache.clear();
-    entityChangeLogPoller.close();
-    garbageCollector.close();
-    backend.close();
+    // Keep shutting the remaining components down even if one of them fails, 
and tolerate a
+    // half-finished initialize() that left some of them null.
+    IOException failure = null;
+    failure = closeComponent(failure, "entity cache", cache == null ? null : 
cache::clear);
+    failure = closeComponent(failure, "entity change log poller", 
entityChangeLogPoller);
+    failure = closeComponent(failure, "entity change log cleaner", 
entityChangeLogCleaner);
+    failure = closeComponent(failure, "relational garbage collector", 
garbageCollector);
+    failure = closeComponent(failure, "relational backend", backend);
+
+    if (failure != null) {
+      throw failure;
+    }
+  }
+
+  private static IOException closeComponent(
+      @Nullable IOException failure, String name, @Nullable AutoCloseable 
component) {
+    if (component == null) {
+      return failure;
+    }
+
+    try {
+      component.close();
+      return failure;
+    } catch (Exception e) {
+      LOGGER.warn("Failed to close {}", name, e);
+      if (failure != null) {
+        failure.addSuppressed(e);
+        return failure;
+      }
+      return e instanceof IOException
+          ? (IOException) e
+          : new IOException("Failed to close " + name, e);
+    }
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogMapper.java
index d82754947c..b82faceb7e 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogMapper.java
@@ -36,6 +36,9 @@ public interface EntityChangeLogMapper {
 
   String ENTITY_CHANGE_LOG_TABLE_NAME = "entity_change_log";
 
+  /** Max expired rows removed in one cleanup transaction. */
+  int ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE = 1000;
+
   @SelectProvider(type = EntityChangeLogSQLProviderFactory.class, method = 
"selectEntityChanges")
   List<EntityChangeRecord> selectEntityChanges(
       @Param("lastConsumedId") long lastConsumedId, @Param("maxRows") int 
maxRows);
@@ -51,5 +54,5 @@ public interface EntityChangeLogMapper {
       @Param("operateType") OperateType operateType);
 
   @DeleteProvider(type = EntityChangeLogSQLProviderFactory.class, method = 
"pruneOldEntityChanges")
-  void pruneOldEntityChanges(@Param("before") long before);
+  int pruneOldEntityChanges(@Param("retentionMs") long retentionMs);
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogSQLProviderFactory.java
index c48f2c0a1a..36620bd905 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/EntityChangeLogSQLProviderFactory.java
@@ -67,7 +67,7 @@ public class EntityChangeLogSQLProviderFactory {
     return getProvider().insertEntityChange(metalakeName, entityType, 
fullName, operateType);
   }
 
-  public static String pruneOldEntityChanges(@Param("before") long before) {
-    return getProvider().pruneOldEntityChanges(before);
+  public static String pruneOldEntityChanges(@Param("retentionMs") long 
retentionMs) {
+    return getProvider().pruneOldEntityChanges(retentionMs);
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DatabaseTimeSQL.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DatabaseTimeSQL.java
new file mode 100644
index 0000000000..65d8f6b547
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DatabaseTimeSQL.java
@@ -0,0 +1,47 @@
+/*
+ * 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.storage.relational.mapper.provider;
+
+/**
+ * SQL expressions that evaluate to the current time in epoch milliseconds, 
using the database
+ * clock.
+ *
+ * <p>Prefer these over a timestamp computed in the JVM whenever a value is 
written to or compared
+ * against a millisecond column: in a multi-node deployment every server has 
its own clock, while
+ * the database provides a single clock all of them agree on.
+ *
+ * <p>The expressions live here, rather than in one SQL provider, so that 
every provider can reuse
+ * them. Most providers still inline the MySQL flavour; they can be migrated 
to {@link #MYSQL}
+ * separately, since changing them is unrelated to any single feature.
+ */
+public final class DatabaseTimeSQL {
+
+  /**
+   * MySQL flavour, also used by H2 in {@code MODE=MYSQL}. This is the 
expression that 30+ SQL
+   * providers currently inline.
+   */
+  public static final String MYSQL =
+      "((UNIX_TIMESTAMP() * 1000.0) + EXTRACT(MICROSECOND FROM 
CURRENT_TIMESTAMP(3)) / 1000)";
+
+  /** PostgreSQL flavour. */
+  public static final String POSTGRESQL =
+      "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+
+  private DatabaseTimeSQL() {}
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/EntityChangeLogBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/EntityChangeLogBaseSQLProvider.java
index 78695830cc..d5c4685efb 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/EntityChangeLogBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/EntityChangeLogBaseSQLProvider.java
@@ -18,13 +18,26 @@
  */
 package org.apache.gravitino.storage.relational.mapper.provider.base;
 
+import static 
org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper.ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE;
 import static 
org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper.ENTITY_CHANGE_LOG_TABLE_NAME;
 
+import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.ibatis.annotations.Param;
 
 public class EntityChangeLogBaseSQLProvider {
 
+  /**
+   * DB-side expression for "now" in milliseconds; PostgreSQL overrides both 
statements that use it
+   * in its own provider.
+   *
+   * <p>Insertion and expiration both use this expression, so retention is 
measured entirely with
+   * the database clock and is immune to clock skew between Gravitino nodes. 
Round-trip behaviour is
+   * verified by {@code 
TestEntityChangeLogMapper#testEntityChangeLogInsertAndSelect}, which asserts
+   * the persisted value is within 1 s of the JVM clock.
+   */
+  private static final String CURRENT_TIME_MILLIS_SQL = DatabaseTimeSQL.MYSQL;
+
   /**
    * Cursor-advance contract for the entity change poller: {@code id} is 
monotonic and unique, so
    * callers only need to remember the last consumed id.
@@ -49,15 +62,7 @@ public class EntityChangeLogBaseSQLProvider {
     return "SELECT COALESCE(MAX(id), 0) FROM " + ENTITY_CHANGE_LOG_TABLE_NAME;
   }
 
-  /**
-   * The {@code (UNIX_TIMESTAMP() * 1000.0) + EXTRACT(MICROSECOND FROM 
CURRENT_TIMESTAMP(3)) / 1000}
-   * expression is the established codebase convention for DB-generated 
millisecond timestamps,
-   * shared with 27+ other base providers (TableMetaBaseSQLProvider, 
FilesetVersionBaseSQLProvider,
-   * etc.). It works on MySQL natively and on H2 in {@code MODE=MYSQL}; 
PostgreSQL overrides this
-   * method in its own provider. Round-trip behaviour is verified by {@code
-   * TestEntityChangeLogMapper#testEntityChangeLogInsertAndSelect}, which 
asserts the persisted
-   * value is within 1 s of the JVM clock.
-   */
+  /** Inserts a change record, stamping {@code created_at} with {@link 
#CURRENT_TIME_MILLIS_SQL}. */
   public String insertEntityChange(
       @Param("metalakeName") String metalakeName,
       @Param("entityType") String entityType,
@@ -67,16 +72,23 @@ public class EntityChangeLogBaseSQLProvider {
         + ENTITY_CHANGE_LOG_TABLE_NAME
         + " (metalake_name, entity_type, entity_full_name, operate_type, 
created_at)"
         + " VALUES (#{metalakeName}, #{entityType}, #{fullName}, 
#{operateType},"
-        + " (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000)";
+        + CURRENT_TIME_MILLIS_SQL
+        + ")";
   }
 
-  public String pruneOldEntityChanges(@Param("before") long before) {
+  public String pruneOldEntityChanges(@Param("retentionMs") long retentionMs) {
     // Keep the retention window conservative. A running server can be delayed 
by long GC pauses,
-    // network isolation, scheduler stalls, or clock skew between nodes; 
pruning too aggressively
-    // can let that server miss an invalidation while its local cache is still 
warm.
+    // network isolation, or scheduler stalls; pruning too aggressively can 
let that server miss an
+    // invalidation while its local cache is still warm.
+    //
+    // No ORDER BY here, unlike the PostgreSQL provider: H2's DELETE grammar 
accepts LIMIT but not
+    // ORDER BY. Every matched row is expired anyway, so the deletion order 
does not matter; the
+    // cleaner randomizes its start time to keep HA nodes from deleting the 
same rows at once.
     return "DELETE FROM "
         + ENTITY_CHANGE_LOG_TABLE_NAME
-        + " WHERE created_at < #{before} LIMIT 1000";
+        + " WHERE created_at < "
+        + CURRENT_TIME_MILLIS_SQL
+        + " - #{retentionMs} LIMIT "
+        + ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE;
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/EntityChangeLogPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/EntityChangeLogPostgreSQLProvider.java
index d6cdbc5851..4f41fe018f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/EntityChangeLogPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/EntityChangeLogPostgreSQLProvider.java
@@ -18,14 +18,22 @@
  */
 package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
 
+import static 
org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper.ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE;
 import static 
org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper.ENTITY_CHANGE_LOG_TABLE_NAME;
 
+import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.EntityChangeLogBaseSQLProvider;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.ibatis.annotations.Param;
 
 public class EntityChangeLogPostgreSQLProvider extends 
EntityChangeLogBaseSQLProvider {
 
+  /**
+   * PostgreSQL flavour of the DB-side "now in milliseconds" expression. 
Insertion and expiration
+   * share it, so retention is measured entirely with the database clock.
+   */
+  private static final String CURRENT_TIME_MILLIS_SQL = 
DatabaseTimeSQL.POSTGRESQL;
+
   @Override
   public String insertEntityChange(
       @Param("metalakeName") String metalakeName,
@@ -36,15 +44,20 @@ public class EntityChangeLogPostgreSQLProvider extends 
EntityChangeLogBaseSQLPro
         + ENTITY_CHANGE_LOG_TABLE_NAME
         + " (metalake_name, entity_type, entity_full_name, operate_type, 
created_at)"
         + " VALUES (#{metalakeName}, #{entityType}, #{fullName}, 
#{operateType},"
-        + " CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT))";
+        + CURRENT_TIME_MILLIS_SQL
+        + ")";
   }
 
   @Override
-  public String pruneOldEntityChanges(@Param("before") long before) {
+  public String pruneOldEntityChanges(@Param("retentionMs") long retentionMs) {
     return "DELETE FROM "
         + ENTITY_CHANGE_LOG_TABLE_NAME
         + " WHERE id IN (SELECT id FROM "
         + ENTITY_CHANGE_LOG_TABLE_NAME
-        + " WHERE created_at < #{before} ORDER BY created_at LIMIT 1000)";
+        + " WHERE created_at < "
+        + CURRENT_TIME_MILLIS_SQL
+        + " - #{retentionMs} ORDER BY created_at LIMIT "
+        + ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE
+        + ")";
   }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
index 10813b5e64..8cc94e01fb 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.authorization;
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -135,6 +137,8 @@ public class TestAccessControlManager {
     
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java 
b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java
index 936d5336a7..564000a7ca 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.authorization;
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -114,6 +116,8 @@ public class TestOwnerManager {
     
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogChangeLogListener.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogChangeLogListener.java
new file mode 100644
index 0000000000..410dcd5829
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogChangeLogListener.java
@@ -0,0 +1,92 @@
+/*
+ * 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.catalog;
+
+import static org.mockito.ArgumentMatchers.any;
+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 com.github.benmanes.caffeine.cache.Cache;
+import java.util.List;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.CatalogManager.CatalogWrapper;
+import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
+import org.apache.gravitino.storage.relational.po.cache.OperateType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestCatalogChangeLogListener {
+
+  @Test
+  @SuppressWarnings("unchecked")
+  void testProcessesRemainingChangesAndSwallowsFailure() {
+    CatalogManager catalogManager = mock(CatalogManager.class);
+    Cache<NameIdentifier, CatalogWrapper> catalogCache = mock(Cache.class);
+    NameIdentifier failedIdentifier = NameIdentifier.of("metalake", "failed");
+    NameIdentifier successfulIdentifier = NameIdentifier.of("metalake", 
"successful");
+
+    when(catalogManager.getCatalogCache()).thenReturn(catalogCache);
+    when(catalogManager.consumeLocalMutation(failedIdentifier))
+        .thenThrow(new RuntimeException("invalidation failed"));
+
+    CatalogChangeLogListener listener = new 
CatalogChangeLogListener(catalogManager);
+
+    // The failure must not reach the poller. A retried batch would 
re-invalidate catalogs this
+    // process mutated itself, because consumeLocalMutation() is single-shot, 
and closing an
+    // in-use CatalogWrapper also closes its IsolatedClassLoader.
+    Assertions.assertDoesNotThrow(
+        () ->
+            listener.onEntityChange(
+                List.of(change(1L, "metalake.failed"), change(2L, 
"metalake.successful"))));
+
+    verify(catalogCache).invalidate(successfulIdentifier);
+    verify(catalogCache, never()).invalidate(failedIdentifier);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  void testSkipsLocalMutationAndNonCatalogRecords() {
+    CatalogManager catalogManager = mock(CatalogManager.class);
+    Cache<NameIdentifier, CatalogWrapper> catalogCache = mock(Cache.class);
+    NameIdentifier localIdentifier = NameIdentifier.of("metalake", "local");
+
+    when(catalogManager.getCatalogCache()).thenReturn(catalogCache);
+    
when(catalogManager.consumeLocalMutation(localIdentifier)).thenReturn(true);
+
+    CatalogChangeLogListener listener = new 
CatalogChangeLogListener(catalogManager);
+
+    Assertions.assertDoesNotThrow(
+        () ->
+            listener.onEntityChange(
+                List.of(
+                    change(1L, "metalake.local"),
+                    change(2L, "metalake"),
+                    change(3L, "metalake.cat.schema"),
+                    new EntityChangeRecord(
+                        4L, "metalake", "SCHEMA", "metalake.cat.sch", 
OperateType.ALTER, 0L))));
+
+    verify(catalogCache, never()).invalidate(any());
+  }
+
+  private static EntityChangeRecord change(long id, String fullName) {
+    return new EntityChangeRecord(id, "metalake", "CATALOG", fullName, 
OperateType.ALTER, 0L);
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java 
b/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
index 0a97ec1efb..943444019e 100644
--- 
a/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.hook;
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -218,6 +220,8 @@ public class TestFilesetHookDispatcher extends 
TestOperationDispatcher {
           
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
           Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 
* 1000L);
           
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+          
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+          
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
           
Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 60 * 
60L);
           
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
           Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java 
b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
index 5b80249042..46504db01c 100644
--- a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
+++ b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.policy;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -199,6 +201,8 @@ public class TestPolicyManager {
     
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java 
b/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java
index aa0cb0fea5..a8a7538ba9 100644
--- a/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java
+++ b/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.stats;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -113,6 +115,8 @@ public class TestStatisticManager {
     
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
index cac53eed61..31e39e3388 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.storage;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -147,6 +149,8 @@ abstract class AbstractEntityStorageTest {
     
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogCleaner.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogCleaner.java
new file mode 100644
index 0000000000..1b13088c51
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogCleaner.java
@@ -0,0 +1,185 @@
+/*
+ * 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.storage.relational;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+public class TestEntityChangeLogCleaner {
+
+  private static final long RETENTION_MS = TimeUnit.DAYS.toMillis(30);
+  private static final long POLL_INTERVAL_MS = TimeUnit.SECONDS.toMillis(3);
+
+  @Test
+  void testRejectsInvalidConfiguration() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new EntityChangeLogCleaner(-1, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new EntityChangeLogCleaner(RETENTION_MS, 0, POLL_INTERVAL_MS));
+  }
+
+  @Test
+  void testRejectsRetentionShorterThanTheConsumptionWindow() {
+    // A retention this short lets the cleaner delete records before every 
node polled them, which
+    // would lose invalidations silently, so it must fail at startup instead.
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new EntityChangeLogCleaner(
+                    POLL_INTERVAL_MS * 9, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS));
+    Assertions.assertTrue(
+        e.getMessage().contains("at least"), "unexpected message: " + 
e.getMessage());
+
+    // Exactly at the bound is accepted, and so is a disabled cleanup with any 
poll interval.
+    Assertions.assertDoesNotThrow(
+        () ->
+            new EntityChangeLogCleaner(
+                POLL_INTERVAL_MS * 10, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS));
+    Assertions.assertDoesNotThrow(
+        () -> new EntityChangeLogCleaner(0, TimeUnit.DAYS.toMillis(1), 
TimeUnit.DAYS.toMillis(7)));
+  }
+
+  @Test
+  void testDisablesCleanupWhenRetentionIsZero() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      EntityChangeLogCleaner cleaner =
+          new EntityChangeLogCleaner(0, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS);
+
+      cleaner.cleanExpiredChanges();
+    }
+
+    verify(mapper, never()).pruneOldEntityChanges(anyLong());
+  }
+
+  @Test
+  void testFirstRunDoesNotWaitForAWholeCleanupInterval() {
+    // A server restarted more often than the cleanup interval must still 
prune, so the first run
+    // is scheduled after a short randomized delay instead of a full interval.
+    EntityChangeLogCleaner dailyCleaner =
+        new EntityChangeLogCleaner(RETENTION_MS, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS);
+    for (int i = 0; i < 100; i++) {
+      long delayMs = dailyCleaner.initialDelayMs();
+      Assertions.assertTrue(delayMs > 0, "initial delay must be positive, got 
" + delayMs);
+      Assertions.assertTrue(
+          delayMs <= TimeUnit.MINUTES.toMillis(10), "initial delay too long: " 
+ delayMs);
+    }
+
+    // Never longer than the configured interval either.
+    EntityChangeLogCleaner frequentCleaner =
+        new EntityChangeLogCleaner(RETENTION_MS, 50L, POLL_INTERVAL_MS);
+    for (int i = 0; i < 100; i++) {
+      Assertions.assertTrue(frequentCleaner.initialDelayMs() <= 50L);
+    }
+  }
+
+  @Test
+  void testStartAndCloseAreSafeToPair() {
+    EntityChangeLogCleaner cleaner =
+        new EntityChangeLogCleaner(RETENTION_MS, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS);
+
+    Assertions.assertDoesNotThrow(cleaner::start);
+    Assertions.assertDoesNotThrow(cleaner::close);
+    // close() on a cleaner that was never started must not fail either.
+    Assertions.assertDoesNotThrow(
+        () ->
+            new EntityChangeLogCleaner(RETENTION_MS, 
TimeUnit.DAYS.toMillis(1), POLL_INTERVAL_MS)
+                .close());
+  }
+
+  @Test
+  void testStartIsNoOpWhenRetentionIsZero() throws Exception {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+
+      try (EntityChangeLogCleaner cleaner = new EntityChangeLogCleaner(0, 1L, 
POLL_INTERVAL_MS)) {
+        cleaner.start();
+        Thread.sleep(200);
+      }
+    }
+
+    verify(mapper, never()).pruneOldEntityChanges(anyLong());
+  }
+
+  @Test
+  void testDrainsExpiredRowsInCommittedBatches() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.pruneOldEntityChanges(RETENTION_MS))
+        .thenReturn(EntityChangeLogMapper.ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE)
+        .thenReturn(EntityChangeLogMapper.ENTITY_CHANGE_LOG_PRUNE_BATCH_SIZE)
+        .thenReturn(7);
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      EntityChangeLogCleaner cleaner =
+          new EntityChangeLogCleaner(RETENTION_MS, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS);
+
+      cleaner.cleanExpiredChanges();
+    }
+
+    verify(mapper, times(3)).pruneOldEntityChanges(RETENTION_MS);
+  }
+
+  @Test
+  void testCleanupFailureDoesNotEscapeScheduledTask() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.pruneOldEntityChanges(RETENTION_MS))
+        .thenThrow(new RuntimeException("database unavailable"));
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      EntityChangeLogCleaner cleaner =
+          new EntityChangeLogCleaner(RETENTION_MS, TimeUnit.DAYS.toMillis(1), 
POLL_INTERVAL_MS);
+
+      Assertions.assertDoesNotThrow(cleaner::cleanExpiredChanges);
+    }
+  }
+
+  private static void mockSessionUtils(
+      MockedStatic<SessionUtils> sessionUtils, EntityChangeLogMapper mapper) {
+    sessionUtils
+        .when(() -> SessionUtils.doWithCommitAndFetchResult(any(), any()))
+        .thenAnswer(
+            invocation -> {
+              Function<Object, Object> function = invocation.getArgument(1);
+              return function.apply(mapper);
+            });
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogPoller.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogPoller.java
index 4eea24e24f..bf54d5b6ef 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogPoller.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityChangeLogPoller.java
@@ -19,18 +19,21 @@
 package org.apache.gravitino.storage.relational;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
+import 
org.apache.gravitino.storage.relational.EntityChangeLogPoller.ListenerFailureAction;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
@@ -41,10 +44,15 @@ import org.mockito.MockedStatic;
 
 public class TestEntityChangeLogPoller {
 
+  private static final int MAX_ROWS = 2000;
+
   @Test
-  void testRejectsNonPositivePollInterval() {
-    Assertions.assertThrows(IllegalArgumentException.class, () -> new 
EntityChangeLogPoller(0));
-    Assertions.assertThrows(IllegalArgumentException.class, () -> new 
EntityChangeLogPoller(-1));
+  void testRejectsInvalidConfiguration() {
+    Assertions.assertThrows(IllegalArgumentException.class, () -> newPoller(0, 
10));
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
newPoller(-1, 10));
+    Assertions.assertThrows(IllegalArgumentException.class, () -> newPoller(1, 
-1));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new EntityChangeLogPoller(1, 10, 
null));
   }
 
   @Test
@@ -52,22 +60,16 @@ public class TestEntityChangeLogPoller {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
     EntityChangeRecord first = change(1L, "CATALOG", "ml1.cat1");
     EntityChangeRecord second = change(2L, "SCHEMA", "ml1.cat1.sch1");
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(List.of(first, 
second));
-    when(mapper.selectEntityChanges(2L, 500)).thenReturn(List.of());
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(first, 
second));
+    when(mapper.selectEntityChanges(2L, MAX_ROWS)).thenReturn(List.of());
 
     List<EntityChangeRecord> firstListenerRecords = new ArrayList<>();
     List<EntityChangeRecord> secondListenerRecords = new ArrayList<>();
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
-      sessionUtils
-          .when(() -> SessionUtils.getWithoutCommit(any(), any()))
-          .thenAnswer(
-              invocation -> {
-                Function<Object, Object> func = invocation.getArgument(1);
-                return func.apply(mapper);
-              });
-
-      EntityChangeLogPoller poller = new EntityChangeLogPoller(1);
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller = newPoller(1, 10);
       poller.registerListener(firstListenerRecords::addAll);
       poller.registerListener(secondListenerRecords::addAll);
 
@@ -77,150 +79,247 @@ public class TestEntityChangeLogPoller {
 
     Assertions.assertEquals(List.of(first, second), firstListenerRecords);
     Assertions.assertEquals(List.of(first, second), secondListenerRecords);
+    verify(mapper).selectEntityChanges(2L, MAX_ROWS);
   }
 
   @Test
-  void testListenerFailureDoesNotBlockOtherListenersAndCursorStillAdvances() {
+  void testRetriesOnlyFailedListenersBeforeAdvancingCursor() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
     EntityChangeRecord change = change(1L, "CATALOG", "ml1.cat1");
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(List.of(change));
-    when(mapper.selectEntityChanges(1L, 500)).thenReturn(List.of());
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(change));
+    when(mapper.selectEntityChanges(1L, MAX_ROWS)).thenReturn(List.of());
 
     List<EntityChangeRecord> received = new ArrayList<>();
+    AtomicInteger failingListenerCalls = new AtomicInteger();
+    AtomicBoolean firstCall = new AtomicBoolean(true);
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
-      sessionUtils
-          .when(() -> SessionUtils.getWithoutCommit(any(), any()))
-          .thenAnswer(
-              invocation -> {
-                Function<Object, Object> func = invocation.getArgument(1);
-                return func.apply(mapper);
-              });
-
-      EntityChangeLogPoller poller = new EntityChangeLogPoller(1);
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller = newPoller(1, 10);
       poller.registerListener(
           changes -> {
-            throw new RuntimeException("listener failed");
+            failingListenerCalls.incrementAndGet();
+            if (firstCall.getAndSet(false)) {
+              throw new RuntimeException("listener failed");
+            }
           });
       poller.registerListener(received::addAll);
 
       poller.pollChanges();
       poller.pollChanges();
+      poller.pollChanges();
     }
 
+    // The healthy listener is not re-notified, the failed one is retried once 
and then succeeds.
+    Assertions.assertEquals(2, failingListenerCalls.get());
     Assertions.assertEquals(List.of(change), received);
+    verify(mapper).selectEntityChanges(0L, MAX_ROWS);
+    verify(mapper).selectEntityChanges(1L, MAX_ROWS);
   }
 
   @Test
-  void testPollChangesCatchesFetchFailures() {
+  void testUnregisteredFailedListenerDoesNotBlockCursor() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
-    when(mapper.selectEntityChanges(0L, 500)).thenThrow(new 
RuntimeException("db failed"));
+    EntityChangeRecord change = change(1L, "CATALOG", "ml1.cat1");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(change));
+    when(mapper.selectEntityChanges(1L, MAX_ROWS)).thenReturn(List.of());
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
-      sessionUtils
-          .when(() -> SessionUtils.getWithoutCommit(any(), any()))
-          .thenAnswer(
-              invocation -> {
-                Function<Object, Object> func = invocation.getArgument(1);
-                return func.apply(mapper);
-              });
+      mockSessionUtils(sessionUtils, mapper);
 
-      EntityChangeLogPoller poller = new EntityChangeLogPoller(1);
+      EntityChangeLogPoller poller = newPoller(1, 10);
+      EntityChangeLogListener failedListener =
+          changes -> {
+            throw new RuntimeException("listener failed");
+          };
+      poller.registerListener(failedListener);
 
-      Assertions.assertDoesNotThrow(poller::pollChanges);
+      poller.pollChanges();
+      poller.unregisterListener(failedListener);
+      poller.pollChanges();
+      poller.pollChanges();
     }
+
+    verify(mapper).selectEntityChanges(1L, MAX_ROWS);
   }
 
   @Test
-  void testDispatchesImmutableBatchToListeners() {
+  void testPausedBatchBlocksFetchingNewBatches() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
-    EntityChangeRecord first = change(1L, "CATALOG", "ml1.cat1");
-    EntityChangeRecord second = change(2L, "SCHEMA", "ml1.cat1.sch1");
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(new 
ArrayList<>(List.of(first, second)));
-
-    List<EntityChangeRecord> received = new ArrayList<>();
+    EntityChangeRecord change = change(1L, "CATALOG", "ml1.cat1");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(change));
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
-      sessionUtils
-          .when(() -> SessionUtils.getWithoutCommit(any(), any()))
-          .thenAnswer(
-              invocation -> {
-                Function<Object, Object> func = invocation.getArgument(1);
-                return func.apply(mapper);
-              });
-      sessionUtils
-          .when(() -> SessionUtils.doWithoutCommit(any(), any()))
-          .thenAnswer(invocation -> null);
-
-      EntityChangeLogPoller poller = new EntityChangeLogPoller(1);
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller = newPoller(1, 10);
       poller.registerListener(
-          changes -> 
Assertions.assertThrows(UnsupportedOperationException.class, changes::clear));
-      poller.registerListener(received::addAll);
+          changes -> {
+            throw new RuntimeException("listener failed");
+          });
 
       poller.pollChanges();
+      poller.pollChanges();
     }
 
-    Assertions.assertEquals(List.of(first, second), received);
+    // Only the very first fetch happened: the second poll retried the paused 
batch instead.
+    verify(mapper).selectEntityChanges(0L, MAX_ROWS);
+    verify(mapper, times(1)).selectEntityChanges(anyLong(), anyInt());
   }
 
   @Test
-  void testPrunesExpiredChangesAfterCleanupInterval() {
+  void testStopsServerAfterListenerExhaustsRetries() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(List.of());
+    EntityChangeRecord change = change(1L, "CATALOG", "ml1.cat1");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(change));
+
+    AtomicInteger listenerCalls = new AtomicInteger();
+    AtomicInteger exitCalls = new AtomicInteger();
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
       mockSessionUtils(sessionUtils, mapper);
 
       EntityChangeLogPoller poller =
-          new EntityChangeLogPoller(
-              1, TimeUnit.DAYS.toMillis(1), TimeUnit.HOURS.toMillis(1), () -> 
100_000_000L);
+          new EntityChangeLogPoller(1, 1, ListenerFailureAction.EXIT, 
exitCalls::incrementAndGet);
+      poller.registerListener(
+          changes -> {
+            listenerCalls.incrementAndGet();
+            throw new RuntimeException("listener failed");
+          });
 
       poller.pollChanges();
+      Assertions.assertEquals(0, exitCalls.get());
+      poller.pollChanges();
     }
 
-    verify(mapper).pruneOldEntityChanges(100_000_000L - 
TimeUnit.DAYS.toMillis(1));
+    // One initial dispatch plus one retry, then the server is stopped and the 
cursor never moves.
+    Assertions.assertEquals(2, listenerCalls.get());
+    Assertions.assertEquals(1, exitCalls.get());
+    verify(mapper, never()).selectEntityChanges(1L, MAX_ROWS);
   }
 
   @Test
-  void testSkipsPruneBeforeCleanupInterval() {
+  void testSkipActionDropsFailedListenerAndAdvancesCursor() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(List.of());
+    EntityChangeRecord first = change(1L, "CATALOG", "ml1.cat1");
+    EntityChangeRecord second = change(2L, "CATALOG", "ml1.cat2");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(List.of(first));
+    when(mapper.selectEntityChanges(1L, MAX_ROWS)).thenReturn(List.of(second));
+    when(mapper.selectEntityChanges(2L, MAX_ROWS)).thenReturn(List.of());
+
+    AtomicInteger listenerCalls = new AtomicInteger();
 
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
       mockSessionUtils(sessionUtils, mapper);
 
       EntityChangeLogPoller poller =
           new EntityChangeLogPoller(
-              1, TimeUnit.DAYS.toMillis(1), TimeUnit.HOURS.toMillis(1), () -> 
100_000_000L);
+              1, 0, ListenerFailureAction.SKIP, 
TestEntityChangeLogPoller::failOnExit);
+      poller.registerListener(
+          changes -> {
+            listenerCalls.incrementAndGet();
+            throw new RuntimeException("listener failed");
+          });
 
       poller.pollChanges();
       poller.pollChanges();
+      poller.pollChanges();
     }
 
-    verify(mapper).pruneOldEntityChanges(100_000_000L - 
TimeUnit.DAYS.toMillis(1));
+    // No retry with maxListenerRetries=0: each batch is dropped for the 
listener after one
+    // attempt, and the cursor keeps moving.
+    Assertions.assertEquals(2, listenerCalls.get());
+    verify(mapper).selectEntityChanges(1L, MAX_ROWS);
+    verify(mapper).selectEntityChanges(2L, MAX_ROWS);
   }
 
   @Test
-  void testDisablesPruneWhenRetentionIsZero() {
+  void testConsumesBacklogLargerThanOneBatch() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
-    when(mapper.selectEntityChanges(0L, 500)).thenReturn(List.of());
+    List<EntityChangeRecord> firstBatch = changes(1L, MAX_ROWS);
+    EntityChangeRecord remainingChange = change(2001L, "TABLE", 
"ml1.cat1.schema1.table2001");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenReturn(firstBatch);
+    when(mapper.selectEntityChanges(2000L, 
MAX_ROWS)).thenReturn(List.of(remainingChange));
 
+    List<EntityChangeRecord> received = new ArrayList<>();
     try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
       mockSessionUtils(sessionUtils, mapper);
 
-      EntityChangeLogPoller poller =
-          new EntityChangeLogPoller(1, 0L, TimeUnit.HOURS.toMillis(1), () -> 
100_000_000L);
+      EntityChangeLogPoller poller = newPoller(1, 10);
+      poller.registerListener(received::addAll);
 
       poller.pollChanges();
+      poller.pollChanges();
     }
 
-    verify(mapper, never()).pruneOldEntityChanges(anyLong());
+    Assertions.assertEquals(2001, received.size());
+    Assertions.assertEquals(remainingChange, received.get(2000));
+  }
+
+  @Test
+  void testPollChangesCatchesFetchFailures() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.selectEntityChanges(0L, MAX_ROWS)).thenThrow(new 
RuntimeException("db failed"));
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller = newPoller(1, 10);
+
+      Assertions.assertDoesNotThrow(poller::pollChanges);
+    }
+  }
+
+  @Test
+  void testDispatchesImmutableBatchToListeners() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    EntityChangeRecord first = change(1L, "CATALOG", "ml1.cat1");
+    EntityChangeRecord second = change(2L, "SCHEMA", "ml1.cat1.sch1");
+    when(mapper.selectEntityChanges(0L, MAX_ROWS))
+        .thenReturn(new ArrayList<>(List.of(first, second)));
+
+    List<EntityChangeRecord> received = new ArrayList<>();
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller = newPoller(1, 10);
+      poller.registerListener(
+          changes -> 
Assertions.assertThrows(UnsupportedOperationException.class, changes::clear));
+      poller.registerListener(received::addAll);
+
+      poller.pollChanges();
+    }
+
+    Assertions.assertEquals(List.of(first, second), received);
+  }
+
+  /** Fails the test instead of stopping the JVM when the poller decides to 
exit. */
+  private static void failOnExit() {
+    Assertions.fail("the poller must not stop the server in this scenario");
+  }
+
+  private static EntityChangeLogPoller newPoller(long pollIntervalSecs, int 
maxListenerRetries) {
+    return new EntityChangeLogPoller(
+        pollIntervalSecs,
+        maxListenerRetries,
+        ListenerFailureAction.EXIT,
+        TestEntityChangeLogPoller::failOnExit);
   }
 
   private static EntityChangeRecord change(long id, String type, String 
fullName) {
     return new EntityChangeRecord(id, "ml1", type, fullName, 
OperateType.ALTER, 0L);
   }
 
+  private static List<EntityChangeRecord> changes(long firstId, long lastId) {
+    List<EntityChangeRecord> changes = new ArrayList<>();
+    for (long id = firstId; id <= lastId; id++) {
+      changes.add(change(id, "TABLE", "ml1.cat1.schema1.table" + id));
+    }
+    return changes;
+  }
+
   private static void mockSessionUtils(
       MockedStatic<SessionUtils> sessionUtils, EntityChangeLogMapper mapper) {
     sessionUtils
@@ -230,13 +329,5 @@ public class TestEntityChangeLogPoller {
               Function<Object, Object> func = invocation.getArgument(1);
               return func.apply(mapper);
             });
-    sessionUtils
-        .when(() -> SessionUtils.doWithoutCommit(any(), any()))
-        .thenAnswer(
-            invocation -> {
-              Consumer<Object> consumer = invocation.getArgument(1);
-              consumer.accept(mapper);
-              return null;
-            });
   }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestEntityChangeLogMapper.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestEntityChangeLogMapper.java
index 8893a74116..89fa8d6543 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestEntityChangeLogMapper.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestEntityChangeLogMapper.java
@@ -26,14 +26,17 @@ import java.sql.PreparedStatement;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.io.FileUtils;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.storage.relational.JDBCBackend;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import 
org.apache.gravitino.storage.relational.mapper.provider.postgresql.EntityChangeLogPostgreSQLProvider;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
@@ -134,11 +137,19 @@ public class TestEntityChangeLogMapper {
             .findFirst()
             .orElseThrow(() -> new AssertionError("recent row missing"));
 
-    entityChangeLogMapper.pruneOldEntityChanges(1001L);
-
-    List<EntityChangeRecord> after = 
entityChangeLogMapper.selectEntityChanges(0L, 100);
-    Assertions.assertEquals(1, after.size());
-    Assertions.assertEquals(recent, after.get(0).getCreatedAt());
+    int prunedRows =
+        SessionUtils.doWithCommitAndFetchResult(
+            EntityChangeLogMapper.class,
+            mapper -> 
mapper.pruneOldEntityChanges(TimeUnit.DAYS.toMillis(30)));
+    Assertions.assertEquals(1, prunedRows);
+
+    try (SqlSession verificationSession =
+        
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true)) 
{
+      List<EntityChangeRecord> after =
+          
verificationSession.getMapper(EntityChangeLogMapper.class).selectEntityChanges(0L,
 100);
+      Assertions.assertEquals(1, after.size());
+      Assertions.assertEquals(recent, after.get(0).getCreatedAt());
+    }
   }
 
   @Test
@@ -156,6 +167,36 @@ public class TestEntityChangeLogMapper {
     Assertions.assertTrue(rows.get(1).getId() < rows.get(2).getId());
   }
 
+  @Test
+  void testEntityChangeLogPruneUsesDatabaseTime() {
+    String baseSql =
+        new 
EntityChangeLogBaseSQLProvider().pruneOldEntityChanges(TimeUnit.DAYS.toMillis(30));
+    String postgreSql =
+        new 
EntityChangeLogPostgreSQLProvider().pruneOldEntityChanges(TimeUnit.DAYS.toMillis(30));
+
+    // The cutoff must be derived from the DB clock, never from a JVM 
timestamp bound into the SQL.
+    Assertions.assertTrue(baseSql.contains("CURRENT_TIMESTAMP"), baseSql);
+    Assertions.assertTrue(baseSql.contains("#{retentionMs}"), baseSql);
+    Assertions.assertFalse(baseSql.contains("#{before}"), baseSql);
+    Assertions.assertTrue(postgreSql.contains("CURRENT_TIMESTAMP"), 
postgreSql);
+    Assertions.assertTrue(postgreSql.contains("#{retentionMs}"), postgreSql);
+    Assertions.assertFalse(postgreSql.contains("#{before}"), postgreSql);
+  }
+
+  @Test
+  void testEntityChangeLogPruneKeepsRowsInsideRetention() {
+    entityChangeLogMapper.insertEntityChange(
+        "metalake1", "TABLE", "metalake1.cat.schema.recent", 
OperateType.ALTER);
+
+    int prunedRows =
+        SessionUtils.doWithCommitAndFetchResult(
+            EntityChangeLogMapper.class,
+            mapper -> 
mapper.pruneOldEntityChanges(TimeUnit.DAYS.toMillis(30)));
+
+    Assertions.assertEquals(0, prunedRows);
+    Assertions.assertEquals(1, entityChangeLogMapper.selectEntityChanges(0L, 
100).size());
+  }
+
   private void forceCreatedAt(String fullName, long createdAt) throws 
SQLException {
     try (PreparedStatement statement =
         sharedSession
diff --git a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java 
b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
index 593e42f5ad..f5e28244ba 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.tag;
 
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION;
+import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
 import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
 import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
@@ -155,6 +157,8 @@ public class TestTagManager {
     
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
     Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 * 
1000L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_MAX_RETRIES)).thenReturn(10);
+    
Mockito.when(config.get(ENTITY_CHANGE_LOG_LISTENER_FAILURE_ACTION)).thenReturn("SKIP");
     Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 * 
60 * 60L);
     
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60 
* 60L);
     Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 242ac37dee..3490510f0e 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -58,8 +58,10 @@ The following table lists the storage configuration items:
 | `gravitino.entity.store.deleteAfterTimeMs`         | The maximum time in 
milliseconds that deleted and old-version data is kept. Set to at least 10 
minutes and no longer than 30 days.                                             
                                                                         | 
`604800000`(7 days)               | No                                          
    | 0.5.0            |
 | `gravitino.entity.store.versionRetentionCount`     | The Count of versions 
allowed to be retained, including the current version, used to delete old 
versions data. Set to at least 1 and no greater than 10.                        
                                                                        | `1`   
                            | No                                              | 
0.5.0            |
 | `gravitino.entityChangeLog.pollIntervalSecs`       | The interval in seconds 
for polling the entity change log. The poller invalidates stale local caches 
(e.g. the catalog cache) across HA nodes by consuming change log records. Must 
be positive.                                                        | `3`       
                        | No                                              | 
1.3.0            |
-| `gravitino.entityChangeLog.retentionSecs`          | The retention time in 
seconds for entity change log rows. Expired rows are pruned periodically. Set 
to `0` to disable automatic cleanup. Must be non-negative.                      
                                                                    | `86400`(1 
day)                    | No                                              | 
1.3.0            |
-| `gravitino.entityChangeLog.cleanupIntervalSecs`    | The interval in seconds 
for pruning expired entity change log rows. Must be positive.                   
                                                                                
                                                                | `3600`(1 
hour)                    | No                                              | 
1.3.0            |
+| `gravitino.entityChangeLog.listenerMaxRetries`     | The number of times the 
poller retries a change log batch for a failing listener before applying 
`gravitino.entityChangeLog.listenerFailureAction`. Must be non-negative.        
                                                                       | `10`   
                           | No                                              | 
2.0.0            |
+| `gravitino.entityChangeLog.listenerFailureAction`  | What the poller does 
once a listener exhausted its retries. `EXIT` stops this server, because its 
local caches are known to be stale and no longer safe to serve from; `SKIP` 
drops the batch for that listener and keeps serving.                      | 
`EXIT`                            | No                                          
    | 2.0.0            |
+| `gravitino.entityChangeLog.retentionSecs`          | The retention time in 
seconds for entity change log rows. A dedicated cleaner removes rows older than 
this period using database time. Set to `0` to disable cleanup, otherwise at 
least 10x `pollIntervalSecs`.                                        | 
`2592000`(30 days)                | No                                          
    | 1.3.0            |
+| `gravitino.entityChangeLog.cleanupIntervalSecs`    | The interval in seconds 
for running the dedicated entity change log cleaner. Must be positive.          
                                                                                
                                                                | `86400`(1 
day)                    | No                                              | 
1.3.0            |
 | `gravitino.entity.store.relational`                | Detailed implementation 
of Relational storage. `H2`, `MySQL` and `PostgreSQL` is supported, and the 
implementation is `JDBCBackend`.                                                
                                                                    | 
`JDBCBackend`                     | No                                          
    | 0.5.0            |
 | `gravitino.entity.store.relational.jdbcUrl`        | The database url that 
the `JDBCBackend` needs to connect to. If you use `MySQL` or `PostgreSQL`, you 
should firstly initialize the database tables yourself by executing the ddl 
scripts in the `${GRAVITINO_HOME}/scripts/{DATABASE_TYPE}/` directory. | 
`jdbc:h2`                         | No                                          
    | 0.5.0            |
 | `gravitino.entity.store.relational.jdbcDriver`     | The jdbc driver name 
that the `JDBCBackend` needs to use. You should place the driver Jar package in 
the `${GRAVITINO_HOME}/libs/` directory.                                        
                                                                   | 
`org.h2.Driver`                   | Yes if the jdbc connection url is not 
`jdbc:h2` | 0.5.0            |

Reply via email to