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

jerryshao 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 51fb2d29c7 [#13581] improvement(core): Drain change-log backlog 
without waiting the poll interval after a full batch (#13619)
51fb2d29c7 is described below

commit 51fb2d29c7573a2284cb4f5422e5ae4fb380a989
Author: Qi Yu <[email protected]>
AuthorDate: Fri Oct 9 15:45:22 2026 +0800

    [#13581] improvement(core): Drain change-log backlog without waiting the 
poll interval after a full batch (#13619)
    
    ### What changes were proposed in this pull request?
    
    - Schedule each change-log poll cycle individually: a full batch
    schedules the next cycle immediately; a partial or empty batch, or a
    failed cycle, waits the configured interval.
    - Keep each cycle bounded by `gravitino.entityChangeLog.pollBatchSize`
    (default `2000`). Sustained backlog intentionally drains continuously,
    with no minimum delay. Tail diagnostics sample at most once per interval
    during full batches, including failed sampling attempts; partial and
    empty batches refresh the tail normally.
    - On shutdown, discard future delayed polls. Already-due tasks may still
    execute, so the `closed` guard prevents them from polling the closing
    store. Reject restarting a closed poller.
    - Synchronize configuration, metrics, and multi-node design
    documentation, including database pressure and sampled-lag limitations.
    
    ### Why are the changes needed?
    
    The poller previously read at most 2,000 records and then always slept
    for the full interval. With the default three-second interval,
    throughput was capped at about 667 records/s even with a backlog,
    allowing remote caches to remain stale under sustained writes.
    
    Fix: #13581
    
    ### Does this PR introduce _any_ user-facing change?
    
    - New server property `gravitino.entityChangeLog.pollBatchSize`.
    - Servers behind the change log drain continuously. Idle and failing
    pollers retain the configured interval. Full batches indicate possible
    backlog; zero sampled record lag alone does not prove the poller has
    caught up.
    
    ### How was this patch tested?
    
    - 22 `TestEntityChangeLogPoller` cases cover scheduling, bounded
    batches, tail-query frequency and failed sampling attempts, scheduling
    rejection, future-task cancellation, and an already-due task queued
    during shutdown. The last test uses the production scheduler and proves
    the queued task executes without polling after closure.
    - H2 cross-node invalidation verifies that five changed cached tables
    drain over batches of two.
    - `core:test` (2,531 tests, two skipped) and all non-Docker tests in
    fileset (186), Kafka (15), model (35), and generic lakehouse (96) passed
    with zero failures/errors. `:core:spotlessCheck`, `:core:javadoc`, and
    `git diff --check` passed. UTF-8 locale and explicit
    `SKIP_DOCKER_TESTS=true` were used. The three cross-node invalidation
    cases also passed on H2, MySQL, and PostgreSQL (nine backend
    invocations, zero failures/errors/skips).
    - Six isolated mutations were caught by the corresponding assertions:
    fixed-interval draining, missing backlog signal, excessive
    successful/failed tail queries, missing closed guard, and missing
    delayed-task shutdown policy. A JDK 17 probe confirms future delayed
    tasks are canceled while already-due tasks can run after shutdown.
---
 .../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    |  17 ++
 .../storage/relational/EntityChangeLogPoller.java  | 186 ++++++++++---
 .../storage/relational/RelationalEntityStore.java  |   4 +-
 .../authorization/TestAccessControlManager.java    |   4 +
 .../gravitino/authorization/TestOwnerManager.java  |   4 +
 .../gravitino/hook/TestFilesetHookDispatcher.java  |   4 +
 .../apache/gravitino/policy/TestPolicyManager.java |   4 +
 .../gravitino/stats/TestStatisticManager.java      |   4 +
 .../storage/AbstractEntityStorageTest.java         |   4 +
 .../TestEntityCacheCrossNodeInvalidation.java      |  61 ++++-
 .../relational/TestEntityChangeLogPoller.java      | 304 ++++++++++++++++++++-
 ...TestRelationalEntityStoreHierarchicalCache.java |   2 +
 .../org/apache/gravitino/tag/TestTagManager.java   |   4 +
 .../gravitino-entity-cache-multinode-design.md     |   2 +-
 docs/gravitino-server-config.md                    |  15 +-
 docs/metrics.md                                    |  15 +-
 21 files changed, 600 insertions(+), 54 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 6a9c49dfdd..3187f22b70 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
@@ -18,8 +18,10 @@
  */
 package org.apache.gravitino.catalog.fileset;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -278,6 +280,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 a974515386..727bf38882 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
@@ -19,8 +19,10 @@
 package org.apache.gravitino.catalog.kafka;
 
 import static org.apache.gravitino.Catalog.Type.MESSAGING;
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -167,6 +169,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 6bed860b0b..df25b219c6 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
@@ -18,8 +18,10 @@
  */
 package org.apache.gravitino.catalog.lakehouse.generic;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -126,6 +128,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 4ef7a9f3be..0793521deb 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
@@ -18,8 +18,10 @@
  */
 package org.apache.gravtitino.catalog.model;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -129,6 +131,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 16e8eed9c5..c78aacde16 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
@@ -19,9 +19,11 @@
 
 package org.apache.gravitino.cache.it;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
 import static 
org.apache.gravitino.Configs.DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_IDLE_CONNECTIONS;
 import static 
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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;
@@ -298,6 +300,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 108f316219..600d5da8e5 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -205,6 +205,9 @@ public class Configs {
           .createWithDefault(60 * 60 * 1000L);
 
   public static final long DEFAULT_ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS = 3L;
+  /** Default maximum number of entity change log records read in one polling 
cycle. */
+  public static final int DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE = 2000;
+
   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;
 
@@ -216,6 +219,20 @@ public class Configs {
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
           .createWithDefault(DEFAULT_ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS);
 
+  /**
+   * Maximum records read per entity change log poll. A full batch triggers an 
immediate next poll;
+   * empty or partial batches wait for the configured poll interval.
+   */
+  public static final ConfigEntry<Integer> ENTITY_CHANGE_LOG_POLL_BATCH_SIZE =
+      new ConfigBuilder("gravitino.entityChangeLog.pollBatchSize")
+          .doc(
+              "The maximum number of entity change log records read per poll. 
A full batch is"
+                  + " followed by another poll right away instead of after the 
poll interval")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .intConf()
+          .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+          .createWithDefault(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
+
   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")
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 b42b57dc1a..9624a4b9f3 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
@@ -23,10 +23,12 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import javax.annotation.Nullable;
+import org.apache.gravitino.Configs;
 import org.apache.gravitino.metrics.source.EntityChangeLogMetricsSource;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
@@ -64,29 +66,35 @@ import org.slf4j.LoggerFactory;
  *
  * <p>Every listener failure is logged at {@code ERROR}, so a listener that 
keeps failing stays
  * visible in the logs even though the poller keeps going.
+ *
+ * <p>Each poll cycle reads at most one batch. A full batch means more rows 
may be waiting, so the
+ * next cycle starts right away instead of after the poll interval; an empty 
or partial batch, or a
+ * failed cycle, waits the full interval. A backlog is therefore drained as 
fast as batches can be
+ * read and delivered, while an idle or failing poller still polls only once 
per interval.
  */
 public class EntityChangeLogPoller implements AutoCloseable {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(EntityChangeLogPoller.class);
 
-  /** Max entity-change rows to fetch per batch. */
-  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;
 
   private final List<EntityChangeLogListener> listeners = new 
CopyOnWriteArrayList<>();
   private final long pollIntervalSecs;
+  private final int batchSize;
   private final EntityChangeLogMetricsSource metrics;
 
   private ScheduledExecutorService scheduler;
+  private volatile boolean closed = false;
   private volatile long entityPollHighWaterId = 0;
+  private boolean tailSampleAttempted;
+  private long lastTailSampleNanos;
 
   /**
    * Creates an {@link EntityChangeLogPoller} with an unregistered metrics 
source for callers that
    * do not use the server metrics system.
    *
-   * @param pollIntervalSecs interval between successive polling cycles
+   * @param pollIntervalSecs interval between polling cycles once the poller 
has caught up
    */
   public EntityChangeLogPoller(long pollIntervalSecs) {
     this(pollIntervalSecs, new EntityChangeLogMetricsSource());
@@ -95,13 +103,28 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
   /**
    * Creates a poller using the metrics source registered by the entity store.
    *
-   * @param pollIntervalSecs interval between successive polling cycles
+   * @param pollIntervalSecs interval between polling cycles once the poller 
has caught up
    * @param metrics process-local change log metrics
    */
   public EntityChangeLogPoller(long pollIntervalSecs, 
EntityChangeLogMetricsSource metrics) {
+    this(pollIntervalSecs, Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE, 
metrics);
+  }
+
+  /**
+   * Creates a poller with a custom batch size, using the metrics source 
registered by the entity
+   * store.
+   *
+   * @param pollIntervalSecs interval between polling cycles once the poller 
has caught up
+   * @param batchSize maximum number of change log rows read per polling cycle
+   * @param metrics process-local change log metrics
+   */
+  public EntityChangeLogPoller(
+      long pollIntervalSecs, int batchSize, EntityChangeLogMetricsSource 
metrics) {
     Preconditions.checkArgument(pollIntervalSecs > 0, "pollIntervalSecs must 
be positive");
+    Preconditions.checkArgument(batchSize > 0, "batchSize must be positive");
     this.metrics = Preconditions.checkNotNull(metrics, "metrics cannot be 
null");
     this.pollIntervalSecs = pollIntervalSecs;
+    this.batchSize = batchSize;
   }
 
   /**
@@ -136,7 +159,8 @@ public class EntityChangeLogPoller implements AutoCloseable 
{
   }
 
   /**
-   * Initializes the high-water cursor to the current DB tail and schedules 
periodic polling.
+   * Initializes the high-water cursor to the current DB tail and schedules 
the first poll one
+   * interval later. A poller cannot be started again after {@link #close()}.
    *
    * <p>On every start (including restarts), the cursor is set to the current 
maximum change ID in
    * the DB, so historical change records written before this server process 
started are NOT
@@ -145,33 +169,30 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
    * cache.
    */
   public void start() {
+    Preconditions.checkState(!closed, "A closed entity change log poller 
cannot be restarted");
     entityPollHighWaterId =
         getOrDefault(
             SessionUtils.getWithoutCommit(
                 EntityChangeLogMapper.class, 
EntityChangeLogMapper::selectMaxChangeId));
     metrics.setDbTailId(entityPollHighWaterId);
+    tailSampleAttempted = true;
+    lastTailSampleNanos = nanoTime();
     metrics.setCursorId(entityPollHighWaterId);
     LOG.info(
-        "Starting entity change log poller at high-water id {} with a {} 
second interval, "
-            + "{} listener(s) registered",
+        "Starting entity change log poller at high-water id {} with a {} 
second interval and a "
+            + "batch size of {}, {} listener(s) registered",
         entityPollHighWaterId,
         pollIntervalSecs,
+        batchSize,
         listeners.size());
 
-    scheduler =
-        Executors.newSingleThreadScheduledExecutor(
-            r -> {
-              Thread t = new Thread(r);
-              t.setName("Gravitino-EntityChangeLogPoller");
-              t.setDaemon(true);
-              return t;
-            });
-    scheduler.scheduleWithFixedDelay(
-        this::pollChanges, pollIntervalSecs, pollIntervalSecs, 
TimeUnit.SECONDS);
+    scheduler = createScheduler();
+    scheduleNextPoll(TimeUnit.SECONDS.toMillis(pollIntervalSecs));
   }
 
   @Override
   public void close() {
+    closed = true;
     if (scheduler != null) {
       scheduler.shutdown();
       try {
@@ -190,28 +211,99 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
   }
 
   @VisibleForTesting
-  void pollChanges() {
+  ScheduledExecutorService createScheduler() {
+    ScheduledThreadPoolExecutor executor =
+        new ScheduledThreadPoolExecutor(
+            1,
+            r -> {
+              Thread t = new Thread(r);
+              t.setName("Gravitino-EntityChangeLogPoller");
+              t.setDaemon(true);
+              return t;
+            });
+    // Drop polls whose delay has not elapsed so close() does not wait for an 
idle poll. Already-due
+    // tasks, including zero-delay drain hops, can still run after shutdown(); 
runPollCycle's closed
+    // guard prevents those tasks from accessing the closing store.
+    executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+    return executor;
+  }
+
+  @VisibleForTesting
+  long nanoTime() {
+    return System.nanoTime();
+  }
+
+  /**
+   * Runs one poll cycle and schedules the next one: immediately when the 
cycle read a full batch,
+   * otherwise after the poll interval. A failed cycle also waits the full 
interval, so a database
+   * outage is not retried in a tight loop.
+   */
+  private void runPollCycle() {
+    if (closed) {
+      return;
+    }
+    long nextDelayMillis = TimeUnit.SECONDS.toMillis(pollIntervalSecs);
+    try {
+      if (pollChanges()) {
+        // Continuous draining is intentional: every cycle advances the cursor 
and is bounded by
+        // batchSize. Tail sampling is rate-limited separately so diagnostics 
do not double the
+        // database query rate while a sustained backlog is being consumed.
+        nextDelayMillis = 0;
+      }
+    } finally {
+      // pollChanges() catches Throwable, but scheduling in finally keeps the 
poller alive even if
+      // that ever changes: a cycle that is not rescheduled stops cache 
invalidation for good.
+      scheduleNextPoll(nextDelayMillis);
+    }
+  }
+
+  private void scheduleNextPoll(long delayMillis) {
+    if (closed) {
+      return;
+    }
+    try {
+      scheduler.schedule(this::runPollCycle, delayMillis, 
TimeUnit.MILLISECONDS);
+    } catch (RejectedExecutionException e) {
+      // Only a shut-down scheduler rejects work, and close() sets closed 
before shutting down.
+      if (!closed) {
+        LOG.error("Could not schedule the next entity change log poll", e);
+      }
+    }
+  }
+
+  /**
+   * Polls one batch and delivers it to the listeners.
+   *
+   * @return true if the batch was full, so more rows may be waiting and the 
next poll should not
+   *     wait for the interval; false if the poller caught up or the poll 
failed
+   */
+  @VisibleForTesting
+  boolean pollChanges() {
     try (Timer.Context ignored = metrics.timePoll()) {
-      doPollChanges();
+      return doPollChanges();
     } catch (Throwable e) {
-      // Catch Throwable, not Exception: this method is the task handed to
-      // scheduleWithFixedDelay(), and anything that escapes it cancels all 
future runs for good,
-      // silently. A listener or its recovery path can throw an Error as well 
as an Exception.
-      // Losing the poller would stop cache invalidation for every listener in 
this process, so we
-      // log and let the next cycle run.
+      // Catch Throwable, not Exception: a listener or its recovery path can 
throw an Error as well
+      // as an Exception. Losing the poller would stop cache invalidation for 
every listener in
+      // this process, so we log and let the next cycle run after the normal 
interval.
       if (handleInterruptIfAny(e, "Entity change poll")) {
-        return;
+        return false;
       }
       metrics.pollFailed();
       LOG.warn("Entity change poll failed at high-water id {}", 
entityPollHighWaterId, e);
+      return false;
     }
   }
 
-  private synchronized void doPollChanges() {
+  private synchronized boolean doPollChanges() {
     BatchDelivery delivery = fetchNextDelivery();
-    if (delivery != null) {
-      deliver(delivery);
+    if (delivery == null) {
+      return false;
     }
+    deliver(delivery);
+    // A full batch is the backlog signal. The sampled tail id would be more 
precise, but that
+    // sample is best-effort and may be missing; a full batch with nothing 
behind it only costs one
+    // extra empty poll before the normal interval resumes.
+    return delivery.changes.size() >= batchSize;
   }
 
   @Nullable
@@ -221,18 +313,27 @@ public class EntityChangeLogPoller implements 
AutoCloseable {
     // The tail is for observability only. A failed sample must not suppress 
delivery of rows
     // already fetched successfully or hold the cursor back.
     @Nullable Long dbTailId = null;
-    try {
-      dbTailId =
-          getOrDefault(
-              SessionUtils.getWithoutCommit(
-                  EntityChangeLogMapper.class, 
EntityChangeLogMapper::selectMaxChangeId));
-      metrics.setDbTailId(dbTailId);
-    } catch (RuntimeException e) {
-      if (handleInterruptIfAny(e, "Entity change log tail sample")) {
-        throw e;
+    long nowNanos = nanoTime();
+    if (changes.size() < batchSize
+        || !tailSampleAttempted
+        || nowNanos - lastTailSampleNanos >= 
TimeUnit.SECONDS.toNanos(pollIntervalSecs)) {
+      // A full batch already signals backlog. Sample at most once per 
interval during a drain,
+      // including failed attempts, while partial/empty polls refresh the 
caught-up state.
+      tailSampleAttempted = true;
+      lastTailSampleNanos = nowNanos;
+      try {
+        dbTailId =
+            getOrDefault(
+                SessionUtils.getWithoutCommit(
+                    EntityChangeLogMapper.class, 
EntityChangeLogMapper::selectMaxChangeId));
+        metrics.setDbTailId(dbTailId);
+      } catch (RuntimeException e) {
+        if (handleInterruptIfAny(e, "Entity change log tail sample")) {
+          throw e;
+        }
+        metrics.tailSampleFailed();
+        LOG.warn("Could not sample entity change log tail; retaining the 
previous gauge value", e);
       }
-      metrics.tailSampleFailed();
-      LOG.warn("Could not sample entity change log tail; retaining the 
previous gauge value", e);
     }
     metrics.pollSucceeded(changes.size());
     long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
fetchStartNanos);
@@ -291,8 +392,7 @@ public class EntityChangeLogPoller implements AutoCloseable 
{
 
   private List<EntityChangeRecord> fetchEntityChanges() {
     return SessionUtils.getWithoutCommit(
-        EntityChangeLogMapper.class,
-        m -> m.selectEntityChanges(entityPollHighWaterId, 
ENTITY_CHANGE_POLLER_MAX_ROWS));
+        EntityChangeLogMapper.class, m -> 
m.selectEntityChanges(entityPollHighWaterId, batchSize));
   }
 
   private static boolean handleInterruptIfAny(Throwable e, String context) {
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 748518fbda..3d9026739b 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
@@ -120,7 +120,9 @@ public class RelationalEntityStore
     }
     this.entityChangeLogPoller =
         new EntityChangeLogPoller(
-            config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS), 
changeLogMetrics);
+            config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS),
+            config.get(Configs.ENTITY_CHANGE_LOG_POLL_BATCH_SIZE),
+            changeLogMetrics);
     this.entityChangeLogCleaner =
         new EntityChangeLogCleaner(
             
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_RETENTION_SECS)),
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 01d92dd44d..dd4310cf18 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -19,8 +19,10 @@
 package org.apache.gravitino.authorization;
 
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -156,6 +158,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 e17d9b6f10..767e13d582 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java
@@ -19,8 +19,10 @@
 package org.apache.gravitino.authorization;
 
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -116,6 +118,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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/hook/TestFilesetHookDispatcher.java 
b/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
index 4037f5d2c7..c1622ab676 100644
--- 
a/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java
@@ -19,8 +19,10 @@
 package org.apache.gravitino.hook;
 
 import static org.apache.gravitino.Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS;
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -248,6 +250,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_POLL_BATCH_SIZE))
+              .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
           
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 dedb7e164e..eae8c12874 100644
--- a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
+++ b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
@@ -19,8 +19,10 @@
 
 package org.apache.gravitino.policy;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -248,6 +250,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 46e82715d8..52f931890b 100644
--- a/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java
+++ b/core/src/test/java/org/apache/gravitino/stats/TestStatisticManager.java
@@ -19,8 +19,10 @@
 
 package org.apache.gravitino.stats;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -115,6 +117,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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 7c8142d4b7..896fe90ca4 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
@@ -19,8 +19,10 @@
 
 package org.apache.gravitino.storage;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -149,6 +151,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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/TestEntityCacheCrossNodeInvalidation.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityCacheCrossNodeInvalidation.java
index 5c979bdfb2..a304b28888 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityCacheCrossNodeInvalidation.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestEntityCacheCrossNodeInvalidation.java
@@ -19,12 +19,16 @@
 package org.apache.gravitino.storage.relational;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
 import org.apache.gravitino.Entity.EntityType;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.cache.CaffeineEntityCache;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.metrics.source.EntityChangeLogMetricsSource;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.junit.jupiter.api.Assertions;
@@ -45,7 +49,12 @@ public class TestEntityCacheCrossNodeInvalidation extends 
TestJDBCBackend {
   // A large poll interval so the background scheduler never fires during the 
test; the test drives
   // node B's poll explicitly via pollChanges().
   private EntityChangeLogPoller newIdlePoller(CaffeineEntityCache nodeBCache) {
-    EntityChangeLogPoller poller = new EntityChangeLogPoller(3600);
+    return newIdlePoller(nodeBCache, 
Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
+  }
+
+  private EntityChangeLogPoller newIdlePoller(CaffeineEntityCache nodeBCache, 
int batchSize) {
+    EntityChangeLogPoller poller =
+        new EntityChangeLogPoller(3600, batchSize, new 
EntityChangeLogMetricsSource());
     poller.registerListener(new EntityCacheChangeLogListener(nodeBCache));
     // start() seeds the cursor with the current DB tail, modelling a node 
whose cache is already
     // warm: only changes written after this point are replayed.
@@ -85,6 +94,56 @@ public class TestEntityCacheCrossNodeInvalidation extends 
TestJDBCBackend {
     }
   }
 
+  @TestTemplate
+  void testBacklogLargerThanOneBatchReachesEveryDistinctEntityOnNodeB() throws 
IOException {
+    createParentEntities(METALAKE, CATALOG, SCHEMA, AUDIT_INFO);
+    Namespace namespace = Namespace.of(METALAKE, CATALOG, SCHEMA);
+    List<TableEntity> tables = new ArrayList<>();
+    for (int i = 0; i < 5; i++) {
+      TableEntity table =
+          createTableEntity(
+              RandomIdGenerator.INSTANCE.nextId(), namespace, "backlog_table" 
+ i, AUDIT_INFO);
+      backend.insert(table, false);
+      tables.add(table);
+    }
+
+    // Node B has cached every table.
+    CaffeineEntityCache nodeBCache = new CaffeineEntityCache(new Config() {});
+    tables.forEach(nodeBCache::put);
+
+    EntityChangeLogPoller nodeBPoller = newIdlePoller(nodeBCache, 2);
+    try {
+      // Node A renames every table, leaving five change rows: more than two 
batches of two.
+      for (TableEntity table : tables) {
+        backend.update(
+            table.nameIdentifier(),
+            EntityType.TABLE,
+            entity ->
+                createTableEntity(table.id(), namespace, table.name() + 
"_renamed", AUDIT_INFO));
+      }
+
+      // A poll reads one bounded batch and reports that more rows may be 
waiting.
+      Assertions.assertTrue(nodeBPoller.pollChanges());
+      Assertions.assertEquals(3, countCached(nodeBCache, tables));
+
+      // The scheduler polls again immediately while batches are full, until 
one is partial.
+      int polls = 1;
+      while (nodeBPoller.pollChanges()) {
+        polls++;
+        Assertions.assertTrue(polls < 10, "the backlog should drain in a 
bounded number of polls");
+      }
+      Assertions.assertEquals(0, countCached(nodeBCache, tables));
+    } finally {
+      nodeBPoller.close();
+    }
+  }
+
+  private static long countCached(CaffeineEntityCache cache, List<TableEntity> 
tables) {
+    return tables.stream()
+        .filter(table -> cache.contains(table.nameIdentifier(), 
EntityType.TABLE))
+        .count();
+  }
+
   @TestTemplate
   void testSchemaDropOnNodeAClearsChildTablesOnNodeB() throws IOException {
     createParentEntities(METALAKE, CATALOG, SCHEMA, AUDIT_INFO);
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 2d68d14d68..2b086f2f61 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
@@ -18,16 +18,37 @@
  */
 package org.apache.gravitino.storage.relational;
 
+import static org.mockito.AdditionalAnswers.delegatesTo;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.spy;
+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.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.apache.gravitino.metrics.source.EntityChangeLogMetricsSource;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
@@ -35,6 +56,8 @@ import 
org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.MockedStatic;
 
 public class TestEntityChangeLogPoller {
@@ -45,6 +68,190 @@ public class TestEntityChangeLogPoller {
   void testRejectsInvalidConfiguration() {
     Assertions.assertThrows(IllegalArgumentException.class, () -> new 
EntityChangeLogPoller(0));
     Assertions.assertThrows(IllegalArgumentException.class, () -> new 
EntityChangeLogPoller(-1));
+    EntityChangeLogMetricsSource metrics = new EntityChangeLogMetricsSource();
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new EntityChangeLogPoller(1, 0, 
metrics));
+  }
+
+  @Test
+  void testPollChangesReportsBacklogOnlyForFullBatch() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.selectEntityChanges(0L, 2)).thenReturn(changes(1L, 2L));
+    when(mapper.selectEntityChanges(2L, 2)).thenReturn(changes(3L, 3L));
+    when(mapper.selectEntityChanges(3L, 2)).thenReturn(List.of());
+    List<EntityChangeRecord> received = new ArrayList<>();
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogMetricsSource metrics = new 
EntityChangeLogMetricsSource();
+      EntityChangeLogPoller poller = new EntityChangeLogPoller(1, 2, metrics);
+      poller.registerListener(received::addAll);
+
+      Assertions.assertTrue(poller.pollChanges(), "a full batch may leave a 
backlog");
+      Assertions.assertFalse(poller.pollChanges(), "a partial batch means the 
poller caught up");
+      Assertions.assertFalse(poller.pollChanges(), "an empty poll means the 
poller caught up");
+      Assertions.assertEquals(
+          3L, 
metrics.getMetricRegistry().getGauges().get("cursor-id").getValue());
+    }
+
+    Assertions.assertEquals(List.of(1L, 2L, 3L), ids(received));
+  }
+
+  @Test
+  void testFullBatchSchedulesNextPollWithoutWaitingForTheInterval() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.selectEntityChanges(0L, 2)).thenReturn(changes(1L, 2L));
+    when(mapper.selectEntityChanges(2L, 2)).thenReturn(changes(3L, 3L));
+    when(mapper.selectEntityChanges(3L, 2)).thenThrow(new 
RuntimeException("database down"));
+    ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
+    ArgumentCaptor<Runnable> cycle = ArgumentCaptor.forClass(Runnable.class);
+    List<EntityChangeRecord> received = new ArrayList<>();
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+
+      EntityChangeLogPoller poller =
+          spy(new EntityChangeLogPoller(5, 2, new 
EntityChangeLogMetricsSource()));
+      doReturn(scheduler).when(poller).createScheduler();
+      poller.registerListener(received::addAll);
+
+      poller.start();
+      InOrder schedules = inOrder(scheduler);
+      schedules.verify(scheduler).schedule(cycle.capture(), eq(5000L), 
eq(TimeUnit.MILLISECONDS));
+
+      // A full batch: more rows may be waiting, so the next cycle starts 
right away.
+      cycle.getValue().run();
+      schedules.verify(scheduler).schedule(any(Runnable.class), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+
+      // A partial batch: caught up, so wait the normal interval.
+      cycle.getValue().run();
+      schedules
+          .verify(scheduler)
+          .schedule(any(Runnable.class), eq(5000L), eq(TimeUnit.MILLISECONDS));
+
+      // A failed poll keeps the normal interval instead of retrying in a 
tight loop.
+      cycle.getValue().run();
+      schedules
+          .verify(scheduler)
+          .schedule(any(Runnable.class), eq(5000L), eq(TimeUnit.MILLISECONDS));
+
+      // Once closed, a cycle that is still running does not schedule another 
one.
+      poller.close();
+      cycle.getValue().run();
+      verify(scheduler, times(4)).schedule(any(Runnable.class), anyLong(), 
any());
+    }
+
+    Assertions.assertEquals(List.of(1L, 2L, 3L), ids(received));
+  }
+
+  @Test
+  void testDrainSamplesTailOncePerIntervalAndRefreshesAfterCatchingUp() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.selectEntityChanges(0L, 2)).thenReturn(changes(1L, 2L));
+    when(mapper.selectEntityChanges(2L, 2)).thenReturn(changes(3L, 4L));
+    when(mapper.selectEntityChanges(4L, 2)).thenReturn(changes(5L, 6L));
+    when(mapper.selectEntityChanges(6L, 2)).thenReturn(changes(7L, 7L));
+    when(mapper.selectEntityChanges(7L, 2)).thenReturn(List.of());
+    when(mapper.selectMaxChangeId()).thenReturn(10L, 12L, 13L, 14L);
+    AtomicLong clock = new AtomicLong();
+    List<EntityChangeRecord> received = new ArrayList<>();
+    EntityChangeLogMetricsSource metrics = new EntityChangeLogMetricsSource();
+    EntityChangeLogPoller poller = spy(new EntityChangeLogPoller(5, 2, 
metrics));
+    doAnswer(invocation -> clock.get()).when(poller).nanoTime();
+    poller.registerListener(received::addAll);
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      Assertions.assertTrue(poller.pollChanges());
+      clock.set(TimeUnit.SECONDS.toNanos(1));
+      Assertions.assertTrue(poller.pollChanges());
+      verify(mapper, times(1)).selectMaxChangeId();
+      Assertions.assertEquals(
+          10L, 
metrics.getMetricRegistry().getGauges().get("db-tail-id").getValue());
+
+      clock.set(TimeUnit.SECONDS.toNanos(5));
+      Assertions.assertTrue(poller.pollChanges());
+      verify(mapper, times(2)).selectMaxChangeId();
+      Assertions.assertFalse(poller.pollChanges());
+      Assertions.assertFalse(poller.pollChanges());
+      verify(mapper, times(4)).selectMaxChangeId();
+    }
+    Assertions.assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), 
ids(received));
+    Assertions.assertEquals(
+        7L, 
metrics.getMetricRegistry().getGauges().get("cursor-id").getValue());
+    Assertions.assertEquals(
+        14L, 
metrics.getMetricRegistry().getGauges().get("db-tail-id").getValue());
+  }
+
+  @Test
+  void testFailedTailSamplesAreAlsoRateLimitedDuringDrain() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    when(mapper.selectEntityChanges(0L, 2)).thenReturn(changes(1L, 2L));
+    when(mapper.selectEntityChanges(2L, 2)).thenReturn(changes(3L, 4L));
+    when(mapper.selectEntityChanges(4L, 2)).thenReturn(changes(5L, 6L));
+    when(mapper.selectMaxChangeId()).thenThrow(new RuntimeException("tail 
unavailable"));
+    AtomicLong clock = new AtomicLong();
+    List<EntityChangeRecord> received = new ArrayList<>();
+    EntityChangeLogMetricsSource metrics = new EntityChangeLogMetricsSource();
+    EntityChangeLogPoller poller = spy(new EntityChangeLogPoller(5, 2, 
metrics));
+    doAnswer(invocation -> clock.get()).when(poller).nanoTime();
+    poller.registerListener(received::addAll);
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      Assertions.assertTrue(poller.pollChanges());
+      clock.set(TimeUnit.SECONDS.toNanos(1));
+      Assertions.assertTrue(poller.pollChanges());
+      verify(mapper, times(1)).selectMaxChangeId();
+      clock.set(TimeUnit.SECONDS.toNanos(5));
+      Assertions.assertTrue(poller.pollChanges());
+      verify(mapper, times(2)).selectMaxChangeId();
+    }
+    Assertions.assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L), ids(received));
+    Assertions.assertEquals(
+        6L, 
metrics.getMetricRegistry().getGauges().get("cursor-id").getValue());
+    Assertions.assertEquals(
+        2, 
metrics.getMetricRegistry().counter("tail-sample-failures-total").getCount());
+    Assertions.assertEquals(
+        0, 
metrics.getMetricRegistry().counter("poll-failures-total").getCount());
+  }
+
+  @Test
+  void testSchedulingRejectionDoesNotEscapeStart() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
+    doThrow(new RejectedExecutionException("scheduler stopped"))
+        .when(scheduler)
+        .schedule(any(Runnable.class), anyLong(), any());
+    EntityChangeLogPoller poller = spy(new EntityChangeLogPoller(1));
+    doReturn(scheduler).when(poller).createScheduler();
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      Assertions.assertDoesNotThrow(poller::start);
+      verify(scheduler).schedule(any(Runnable.class), anyLong(), any());
+      poller.close();
+    }
+  }
+
+  @Test
+  void testSchedulingRejectionWhileClosingDoesNotEscapeStart() {
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
+    EntityChangeLogPoller poller = spy(new EntityChangeLogPoller(1));
+    doReturn(scheduler).when(poller).createScheduler();
+    doAnswer(
+            invocation -> {
+              poller.close();
+              throw new RejectedExecutionException("closed during scheduling");
+            })
+        .when(scheduler)
+        .schedule(any(Runnable.class), anyLong(), any());
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      Assertions.assertDoesNotThrow(poller::start);
+      verify(scheduler).shutdown();
+      verify(scheduler).schedule(any(Runnable.class), anyLong(), any());
+    }
   }
 
   @Test
@@ -132,7 +339,7 @@ public class TestEntityChangeLogPoller {
       EntityChangeLogPoller poller = new EntityChangeLogPoller(1);
       // A listener that clears its catalog cache can close an 
IsolatedClassLoader that is still in
       // use, and a request holding a class from it then fails with 
NoClassDefFoundError, an Error
-      // rather than an Exception. pollChanges() is the task given to 
scheduleWithFixedDelay(), so
+      // rather than an Exception. pollChanges() runs inside each scheduled 
cycle, so
       // anything escaping it would cancel every future poll and freeze 
invalidation process-wide.
       poller.registerListener(
           changes -> {
@@ -389,6 +596,97 @@ public class TestEntityChangeLogPoller {
         0, 
metrics.getMetricRegistry().counter("records-applied-total").getCount());
   }
 
+  @Test
+  void testCloseDropsThePendingPollInsteadOfWaitingForIt() {
+    // Uses the real scheduler, with the pending poll two seconds away. If 
that delayed task
+    // survived
+    // shutdown, close() would wait for it. No poll may run after close() 
either. A poll would run
+    // on the scheduler thread, where this thread's static SessionUtils mock 
is not active, so it is
+    // observed through the poll timer, which counts every attempt, failed or 
not.
+    EntityChangeLogMetricsSource metrics = new EntityChangeLogMetricsSource();
+    EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+    long closeMillis;
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      mockSessionUtils(sessionUtils, mapper);
+      EntityChangeLogPoller poller = new EntityChangeLogPoller(2, metrics);
+      poller.start();
+      long startNanos = System.nanoTime();
+      poller.close();
+      closeMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startNanos);
+      Assertions.assertThrows(IllegalStateException.class, poller::start);
+    }
+
+    Assertions.assertTrue(closeMillis < 1500, "close() took " + closeMillis + 
" ms");
+    Assertions.assertEquals(0, 
metrics.getMetricRegistry().timer("poll-duration").getCount());
+  }
+
+  @Test
+  void testAlreadyDueDrainHopDoesNotPollAfterClose() throws Exception {
+    EntityChangeLogMetricsSource metrics = new EntityChangeLogMetricsSource();
+    EntityChangeLogPoller poller = spy(new EntityChangeLogPoller(1, metrics));
+    ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor) 
poller.createScheduler();
+    ScheduledExecutorService scheduler =
+        mock(ScheduledExecutorService.class, delegatesTo(executor));
+    doReturn(scheduler).when(poller).createScheduler();
+    CountDownLatch workerBusy = new CountDownLatch(1);
+    CountDownLatch releaseWorker = new CountDownLatch(1);
+    CountDownLatch shutdownStarted = new CountDownLatch(1);
+    AtomicReference<ScheduledFuture<?>> queuedHop = new AtomicReference<>();
+    doAnswer(
+            invocation -> {
+              // Queue the real poll-cycle runnable as an already-due drain 
hop behind a busy
+              // worker.
+              ScheduledFuture<?> hop =
+                  executor.schedule(invocation.<Runnable>getArgument(0), 0, 
TimeUnit.MILLISECONDS);
+              queuedHop.set(hop);
+              return hop;
+            })
+        .when(scheduler)
+        .schedule(any(Runnable.class), anyLong(), any());
+    doAnswer(
+            invocation -> {
+              executor.shutdown();
+              shutdownStarted.countDown();
+              return null;
+            })
+        .when(scheduler)
+        .shutdown();
+    ExecutorService closer = Executors.newSingleThreadExecutor();
+    Future<?> blockingTask =
+        executor.submit(
+            () -> {
+              workerBusy.countDown();
+              try {
+                Assertions.assertTrue(releaseWorker.await(5, 
TimeUnit.SECONDS));
+              } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+              }
+            });
+    try {
+      Assertions.assertTrue(workerBusy.await(5, TimeUnit.SECONDS));
+      EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
+      try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+        mockSessionUtils(sessionUtils, mapper);
+        poller.start();
+      }
+      Future<?> closed = closer.submit(poller::close);
+      Assertions.assertTrue(shutdownStarted.await(5, TimeUnit.SECONDS));
+      releaseWorker.countDown();
+      blockingTask.get(5, TimeUnit.SECONDS);
+      // The shutdown policy retains already-due tasks: get() proves this hop 
ran, not cancelled.
+      queuedHop.get().get(5, TimeUnit.SECONDS);
+      closed.get(5, TimeUnit.SECONDS);
+      Assertions.assertEquals(0, 
metrics.getMetricRegistry().timer("poll-duration").getCount());
+    } finally {
+      releaseWorker.countDown();
+      executor.shutdownNow();
+      closer.shutdownNow();
+      poller.close();
+    }
+  }
+
   @Test
   void testDispatchesImmutableBatchToListeners() {
     EntityChangeLogMapper mapper = mock(EntityChangeLogMapper.class);
@@ -425,6 +723,10 @@ public class TestEntityChangeLogPoller {
     return changes;
   }
 
+  private static List<Long> ids(List<EntityChangeRecord> changes) {
+    return 
changes.stream().map(EntityChangeRecord::getId).collect(Collectors.toList());
+  }
+
   private static void mockSessionUtils(
       MockedStatic<SessionUtils> sessionUtils, EntityChangeLogMapper mapper) {
     sessionUtils
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
index 9fa204a8d2..00197d0c6d 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
@@ -205,6 +205,8 @@ public class TestRelationalEntityStoreHierarchicalCache {
     Mockito.when(config.get(Configs.STORE_DELETE_AFTER_TIME)).thenReturn(20 * 
60 * 1000L);
     Mockito.when(config.get(Configs.VERSION_RETENTION_COUNT)).thenReturn(1L);
     
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+    Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_POLL_BATCH_SIZE))
+        .thenReturn(Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24
 * 60 * 60L);
     
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60
 * 60L);
     Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(true);
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 de3f9c8473..31ea975306 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -18,8 +18,10 @@
  */
 package org.apache.gravitino.tag;
 
+import static 
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
 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_POLL_BATCH_SIZE;
 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;
@@ -173,6 +175,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_POLL_BATCH_SIZE))
+        .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
     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/design-docs/gravitino-entity-cache-multinode-design.md 
b/design-docs/gravitino-entity-cache-multinode-design.md
index a4dc346642..4d0bedb14c 100644
--- a/design-docs/gravitino-entity-cache-multinode-design.md
+++ b/design-docs/gravitino-entity-cache-multinode-design.md
@@ -338,7 +338,7 @@ After this, everything a per-node cache serves is safe or 
bounded by change-log
 For everything that stays in the cache:
 
 - The node that made the change sees it right away.
-- Under normal operation, every other node sees it after the next successful 
poll. The interval is set by `gravitino.entityChangeLog.pollIntervalSecs` 
(**default 3 seconds**; lower it, e.g. to 1 second, for a shorter delay at the 
cost of more frequent DB polls). Database failures or a listener that fails to 
recover can extend this delay.
+- Under normal operation, every other node sees it once a successful poll 
reaches the change. Each poll reads at most 
`gravitino.entityChangeLog.pollBatchSize` records (**default 2000**); a full 
batch schedules the next poll immediately to drain backlog. After an empty or 
partial batch, or a failed poll, the interval is set by 
`gravitino.entityChangeLog.pollIntervalSecs` (**default 3 seconds**). 
Propagation includes the time to drain earlier records, and database or 
listener recovery fai [...]
 - The cache's own TTL (minutes to hours) is only a safety net in case the 
poller ever misses a row; it is not the main mechanism.
 
 The poller uses gap-safe, id-based polling (see `#11736`). It delivers a batch 
once and advances its shared cursor even when a listener fails, so each 
listener must recover locally by clearing its cache. The cache TTL remains a 
backstop. Operators can use the [entity change log 
metrics](../docs/metrics.md#entity-change-log-metrics) to watch the sampled 
database tail, cursor, record lag, last successful poll age, listener failures, 
and fallback clears; the debug logs correlate the write,  [...]
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 672dd01340..44ca811a29 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -327,9 +327,22 @@ Caches are local to each server, so a metalake modified on 
one server would othe
 its neighbors. Every server writes its changes to an entity change log table 
and polls that table
 to invalidate what other servers have touched. A separate cleaner trims old 
rows.
 
+Each poll reads at most `pollBatchSize` records. When a poll returns a full 
batch, the next poll
+starts immediately instead of waiting `pollIntervalSecs`, so a backlog is 
drained as fast as the
+server can read and apply batches rather than at a fixed `pollBatchSize / 
pollIntervalSecs`
+records per second. Once a poll returns fewer records than the batch size, or 
fails, the server
+waits `pollIntervalSecs` again. A change written on one server therefore 
normally becomes visible
+on the others within about `pollIntervalSecs`, plus the time needed to drain 
any backlog ahead of
+it. Continuous draining deliberately has no minimum delay between batches; a 
sustained backlog
+can therefore keep the data query running at the rate the database and 
listeners support. The
+observability-only database-tail query is sampled at most once per 
`pollIntervalSecs` during a
+full-batch drain; partial and empty polls still sample the tail. Each poll 
holds its whole batch
+in memory, so raise `pollBatchSize` in moderate steps.
+
 | Configuration Item                              | Description                
                                                                                
                                         | Default Value       |
 
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|
-| `gravitino.entityChangeLog.pollIntervalSecs`    | Interval in seconds 
between polls. Must be positive.                                                
                                                | `3`                 |
+| `gravitino.entityChangeLog.pollIntervalSecs`    | Interval in seconds 
between polls once a server has caught up. Must be positive.                    
                                                | `3`                 |
+| `gravitino.entityChangeLog.pollBatchSize`       | Maximum number of change 
log records read per poll. A full batch is followed by another poll right away. 
Must be positive.                          | `2000`              |
 | `gravitino.entityChangeLog.retentionSecs`       | How long in seconds change 
log rows are kept, measured by database time. `0` disables cleanup; otherwise 
use at least ten times `pollIntervalSecs`. | `2592000` (30 days) |
 | `gravitino.entityChangeLog.cleanupIntervalSecs` | Interval in seconds 
between cleaner runs. Must be positive.                                         
                                                | `86400` (1 day)     |
 
diff --git a/docs/metrics.md b/docs/metrics.md
index b7e19b32c9..5d54df52f5 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -77,8 +77,9 @@ 
gravitino_catalog_datasource_max_connections{provider="jdbc",metalake="test_meta
 The `entity-change-log` source exposes each server's change-log processing 
state through JMX and
 `/prometheus/metrics`. For example, `entity-change-log.record-lag` in the 
metrics registry becomes
 `entity_change_log_record_lag` in Prometheus. Gauges read only in-memory 
values; the poller samples
-the database tail once per cycle. If only the tail sample fails, delivery 
continues and the tail
-value remains at its last successful sample.
+the database tail on partial and empty polls, and at most once per 
`pollIntervalSecs` during
+a full-batch drain (including failed sample attempts). If only the tail sample 
fails, delivery
+continues and the tail value remains at its last successful sample.
 
 | Metric suffix                                                | Type and unit 
           | Meaning                                                            
                                                                                
         |
 | ------------------------------------------------------------ | 
------------------------ | 
-----------------------------------------------------------------------------------------------------------------------------------------------------------
 |
@@ -92,7 +93,7 @@ value remains at its last successful sample.
 | `records-fetched-total`, `records-delivered-total`           | counter, 
records         | Rows fetched and rows delivered successfully to listeners; 
one row delivered to two listeners counts twice as delivered.                   
                 |
 | `records-delivered.<class>-total`                            | counter, 
records         | Successful deliveries by listener class. Lambda and anonymous 
listeners share the `anonymous` bucket.                                         
              |
 | `records-applied-total`                                      | counter, 
invalidations   | Targeted entity-cache invalidations completed successfully; 
malformed rows and fallback clears do not count.                                
                |
-| `batch-size-records`                                         | histogram, 
records       | Number of rows fetched per successful poll, including empty 
polls.                                                                          
                |
+| `batch-size-records`                                         | histogram, 
records       | Number of rows fetched per successful poll, including empty 
polls. Polls at `pollBatchSize` mean the server is draining a backlog and polls 
again at once.  |
 | `poll-duration`                                              | timer, 
duration          | End-to-end poll-cycle duration.                             
                                                                                
                |
 | `invalidation-failures-total`, `fallback-clears-total`       | counter, 
failures/clears | Failed targeted entity-cache invalidations and successful 
full-cache recovery clears.                                                     
                  |
 
@@ -102,9 +103,11 @@ recover locally; its failure counter and log identify the 
affected listener. The
 row was added to the current transaction, not that the transaction committed.
 
 For an incident, check `seconds-since-last-successful-tail-sample` before 
comparing `db-tail-id`
-with `cursor-id` on the affected server. If it exceeds the poll interval, the 
tail sample itself is
-failing: the retained tail can fall below an advancing cursor and `record-lag` 
can read zero despite
-an unknown database tail. `tail-sample-failures-total` counts those failures 
for alerting. With a
+with `cursor-id` on the affected server. If it substantially exceeds the 
normal sampling interval,
+investigate slow or failed polling and tail sampling; 
`tail-sample-failures-total` counts actual
+sample failures. Between samples, an advancing cursor can pass the retained 
tail, so `record-lag`
+can read zero even while full batches remain. Use `batch-size-records` at 
`pollBatchSize` as the
+backlog signal rather than treating zero sampled lag as proof that the poller 
has caught up. With a
 fresh tail sample, a growing `record-lag` together with an increasing poll age 
or
 `poll-failures-total` points to polling trouble.
 If the cursor advances but data remains stale, inspect 
`listener-failures-total`, `records-delivered.<class>-total`,

Reply via email to