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

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 34db649643 [Cherry-pick to branch-1.3] [#14022] fix(core): Honor the 
declared mode of event listeners registered in code (#14023) (#14193)
34db649643 is described below

commit 34db649643ea6b43895495ebc47eb28eab11ae5a
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 25 09:35:32 2026 +0800

    [Cherry-pick to branch-1.3] [#14022] fix(core): Honor the declared mode of 
event listeners registered in code (#14023) (#14193)
    
    **Cherry-pick Information:**
    - Original commit: 1d73fb1b7945279f79aa5e9d6bf5e7db8150f665
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: Jerry Shao <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/gravitino/GravitinoEnv.java    |  27 +--
 .../job/BuiltInJobTemplateEventListener.java       | 177 ++++++++++++++++--
 .../gravitino/listener/AsyncQueueListener.java     |  48 ++++-
 .../org/apache/gravitino/listener/EventBus.java    |  18 +-
 .../gravitino/listener/EventListenerManager.java   | 109 ++++++-----
 .../apache/gravitino/TestGravitinoEnvShutdown.java | 104 +++++++++++
 .../apache/gravitino/audit/DummyAuditWriter.java   |   7 +-
 .../apache/gravitino/audit/TestAuditManager.java   |  28 +++
 .../job/TestBuiltInJobTemplateEventListener.java   | 184 +++++++++++++++++-
 .../gravitino/listener/TestAsyncQueueListener.java | 120 ++++++++++++
 .../listener/TestEventListenerManager.java         | 206 +++++++++++++++++++++
 11 files changed, 933 insertions(+), 95 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java 
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index c0222c2089..4fee8c7d27 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -670,32 +670,35 @@ public class GravitinoEnv {
   public void shutdown() {
     LOG.info("Shutting down Gravitino Environment...");
 
-    if (entityStore != null) {
+    // Stop the auxiliary services first, since they share the event bus. Then 
stop the event
+    // listeners, so the async listeners finish the queued events, such as 
audit logs and built-in
+    // job template registration, before the entity store and the catalogs are 
closed.
+    if (auxServiceManager != null) {
       try {
-        entityStore.close();
+        auxServiceManager.serviceStop();
       } catch (Exception e) {
-        LOG.warn("Failed to close EntityStore.", e);
+        LOG.warn("Failed to stop AuxServiceManager", e);
       }
     }
 
-    if (catalogManager != null) {
-      catalogManager.close();
+    if (eventListenerManager != null) {
+      eventListenerManager.stop();
     }
 
-    if (auxServiceManager != null) {
+    if (entityStore != null) {
       try {
-        auxServiceManager.serviceStop();
+        entityStore.close();
       } catch (Exception e) {
-        LOG.warn("Failed to stop AuxServiceManager", e);
+        LOG.warn("Failed to close EntityStore.", e);
       }
     }
 
-    if (metricsSystem != null) {
-      metricsSystem.close();
+    if (catalogManager != null) {
+      catalogManager.close();
     }
 
-    if (eventListenerManager != null) {
-      eventListenerManager.stop();
+    if (metricsSystem != null) {
+      metricsSystem.close();
     }
 
     if (metalakeManager != null) {
diff --git 
a/core/src/main/java/org/apache/gravitino/job/BuiltInJobTemplateEventListener.java
 
b/core/src/main/java/org/apache/gravitino/job/BuiltInJobTemplateEventListener.java
index 48568e3687..5613f83f93 100644
--- 
a/core/src/main/java/org/apache/gravitino/job/BuiltInJobTemplateEventListener.java
+++ 
b/core/src/main/java/org/apache/gravitino/job/BuiltInJobTemplateEventListener.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.job;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import java.io.File;
 import java.io.IOException;
 import java.net.MalformedURLException;
@@ -32,6 +33,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.ServiceLoader;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -39,14 +44,18 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.listener.api.EventListenerPlugin;
 import org.apache.gravitino.listener.api.event.CreateMetalakeEvent;
+import org.apache.gravitino.listener.api.event.EnableMetalakeEvent;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.lock.LockType;
 import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.JobTemplateEntity;
 import org.apache.gravitino.metalake.MetalakeManager;
 import org.apache.gravitino.storage.IdGenerator;
@@ -59,8 +68,8 @@ import org.slf4j.LoggerFactory;
 /**
  * Event listener that automatically registers built-in job templates when 
metalakes are created.
  *
- * <p>This listener monitors metalake creation events and registers all 
discovered built-in job
- * templates (via JobTemplateProvider SPI) into the newly created metalake. It 
also handles
+ * <p>This listener monitors metalake creation and enablement events and 
registers all discovered
+ * built-in job templates (via JobTemplateProvider SPI) into the metalake. It 
also handles
  * registration for existing metalakes on first startup.
  */
 public class BuiltInJobTemplateEventListener implements EventListenerPlugin {
@@ -72,15 +81,38 @@ public class BuiltInJobTemplateEventListener implements 
EventListenerPlugin {
   private static final Pattern VERSION_PATTERN =
       Pattern.compile(JobTemplateProvider.VERSION_VALUE_PATTERN);
 
+  private static final long REGISTRATION_STOP_TIMEOUT_SECONDS = 10;
+
   private final JobManager jobManager;
   private final EntityStore entityStore;
   private final IdGenerator idGenerator;
+  // Registers the templates of created metalakes off the request thread. Its 
queue is unbounded,
+  // so a created metalake never misses its templates; metalakes are created 
rarely.
+  private final ExecutorService registrationExecutor;
 
   public BuiltInJobTemplateEventListener(
       JobManager jobManager, EntityStore entityStore, IdGenerator idGenerator) 
{
+    this(
+        jobManager,
+        entityStore,
+        idGenerator,
+        Executors.newSingleThreadExecutor(
+            new ThreadFactoryBuilder()
+                .setDaemon(true)
+                .setNameFormat("builtin-job-template-registration")
+                .build()));
+  }
+
+  @VisibleForTesting
+  BuiltInJobTemplateEventListener(
+      JobManager jobManager,
+      EntityStore entityStore,
+      IdGenerator idGenerator,
+      ExecutorService registrationExecutor) {
     this.jobManager = jobManager;
     this.entityStore = entityStore;
     this.idGenerator = idGenerator;
+    this.registrationExecutor = registrationExecutor;
   }
 
   @Override
@@ -123,34 +155,58 @@ public class BuiltInJobTemplateEventListener implements 
EventListenerPlugin {
 
   @Override
   public void stop() throws RuntimeException {
-    // No resources to clean up
+    // Finish the pending registrations. Any that do not finish in time are 
registered by start()
+    // on the next startup, which reconciles all the metalakes.
+    registrationExecutor.shutdown();
+    try {
+      if (!registrationExecutor.awaitTermination(
+          REGISTRATION_STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+        LOG.warn(
+            "Built-in job template registration did not finish within {} 
seconds, stop it",
+            REGISTRATION_STOP_TIMEOUT_SECONDS);
+        registrationExecutor.shutdownNow();
+      }
+    } catch (InterruptedException e) {
+      registrationExecutor.shutdownNow();
+      Thread.currentThread().interrupt();
+    }
   }
 
   @Override
   public void onPostEvent(Event postEvent) throws RuntimeException {
-    if (postEvent instanceof CreateMetalakeEvent) {
-      CreateMetalakeEvent event = (CreateMetalakeEvent) postEvent;
-      String metalakeName = event.identifier().name();
-
-      try {
-        Map<String, JobTemplate> builtInTemplates = loadBuiltInJobTemplates();
-        if (builtInTemplates.isEmpty()) {
-          LOG.debug("No built-in job templates to register for metalake: {}", 
metalakeName);
-          return;
-        }
+    // Register the templates when a metalake is created, and again when it is 
enabled: a
+    // registration that runs while the metalake is disabled is skipped. 
Reconciling is idempotent.
+    if (!(postEvent instanceof CreateMetalakeEvent)
+        && !(postEvent instanceof EnableMetalakeEvent)) {
+      return;
+    }
 
-        reconcileBuiltInJobTemplates(metalakeName, builtInTemplates);
-        LOG.info("Registered built-in job templates for metalake: {}", 
metalakeName);
-      } catch (Exception e) {
-        LOG.error("Failed to register built-in job templates for metalake: 
{}", metalakeName, e);
-      }
+    String metalakeName = postEvent.identifier().name();
+    String user = postEvent.user();
+    // Capture the id of the metalake now. By the time the registration runs, 
the metalake may be
+    // renamed, or dropped and re-created under the same name, so it is looked 
up by id.
+    Optional<Long> metalakeId = loadMetalakeId(metalakeName);
+    if (!metalakeId.isPresent()) {
+      return;
+    }
+
+    try {
+      registrationExecutor.execute(
+          () -> registerBuiltInJobTemplates(metalakeId.get(), metalakeName, 
user));
+    } catch (RejectedExecutionException e) {
+      LOG.warn(
+          "Skip registering built-in job templates for metalake {} since the 
listener is stopped, "
+              + "they will be registered on the next startup",
+          metalakeName);
     }
   }
 
   @Override
   public Mode mode() {
-    // Use async isolated to avoid blocking metalake creation
-    return Mode.ASYNC_ISOLATED;
+    // onPostEvent only hands created metalakes to the registration executor, 
which is cheap, so it
+    // runs on the caller thread. Unlike an async listener queue, the executor 
never drops a
+    // created metalake when it is busy.
+    return Mode.SYNC;
   }
 
   @VisibleForTesting
@@ -246,6 +302,87 @@ public class BuiltInJobTemplateEventListener implements 
EventListenerPlugin {
     }
   }
 
+  private Optional<Long> loadMetalakeId(String metalakeName) {
+    NameIdentifier ident = NameIdentifierUtil.ofMetalake(metalakeName);
+    try {
+      BaseMetalake metalake =
+          TreeLockUtils.doWithTreeLock(
+              ident,
+              LockType.READ,
+              () -> entityStore.get(ident, Entity.EntityType.METALAKE, 
BaseMetalake.class));
+      return Optional.of(metalake.id());
+    } catch (Exception e) {
+      LOG.warn(
+          "Skip registering built-in job templates for metalake {} since it 
cannot be loaded, "
+              + "they will be registered on the next startup",
+          metalakeName,
+          e);
+      return Optional.empty();
+    }
+  }
+
+  private void registerBuiltInJobTemplates(long metalakeId, String eventName, 
String user) {
+    try {
+      Map<String, JobTemplate> builtInTemplates = loadBuiltInJobTemplates();
+      if (builtInTemplates.isEmpty()) {
+        LOG.debug("No built-in job templates to register for metalake: {}", 
eventName);
+        return;
+      }
+
+      // Renaming or dropping a metalake takes the root write lock, so holding 
the root read lock
+      // keeps the metalake found by id from being renamed or dropped during 
the registration.
+      TreeLockUtils.doWithRootTreeLock(
+          LockType.READ,
+          () -> {
+            Optional<String> currentName = currentMetalakeName(metalakeId);
+            if (!currentName.isPresent()) {
+              LOG.info(
+                  "Skip registering built-in job templates for metalake {} 
since it was dropped",
+                  eventName);
+              return null;
+            }
+
+            // Disabling or enabling a metalake takes its write lock, so it 
stays in the same state
+            // while its templates are registered.
+            NameIdentifier ident = 
NameIdentifierUtil.ofMetalake(currentName.get());
+            return TreeLockUtils.doWithTreeLock(
+                ident,
+                LockType.READ,
+                () -> {
+                  if (!MetalakeManager.metalakeInUse(entityStore, ident)) {
+                    LOG.info(
+                        "Skip registering built-in job templates for metalake 
{} since it is "
+                            + "disabled, they will be registered when it is 
enabled",
+                        currentName.get());
+                    return null;
+                  }
+
+                  // The registration thread has no caller principal. Run as 
the user from the
+                  // event, so the templates record that user as the creator.
+                  PrincipalUtils.doAs(
+                      new UserPrincipal(user),
+                      () -> {
+                        reconcileBuiltInJobTemplates(currentName.get(), 
builtInTemplates);
+                        return null;
+                      });
+                  LOG.info("Registered built-in job templates for metalake: 
{}", currentName.get());
+                  return null;
+                });
+          });
+    } catch (Exception e) {
+      LOG.error("Failed to register built-in job templates for metalake: {}", 
eventName, e);
+    }
+  }
+
+  private Optional<String> currentMetalakeName(long metalakeId) throws 
IOException {
+    return entityStore
+        .list(Namespace.empty(), BaseMetalake.class, 
Entity.EntityType.METALAKE)
+        .stream()
+        .filter(metalake -> metalake.id() == metalakeId)
+        .map(BaseMetalake::name)
+        .findFirst();
+  }
+
   private boolean isValidBuiltInJobTemplate(JobTemplate jobTemplate) {
     if (!isValidBuiltInName(jobTemplate.name())) {
       return false;
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/AsyncQueueListener.java 
b/core/src/main/java/org/apache/gravitino/listener/AsyncQueueListener.java
index fd5387b40b..d25e6a377d 100644
--- a/core/src/main/java/org/apache/gravitino/listener/AsyncQueueListener.java
+++ b/core/src/main/java/org/apache/gravitino/listener/AsyncQueueListener.java
@@ -20,12 +20,13 @@
 package org.apache.gravitino.listener;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.Uninterruptibles;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import org.apache.gravitino.listener.api.EventListenerPlugin;
@@ -45,6 +46,8 @@ public class AsyncQueueListener implements 
EventListenerPlugin {
   private static final Logger LOG = 
LoggerFactory.getLogger(AsyncQueueListener.class);
   private static final String NAME_PREFIX = "async-queue-listener-";
   private static final float HIGH_WATERMARK_RATIO = 0.9f;
+  // Queued by stop() behind the pending events. The dispatcher thread exits 
when it takes it.
+  private static final BaseEvent STOP_SIGNAL = new BaseEvent(null, null) {};
 
   private final List<EventListenerPlugin> eventListeners;
   private final BlockingQueue<BaseEvent> queue;
@@ -97,13 +100,36 @@ public class AsyncQueueListener implements 
EventListenerPlugin {
 
   @Override
   public void stop() {
-    Preconditions.checkState(!stopped.get(), asyncQueueListenerName + " had 
already stopped");
-    stopped.compareAndSet(false, true);
-    asyncProcessor.interrupt();
+    // Stop can be called more than once, e.g. by the server shutdown hook 
after an explicit stop.
+    if (!stopped.compareAndSet(false, true)) {
+      LOG.debug("{} had already stopped", asyncQueueListenerName);
+      return;
+    }
+    long joinMillis = TimeUnit.SECONDS.toMillis(dispatcherJoinSeconds);
+    long deadlineNanos = System.nanoTime() + 
TimeUnit.MILLISECONDS.toNanos(joinMillis);
+    // Give the dispatcher thread up to dispatcherJoinSeconds to drain the 
queued events. If it
+    // does not finish in time, interrupt it and wait up to another 
dispatcherJoinSeconds for it to
+    // exit, which is how long stop() waited for the thread before draining 
was added. So a stuck
+    // dispatcher delays stop() by at most twice dispatcherJoinSeconds.
     try {
-      asyncProcessor.join(dispatcherJoinSeconds * 1000L);
+      if (asyncProcessor.isAlive()
+          && queue.offer(STOP_SIGNAL, remainingMillis(deadlineNanos), 
TimeUnit.MILLISECONDS)) {
+        asyncProcessor.join(remainingMillis(deadlineNanos));
+      }
     } catch (InterruptedException e) {
-      LOG.warn("{} interrupt async processor failed.", asyncQueueListenerName, 
e);
+      // Skip the drain, but still stop the dispatcher thread below. The 
interrupt status is kept.
+      LOG.warn("{} is interrupted while draining the queued events.", 
asyncQueueListenerName);
+      Thread.currentThread().interrupt();
+    }
+    if (asyncProcessor.isAlive()) {
+      asyncProcessor.interrupt();
+      Uninterruptibles.joinUninterruptibly(asyncProcessor, joinMillis, 
TimeUnit.MILLISECONDS);
+    }
+    if (asyncProcessor.isAlive()) {
+      LOG.warn(
+          "{} event dispatcher thread is still running after it was 
interrupted, stop the "
+              + "listeners anyway.",
+          asyncQueueListenerName);
     }
     eventListeners.forEach(listenerPlugin -> listenerPlugin.stop());
   }
@@ -121,6 +147,9 @@ public class AsyncQueueListener implements 
EventListenerPlugin {
     while (!Thread.currentThread().isInterrupted()) {
       try {
         BaseEvent baseEvent = queue.take();
+        if (baseEvent == STOP_SIGNAL) {
+          break;
+        }
         if (baseEvent instanceof PreEvent) {
           this.eventListeners.forEach(listener -> 
listener.onPreEvent((PreEvent) baseEvent));
         } else if (baseEvent instanceof Event) {
@@ -138,12 +167,17 @@ public class AsyncQueueListener implements 
EventListenerPlugin {
 
     if (!queue.isEmpty()) {
       LOG.warn(
-          "{} drop {} events since dispatch thread is interrupted",
+          "{} drop {} events since dispatch thread is stopped",
           asyncQueueListenerName,
           queue.size());
     }
   }
 
+  // Never returns 0, since Thread.join(0) waits forever.
+  private static long remainingMillis(long deadlineNanos) {
+    return Math.max(1, TimeUnit.NANOSECONDS.toMillis(deadlineNanos - 
System.nanoTime()));
+  }
+
   private void logDropEventsIfNecessary() {
     long dropEventsToLog = 0;
     Instant previousRecordTime = null;
diff --git a/core/src/main/java/org/apache/gravitino/listener/EventBus.java 
b/core/src/main/java/org/apache/gravitino/listener/EventBus.java
index 33de94ce42..387b31a381 100644
--- a/core/src/main/java/org/apache/gravitino/listener/EventBus.java
+++ b/core/src/main/java/org/apache/gravitino/listener/EventBus.java
@@ -65,12 +65,24 @@ public class EventBus {
    *     with this EventBus for event dispatch.
    */
   public EventBus(List<EventListenerPlugin> eventListeners) {
-    this.eventListeners = eventListeners;
-    this.asyncQueueListeners =
+    this(
+        eventListeners,
         eventListeners.stream()
             .filter(AsyncQueueListener.class::isInstance)
             .map(AsyncQueueListener.class::cast)
-            .collect(Collectors.toList());
+            .collect(Collectors.toList()));
+  }
+
+  /**
+   * Constructs an EventBus with the event listeners and the async queues 
among them. The lists are
+   * kept by reference, so listeners the caller adds to them later are 
dispatched and checked too.
+   *
+   * @param eventListeners The listeners to dispatch events to.
+   * @param asyncQueueListeners The {@link AsyncQueueListener} instances in 
{@code eventListeners}.
+   */
+  EventBus(List<EventListenerPlugin> eventListeners, List<AsyncQueueListener> 
asyncQueueListeners) {
+    this.eventListeners = eventListeners;
+    this.asyncQueueListeners = asyncQueueListeners;
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/EventListenerManager.java 
b/core/src/main/java/org/apache/gravitino/listener/EventListenerManager.java
index 224d4e2d16..4cf157946a 100644
--- a/core/src/main/java/org/apache/gravitino/listener/EventListenerManager.java
+++ b/core/src/main/java/org/apache/gravitino/listener/EventListenerManager.java
@@ -23,10 +23,9 @@ import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.listener.api.EventListenerPlugin;
@@ -59,9 +58,17 @@ public class EventListenerManager {
 
   private int queueCapacity;
   private int dispatcherJoinSeconds;
-  private List<EventListenerPlugin> eventListeners;
+  // Copy-on-write lists, so listeners added after start() are safe to iterate 
concurrently.
+  private final List<EventListenerPlugin> eventListeners = new 
CopyOnWriteArrayList<>();
+  private final List<EventListenerPlugin> sharedQueueListeners = new 
CopyOnWriteArrayList<>();
+  private final List<AsyncQueueListener> asyncQueueListeners = new 
CopyOnWriteArrayList<>();
+  private boolean initialized = false;
+  private volatile boolean started = false;
 
-  public void init(Map<String, String> properties) {
+  public synchronized void init(Map<String, String> properties) {
+    // The listener lists are only appended to, so a second init would 
register them twice.
+    Preconditions.checkState(!initialized, "EventListenerManager has already 
been initialized");
+    initialized = true;
     EventListenerConfig config = new EventListenerConfig(properties);
     this.queueCapacity = config.get(EventListenerConfig.QUEUE_CAPACITY);
     this.dispatcherJoinSeconds = 
config.get(EventListenerConfig.DISPATCHER_JOIN_SECONDS);
@@ -83,61 +90,73 @@ public class EventListenerManager {
                       throw new IllegalStateException(
                           "Duplicate event listener name detected: " + 
existingValue);
                     }));
-    this.eventListeners = assembleEventListeners(userEventListenerPlugins);
+    userEventListenerPlugins.forEach(this::assembleEventListener);
   }
 
-  public void start() {
-    eventListeners.stream().forEach(listener -> listener.start());
+  public synchronized void start() {
+    eventListeners.forEach(EventListenerPlugin::start);
+    started = true;
   }
 
-  public void stop() {
-    eventListeners.stream().forEach(listener -> listener.stop());
+  public synchronized void stop() {
+    eventListeners.forEach(EventListenerPlugin::stop);
+    started = false;
   }
 
   public EventBus createEventBus() {
-    return new EventBus(eventListeners);
+    return new EventBus(eventListeners, asyncQueueListeners);
   }
 
-  public void addEventListener(String listenerName, EventListenerPlugin 
listener) {
-    eventListeners.add(new EventListenerPluginWrapper(listenerName, listener));
+  /**
+   * Registers an event listener that is created in code rather than loaded 
from the configuration.
+   * The listener is dispatched according to its {@link 
EventListenerPlugin#mode()}, the same as the
+   * configured listeners. If the manager has already started, the listener is 
started here.
+   *
+   * @param listenerName The name of the listener.
+   * @param listener The listener to register.
+   */
+  public synchronized void addEventListener(String listenerName, 
EventListenerPlugin listener) {
+    assembleEventListener(listenerName, listener);
   }
 
-  private List<EventListenerPlugin> assembleEventListeners(
-      Map<String, EventListenerPlugin> userEventListeners) {
-    List<EventListenerPlugin> sharedQueueListeners = new ArrayList<>();
+  private void assembleEventListener(String listenerName, EventListenerPlugin 
listener) {
+    EventListenerPluginWrapper wrapper = new 
EventListenerPluginWrapper(listenerName, listener);
+    switch (listener.mode()) {
+      case SYNC:
+        addAndStartIfNeeded(eventListeners, wrapper);
+        break;
+      case ASYNC_ISOLATED:
+        addAsyncQueueListener(
+            new AsyncQueueListener(
+                ImmutableList.of(wrapper), listenerName, queueCapacity, 
dispatcherJoinSeconds));
+        break;
+      case ASYNC_SHARED:
+        if (!sharedQueueListeners.isEmpty()) {
+          addAndStartIfNeeded(sharedQueueListeners, wrapper);
+        } else {
+          // Starting the shared queue starts the wrapper as well.
+          sharedQueueListeners.add(wrapper);
+          addAsyncQueueListener(
+              new AsyncQueueListener(
+                  sharedQueueListeners, "default", queueCapacity, 
dispatcherJoinSeconds));
+        }
+        break;
+      default:
+        throw new RuntimeException("Unexpected listener mode:" + 
listener.mode());
+    }
+  }
 
-    List<EventListenerPlugin> listeners =
-        userEventListeners.entrySet().stream()
-            .map(
-                entrySet -> {
-                  String listenerName = entrySet.getKey();
-                  EventListenerPlugin listener = entrySet.getValue();
-                  switch (listener.mode()) {
-                    case SYNC:
-                      return new EventListenerPluginWrapper(listenerName, 
listener);
-                    case ASYNC_ISOLATED:
-                      return new AsyncQueueListener(
-                          ImmutableList.of(new 
EventListenerPluginWrapper(listenerName, listener)),
-                          listenerName,
-                          queueCapacity,
-                          dispatcherJoinSeconds);
-                    case ASYNC_SHARED:
-                      sharedQueueListeners.add(
-                          new EventListenerPluginWrapper(listenerName, 
listener));
-                      return null;
-                    default:
-                      throw new RuntimeException("Unexpected listener mode:" + 
listener.mode());
-                  }
-                })
-            .filter(Objects::nonNull)
-            .collect(Collectors.toList());
+  private void addAsyncQueueListener(AsyncQueueListener asyncQueueListener) {
+    addAndStartIfNeeded(eventListeners, asyncQueueListener);
+    asyncQueueListeners.add(asyncQueueListener);
+  }
 
-    if (!sharedQueueListeners.isEmpty()) {
-      listeners.add(
-          new AsyncQueueListener(
-              sharedQueueListeners, "default", queueCapacity, 
dispatcherJoinSeconds));
+  private void addAndStartIfNeeded(
+      List<EventListenerPlugin> listeners, EventListenerPlugin listener) {
+    if (started) {
+      listener.start();
     }
-    return listeners;
+    listeners.add(listener);
   }
 
   private EventListenerPlugin loadUserEventListenerPlugin(
diff --git 
a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvShutdown.java 
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvShutdown.java
new file mode 100644
index 0000000000..bcaa838a43
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvShutdown.java
@@ -0,0 +1,104 @@
+/*
+ * 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;
+
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
+import org.apache.gravitino.listener.EventListenerManager;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.event.Event;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+
+public class TestGravitinoEnvShutdown {
+
+  @Test
+  void testAsyncListenersDrainBeforeEntityStoreCloses() throws Exception {
+    AtomicBoolean entityStoreClosed = new AtomicBoolean(false);
+    EntityStore entityStore = mock(EntityStore.class);
+    doAnswer(
+            invocation -> {
+              entityStoreClosed.set(true);
+              return null;
+            })
+        .when(entityStore)
+        .close();
+    AuxiliaryServiceManager auxServiceManager = 
mock(AuxiliaryServiceManager.class);
+
+    // A slow async listener that records whether the entity store was still 
open while it ran.
+    AtomicInteger eventsSeenWithOpenStore = new AtomicInteger();
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(new HashMap<>());
+    eventListenerManager.addEventListener(
+        "slow",
+        new EventListenerPlugin() {
+          @Override
+          public void init(Map<String, String> properties) {}
+
+          @Override
+          public void start() {}
+
+          @Override
+          public void stop() {}
+
+          @Override
+          public void onPostEvent(Event event) {
+            try {
+              Thread.sleep(200);
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              return;
+            }
+            if (!entityStoreClosed.get()) {
+              eventsSeenWithOpenStore.incrementAndGet();
+            }
+          }
+
+          @Override
+          public Mode mode() {
+            return Mode.ASYNC_ISOLATED;
+          }
+        });
+    eventListenerManager.start();
+    eventListenerManager.createEventBus().dispatchEvent(mock(Event.class));
+
+    TestGravitinoEnv env = new TestGravitinoEnv();
+    FieldUtils.writeField(env, "entityStore", entityStore, true);
+    FieldUtils.writeField(env, "auxServiceManager", auxServiceManager, true);
+    FieldUtils.writeField(env, "eventListenerManager", eventListenerManager, 
true);
+
+    env.shutdown();
+
+    Assertions.assertEquals(1, eventsSeenWithOpenStore.get());
+    InOrder order = inOrder(auxServiceManager, entityStore);
+    order.verify(auxServiceManager).serviceStop();
+    order.verify(entityStore).close();
+  }
+
+  private static final class TestGravitinoEnv extends GravitinoEnv {}
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/audit/DummyAuditWriter.java 
b/core/src/test/java/org/apache/gravitino/audit/DummyAuditWriter.java
index a6ca2d6276..7608efd1eb 100644
--- a/core/src/test/java/org/apache/gravitino/audit/DummyAuditWriter.java
+++ b/core/src/test/java/org/apache/gravitino/audit/DummyAuditWriter.java
@@ -19,14 +19,17 @@
 
 package org.apache.gravitino.audit;
 
-import java.util.LinkedList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import lombok.Getter;
 
 public class DummyAuditWriter implements AuditLogWriter {
   private Formatter formatter;
 
-  @Getter private final LinkedList<DummyAuditLog> auditLogs = new 
LinkedList<>();
+  @Getter
+  private final List<DummyAuditLog> auditLogs = 
Collections.synchronizedList(new ArrayList<>());
 
   @Override
   public Formatter getFormatter() {
diff --git 
a/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java 
b/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
index 21ec1ca1c6..6bdfc8456c 100644
--- a/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
+++ b/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
@@ -22,7 +22,10 @@ package org.apache.gravitino.audit;
 import static org.apache.gravitino.audit.AuditLog.Operation;
 import static org.apache.gravitino.audit.AuditLog.Status;
 
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.NameIdentifier;
@@ -31,6 +34,8 @@ import org.apache.gravitino.listener.EventBus;
 import org.apache.gravitino.listener.EventListenerManager;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.listener.api.event.FailureEvent;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
@@ -40,6 +45,18 @@ public class TestAuditManager {
 
   private static final int EVENT_NUM = 2000;
 
+  private final List<EventListenerManager> eventListenerManagers = new 
ArrayList<>();
+
+  /**
+   * Stops the async audit log dispatchers, so they do not keep writing to the 
shared audit logger
+   * after the test ends.
+   */
+  @AfterEach
+  public void tearDown() {
+    eventListenerManagers.forEach(EventListenerManager::stop);
+    eventListenerManagers.clear();
+  }
+
   /** Test audit log with custom audit writer and formatter. */
   @Test
   public void testAuditLog() {
@@ -62,6 +79,8 @@ public class TestAuditManager {
         DummyAuditFormatter.class, 
(auditLogManager.getAuditLogWriter()).getFormatter());
 
     DummyAuditWriter dummyAuditWriter = (DummyAuditWriter) 
auditLogManager.getAuditLogWriter();
+    // The audit log listener is async, so wait for the dispatcher thread to 
write the log.
+    awaitAuditLogs(dummyAuditWriter, 1);
 
     DummyAuditFormatter formatter = (DummyAuditFormatter) 
dummyAuditWriter.getFormatter();
     DummyAuditLog formattedAuditLog = formatter.format(dummyEvent);
@@ -77,6 +96,7 @@ public class TestAuditManager {
     // dispatch fail event
     DummyFailEvent dummyFailEvent = mockDummyFailEvent();
     eventBus.dispatchEvent(dummyFailEvent);
+    awaitAuditLogs(dummyAuditWriter, 2);
     DummyAuditLog formattedFailAuditLog = formatter.format(dummyFailEvent);
     Assertions.assertEquals(formattedFailAuditLog, 
dummyAuditWriter.getAuditLogs().get(1));
     Assertions.assertEquals(Operation.UNKNOWN_OPERATION, 
formattedFailAuditLog.operation());
@@ -116,6 +136,13 @@ public class TestAuditManager {
     // No assertion needed — we just verify no exception is thrown for a large 
batch.
   }
 
+  private void awaitAuditLogs(DummyAuditWriter writer, int expectedSize) {
+    Awaitility.await()
+        .atMost(20, TimeUnit.SECONDS)
+        .pollInterval(10, TimeUnit.MILLISECONDS)
+        .until(() -> writer.getAuditLogs().size() >= expectedSize);
+  }
+
   private AuditLogManager mockAuditLogManager(
       Config config, EventListenerManager eventListenerManager) {
     AuditLogManager auditLogManager = new AuditLogManager();
@@ -127,6 +154,7 @@ public class TestAuditManager {
     EventListenerManager eventListenerManager = new EventListenerManager();
     eventListenerManager.init(new HashMap<>());
     eventListenerManager.start();
+    eventListenerManagers.add(eventListenerManager);
     return eventListenerManager;
   }
 
diff --git 
a/core/src/test/java/org/apache/gravitino/job/TestBuiltInJobTemplateEventListener.java
 
b/core/src/test/java/org/apache/gravitino/job/TestBuiltInJobTemplateEventListener.java
index c20cfcb8ea..1f2b97bbb8 100644
--- 
a/core/src/test/java/org/apache/gravitino/job/TestBuiltInJobTemplateEventListener.java
+++ 
b/core/src/test/java/org/apache/gravitino/job/TestBuiltInJobTemplateEventListener.java
@@ -34,6 +34,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.MoreExecutors;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
@@ -41,6 +42,11 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Config;
@@ -50,6 +56,7 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
 import org.apache.gravitino.listener.api.event.CreateMetalakeEvent;
+import org.apache.gravitino.listener.api.event.EnableMetalakeEvent;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.listener.api.info.MetalakeInfo;
 import org.apache.gravitino.lock.LockManager;
@@ -60,11 +67,13 @@ import org.apache.gravitino.meta.SchemaVersion;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.memory.TestMemoryEntityStore;
+import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
@@ -91,7 +100,10 @@ public class TestBuiltInJobTemplateEventListener {
     idGenerator = new RandomIdGenerator();
 
     jobManager = mock(JobManager.class);
-    listener = new BuiltInJobTemplateEventListener(jobManager, entityStore, 
idGenerator);
+    // Register on the test thread, since the ServiceLoader static mocks are 
thread local.
+    listener =
+        new BuiltInJobTemplateEventListener(
+            jobManager, entityStore, idGenerator, 
MoreExecutors.newDirectExecutorService());
   }
 
   @AfterEach
@@ -102,8 +114,9 @@ public class TestBuiltInJobTemplateEventListener {
   }
 
   @Test
-  public void testOnPostCreateMetalakeEvent() {
+  public void testOnPostCreateMetalakeEvent() throws IOException {
     String metalakeName = "test_metalake";
+    createMetalake(metalakeName, true);
     NameIdentifier identifier = NameIdentifier.of(metalakeName);
     MetalakeInfo metalakeInfo = new MetalakeInfo(metalakeName, "comment", 
null, null);
 
@@ -143,11 +156,115 @@ public class TestBuiltInJobTemplateEventListener {
 
       listener.onPostEvent(event);
 
-      // Verify registerJobTemplate was called
-      verify(jobManager, times(1)).registerJobTemplate(eq(metalakeName), 
any());
+      // Verify registerJobTemplate was called, with the metalake creator as 
the template creator
+      ArgumentCaptor<JobTemplateEntity> captor = 
ArgumentCaptor.forClass(JobTemplateEntity.class);
+      verify(jobManager, times(1)).registerJobTemplate(eq(metalakeName), 
captor.capture());
+      Assertions.assertEquals("user", captor.getValue().auditInfo().creator());
     }
   }
 
+  @Test
+  public void testCreatedMetalakesAreNotDroppedWhileRegistrationIsBusy() 
throws Exception {
+    for (String metalake : Arrays.asList("metalake1", "metalake2", 
"metalake3")) {
+      createMetalake(metalake, true);
+    }
+    CountDownLatch firstLoadStarted = new CountDownLatch(1);
+    CountDownLatch releaseFirstLoad = new CountDownLatch(1);
+    BuiltInJobTemplateEventListener busyListener =
+        newBlockingListener(firstLoadStarted, releaseFirstLoad);
+    
when(jobManager.listJobTemplates(any())).thenReturn(Collections.emptyList());
+
+    // Block the first registration, then create more metalakes while it is 
busy.
+    busyListener.onPostEvent(createMetalakeEvent("metalake1"));
+    Assertions.assertTrue(firstLoadStarted.await(30, TimeUnit.SECONDS));
+    busyListener.onPostEvent(createMetalakeEvent("metalake2"));
+    busyListener.onPostEvent(createMetalakeEvent("metalake3"));
+    releaseFirstLoad.countDown();
+
+    // Stop finishes the pending registrations.
+    busyListener.stop();
+
+    for (String metalake : Arrays.asList("metalake1", "metalake2", 
"metalake3")) {
+      verify(jobManager, times(1)).registerJobTemplate(eq(metalake), any());
+    }
+  }
+
+  @Test
+  public void testRegistrationFollowsMetalakeRenamedWhileQueued() throws 
Exception {
+    long metalakeId = createMetalake("metalake_a", true).id();
+    CountDownLatch loadStarted = new CountDownLatch(1);
+    CountDownLatch releaseLoad = new CountDownLatch(1);
+    BuiltInJobTemplateEventListener busyListener = 
newBlockingListener(loadStarted, releaseLoad);
+    
when(jobManager.listJobTemplates(any())).thenReturn(Collections.emptyList());
+
+    busyListener.onPostEvent(createMetalakeEvent("metalake_a"));
+    Assertions.assertTrue(loadStarted.await(30, TimeUnit.SECONDS));
+    // Rename metalake_a to metalake_b before the registration resumes.
+    entityStore.delete(NameIdentifier.of("metalake_a"), 
Entity.EntityType.METALAKE);
+    putMetalake("metalake_b", metalakeId, true);
+    releaseLoad.countDown();
+    busyListener.stop();
+
+    verify(jobManager, times(1)).registerJobTemplate(eq("metalake_b"), any());
+    verify(jobManager, times(0)).registerJobTemplate(eq("metalake_a"), any());
+  }
+
+  @Test
+  public void testRegistrationSkipsMetalakeDroppedAndRecreatedWhileQueued() 
throws Exception {
+    createMetalake("metalake_a", true);
+    CountDownLatch loadStarted = new CountDownLatch(1);
+    CountDownLatch releaseLoad = new CountDownLatch(1);
+    BuiltInJobTemplateEventListener busyListener = 
newBlockingListener(loadStarted, releaseLoad);
+
+    busyListener.onPostEvent(createMetalakeEvent("metalake_a"));
+    Assertions.assertTrue(loadStarted.await(30, TimeUnit.SECONDS));
+    // Drop metalake_a and create another metalake with the same name before 
the registration
+    // resumes. The old event must not populate the new metalake.
+    entityStore.delete(NameIdentifier.of("metalake_a"), 
Entity.EntityType.METALAKE);
+    createMetalake("metalake_a", true);
+    releaseLoad.countDown();
+    busyListener.stop();
+
+    verify(jobManager, times(0)).listJobTemplates(any());
+    verify(jobManager, times(0)).registerJobTemplate(any(), any());
+  }
+
+  @Test
+  public void testRegistrationSkipsDisabledMetalakeAndRetriesOnEnable() throws 
Exception {
+    long metalakeId = createMetalake("metalake_a", true).id();
+    CountDownLatch loadStarted = new CountDownLatch(1);
+    CountDownLatch releaseLoad = new CountDownLatch(1);
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    BuiltInJobTemplateEventListener busyListener =
+        newBlockingListener(loadStarted, releaseLoad, executor);
+    
when(jobManager.listJobTemplates(any())).thenReturn(Collections.emptyList());
+
+    busyListener.onPostEvent(createMetalakeEvent("metalake_a"));
+    Assertions.assertTrue(loadStarted.await(30, TimeUnit.SECONDS));
+    // Disable metalake_a before the registration resumes, then wait for the 
registration to end.
+    replaceMetalake("metalake_a", metalakeId, false);
+    releaseLoad.countDown();
+    executor.submit(() -> {}).get(30, TimeUnit.SECONDS);
+    verify(jobManager, times(0)).registerJobTemplate(any(), any());
+
+    // Enabling metalake_a registers its templates.
+    replaceMetalake("metalake_a", metalakeId, true);
+    busyListener.onPostEvent(
+        new EnableMetalakeEvent("user", 
NameIdentifierUtil.ofMetalake("metalake_a")));
+    busyListener.stop();
+
+    verify(jobManager, times(1)).registerJobTemplate(eq("metalake_a"), any());
+  }
+
+  @Test
+  public void testOnPostEventAfterStop() {
+    listener.stop();
+
+    // A metalake created after stop is skipped; start() registers its 
templates on next startup.
+    Assertions.assertDoesNotThrow(() -> 
listener.onPostEvent(createMetalakeEvent("metalake1")));
+    verify(jobManager, times(0)).listJobTemplates(any());
+  }
+
   @Test
   public void testOnPostEventWithNonMetalakeEvent() {
     Event otherEvent = mock(Event.class);
@@ -566,13 +683,22 @@ public class TestBuiltInJobTemplateEventListener {
     Assertions.assertEquals("builtin-existing", updated.name());
   }
 
-  private void createMetalake(String name, boolean inUse) throws IOException {
+  private BaseMetalake createMetalake(String name, boolean inUse) throws 
IOException {
+    return putMetalake(name, idGenerator.nextId(), inUse);
+  }
+
+  private void replaceMetalake(String name, long id, boolean inUse) throws 
IOException {
+    entityStore.delete(NameIdentifier.of(name), Entity.EntityType.METALAKE);
+    putMetalake(name, id, inUse);
+  }
+
+  private BaseMetalake putMetalake(String name, long id, boolean inUse) throws 
IOException {
     Map<String, String> props = new HashMap<>();
     props.put(PROPERTY_IN_USE, String.valueOf(inUse));
 
     BaseMetalake metalake =
         BaseMetalake.builder()
-            .withId(idGenerator.nextId())
+            .withId(id)
             .withName(name)
             .withComment("test metalake")
             .withProperties(props)
@@ -582,6 +708,7 @@ public class TestBuiltInJobTemplateEventListener {
             .build();
 
     entityStore.put(metalake, false);
+    return metalake;
   }
 
   private JobTemplateEntity createJobTemplateEntity(String name, String 
version) {
@@ -607,4 +734,49 @@ public class TestBuiltInJobTemplateEventListener {
             
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
         .build();
   }
+
+  /**
+   * Creates a listener with a real registration thread, whose first template 
load blocks until
+   * {@code releaseFirstLoad} is counted down.
+   */
+  private BuiltInJobTemplateEventListener newBlockingListener(
+      CountDownLatch firstLoadStarted, CountDownLatch releaseFirstLoad) {
+    return newBlockingListener(
+        firstLoadStarted, releaseFirstLoad, 
Executors.newSingleThreadExecutor());
+  }
+
+  private BuiltInJobTemplateEventListener newBlockingListener(
+      CountDownLatch firstLoadStarted,
+      CountDownLatch releaseFirstLoad,
+      ExecutorService registrationExecutor) {
+    ShellJobTemplate template =
+        ShellJobTemplate.builder()
+            .withName("builtin-test")
+            .withExecutable("/bin/echo")
+            .withCustomFields(Collections.singletonMap("version", "v1"))
+            .build();
+    AtomicInteger loads = new AtomicInteger();
+    return new BuiltInJobTemplateEventListener(
+        jobManager, entityStore, idGenerator, registrationExecutor) {
+      @Override
+      Map<String, JobTemplate> loadBuiltInJobTemplates() {
+        if (loads.incrementAndGet() == 1) {
+          firstLoadStarted.countDown();
+          try {
+            releaseFirstLoad.await();
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+          }
+        }
+        return Collections.singletonMap(template.name(), template);
+      }
+    };
+  }
+
+  private CreateMetalakeEvent createMetalakeEvent(String metalakeName) {
+    return new CreateMetalakeEvent(
+        "user",
+        NameIdentifier.of(metalakeName),
+        new MetalakeInfo(metalakeName, "comment", null, null));
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/TestAsyncQueueListener.java 
b/core/src/test/java/org/apache/gravitino/listener/TestAsyncQueueListener.java
index a5d578a7c0..b947b9244d 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/TestAsyncQueueListener.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/TestAsyncQueueListener.java
@@ -20,16 +20,21 @@
 package org.apache.gravitino.listener;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.LogManager;
@@ -100,6 +105,121 @@ class TestAsyncQueueListener {
     }
   }
 
+  @Test
+  void testStopIsIdempotent() {
+    // Do not block the WARN log the dispatcher thread writes when it is 
interrupted.
+    blockingAppender.releaseFirstLog.countDown();
+    EventListenerPlugin innerListener = mock(EventListenerPlugin.class);
+    AsyncQueueListener listener = new 
AsyncQueueListener(List.of(innerListener), "test", 1, 1);
+    listener.start();
+
+    listener.stop();
+    listener.stop();
+
+    verify(innerListener, times(1)).stop();
+  }
+
+  @Test
+  void testStopWhenCallerIsInterrupted() {
+    blockingAppender.releaseFirstLog.countDown();
+    EventListenerPlugin innerListener = mock(EventListenerPlugin.class);
+    AsyncQueueListener listener =
+        new AsyncQueueListener(List.of(innerListener), "interrupted-caller", 
10, 1);
+    listener.start();
+
+    Thread.currentThread().interrupt();
+    listener.stop();
+
+    // The caller keeps its interrupt status, and the dispatcher thread is 
still stopped.
+    assertTrue(Thread.interrupted());
+    assertFalse(
+        Thread.getAllStackTraces().keySet().stream()
+            .anyMatch(
+                thread ->
+                    
thread.getName().equals("async-queue-listener-interrupted-caller")
+                        && thread.isAlive()));
+    verify(innerListener, times(1)).stop();
+  }
+
+  @Test
+  void testStopDrainsQueuedEvents() {
+    blockingAppender.releaseFirstLog.countDown();
+    AtomicInteger processedEvents = new AtomicInteger();
+    EventListenerPlugin slowListener =
+        new EventListenerPlugin() {
+          @Override
+          public void init(Map<String, String> properties) {}
+
+          @Override
+          public void start() {}
+
+          @Override
+          public void stop() {}
+
+          @Override
+          public void onPostEvent(Event event) {
+            try {
+              Thread.sleep(50);
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              return;
+            }
+            processedEvents.incrementAndGet();
+          }
+        };
+    AsyncQueueListener listener = new 
AsyncQueueListener(List.of(slowListener), "test", 10, 10);
+    listener.start();
+
+    Event event = mock(Event.class);
+    for (int i = 0; i < 5; i++) {
+      listener.onPostEvent(event);
+    }
+    listener.stop();
+
+    assertEquals(5, processedEvents.get());
+  }
+
+  @Test
+  void testStopInterruptsDispatcherThatDoesNotFinishInTime() throws Exception {
+    blockingAppender.releaseFirstLog.countDown();
+    CountDownLatch neverReleased = new CountDownLatch(1);
+    CountDownLatch entered = new CountDownLatch(1);
+    EventListenerPlugin blockedListener =
+        new EventListenerPlugin() {
+          @Override
+          public void init(Map<String, String> properties) {}
+
+          @Override
+          public void start() {}
+
+          @Override
+          public void stop() {}
+
+          @Override
+          public void onPostEvent(Event event) {
+            entered.countDown();
+            try {
+              neverReleased.await();
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+            }
+          }
+        };
+    AsyncQueueListener listener = new 
AsyncQueueListener(List.of(blockedListener), "test", 10, 1);
+    listener.start();
+    listener.onPostEvent(mock(Event.class));
+    assertTrue(entered.await(30, TimeUnit.SECONDS));
+
+    long startNanos = System.nanoTime();
+    listener.stop();
+
+    // Stop waits at most twice dispatcherJoinSeconds: once to drain, once 
after the interrupt.
+    // The dispatcher exits right after the interrupt, so stop() takes a 
little over 1 second.
+    long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startNanos);
+    assertTrue(elapsedMillis >= 900, "stop() returned before the drain 
timeout: " + elapsedMillis);
+    assertTrue(elapsedMillis < 5000, "stop() took too long: " + elapsedMillis);
+  }
+
   private static class BlockingAppender extends AbstractAppender {
     private final AtomicInteger logCount = new AtomicInteger();
     private final CountDownLatch firstLogEntered = new CountDownLatch(1);
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/TestEventListenerManager.java
 
b/core/src/test/java/org/apache/gravitino/listener/TestEventListenerManager.java
index 30faada6be..280860a9f1 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/TestEventListenerManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/TestEventListenerManager.java
@@ -19,22 +19,30 @@
 
 package org.apache.gravitino.listener;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import 
org.apache.gravitino.listener.DummyEventListener.DummyAsyncEventListener;
 import 
org.apache.gravitino.listener.DummyEventListener.DummyAsyncIsolatedEventListener;
 import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.EventListenerPlugin.Mode;
 import org.apache.gravitino.listener.api.event.BaseEvent;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.listener.api.event.OperationStatus;
 import org.apache.gravitino.listener.api.event.PreEvent;
+import org.awaitility.Awaitility;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -59,6 +67,60 @@ public class TestEventListenerManager {
     }
   }
 
+  /** Records the threads that deliver post events, optionally blocking until 
released. */
+  static class ThreadRecordingEventListener implements EventListenerPlugin {
+    private final Mode mode;
+    private final CountDownLatch release;
+    private final List<String> postEventThreads = new CopyOnWriteArrayList<>();
+    private final AtomicBoolean started = new AtomicBoolean(false);
+    private final AtomicInteger receivedEvents = new AtomicInteger(0);
+
+    ThreadRecordingEventListener(Mode mode) {
+      this(mode, new CountDownLatch(0));
+    }
+
+    ThreadRecordingEventListener(Mode mode, CountDownLatch release) {
+      this.mode = mode;
+      this.release = release;
+    }
+
+    @Override
+    public void init(Map<String, String> properties) {}
+
+    @Override
+    public void start() {
+      started.set(true);
+    }
+
+    @Override
+    public void stop() {}
+
+    @Override
+    public void onPostEvent(Event event) {
+      receivedEvents.incrementAndGet();
+      try {
+        release.await();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        return;
+      }
+      postEventThreads.add(Thread.currentThread().getName());
+    }
+
+    @Override
+    public Mode mode() {
+      return mode;
+    }
+
+    List<String> awaitPostEventThreads() {
+      Awaitility.await()
+          .atMost(20, TimeUnit.SECONDS)
+          .pollInterval(10, TimeUnit.MILLISECONDS)
+          .until(() -> !postEventThreads.isEmpty());
+      return postEventThreads;
+    }
+  }
+
   private static final DummyPostEvent DUMMY_POST_EVENT_INSTANCE =
       new DummyPostEvent("user", NameIdentifier.of("a", "b"));
 
@@ -270,6 +332,150 @@ public class TestEventListenerManager {
     Assertions.assertEquals(2, ((CountingPreEvent) 
transformedEvent.get()).count());
   }
 
+  @Test
+  void testAddEventListenerHonorsMode() {
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(new HashMap<>());
+
+    ThreadRecordingEventListener syncListener = new 
ThreadRecordingEventListener(Mode.SYNC);
+    ThreadRecordingEventListener isolatedListener =
+        new ThreadRecordingEventListener(Mode.ASYNC_ISOLATED);
+    ThreadRecordingEventListener sharedListener1 =
+        new ThreadRecordingEventListener(Mode.ASYNC_SHARED);
+    ThreadRecordingEventListener sharedListener2 =
+        new ThreadRecordingEventListener(Mode.ASYNC_SHARED);
+    eventListenerManager.addEventListener("sync", syncListener);
+    eventListenerManager.addEventListener("isolated", isolatedListener);
+    eventListenerManager.addEventListener("shared1", sharedListener1);
+    eventListenerManager.addEventListener("shared2", sharedListener2);
+    eventListenerManager.start();
+    EventBus eventBus = eventListenerManager.createEventBus();
+    try {
+      List<EventListenerPlugin> listeners = eventBus.getEventListeners();
+      Assertions.assertEquals(3, listeners.size());
+      Assertions.assertEquals(
+          1, 
listeners.stream().filter(EventListenerPluginWrapper.class::isInstance).count());
+      Set<Integer> queueSizes =
+          listeners.stream()
+              .filter(AsyncQueueListener.class::isInstance)
+              .map(listener -> ((AsyncQueueListener) 
listener).getEventListeners().size())
+              .collect(Collectors.toSet());
+      Assertions.assertEquals(ImmutableSet.of(1, 2), queueSizes);
+
+      eventBus.dispatchEvent(DUMMY_POST_EVENT_INSTANCE);
+
+      Assertions.assertEquals(
+          ImmutableList.of(Thread.currentThread().getName()), 
syncListener.awaitPostEventThreads());
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-isolated"),
+          isolatedListener.awaitPostEventThreads());
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-default"),
+          sharedListener1.awaitPostEventThreads());
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-default"),
+          sharedListener2.awaitPostEventThreads());
+    } finally {
+      eventListenerManager.stop();
+    }
+  }
+
+  @Test
+  void testAddEventListenerAfterStart() {
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(createAsyncEventListenerConfig("async1", 
"async2"));
+    eventListenerManager.start();
+    EventBus eventBus = eventListenerManager.createEventBus();
+
+    ThreadRecordingEventListener isolatedListener =
+        new ThreadRecordingEventListener(Mode.ASYNC_ISOLATED);
+    ThreadRecordingEventListener sharedListener =
+        new ThreadRecordingEventListener(Mode.ASYNC_SHARED);
+    try {
+      eventListenerManager.addEventListener("isolated", isolatedListener);
+      eventListenerManager.addEventListener("shared", sharedListener);
+      Assertions.assertTrue(isolatedListener.started.get());
+      Assertions.assertTrue(sharedListener.started.get());
+
+      // The shared listener joins the queue created for the configured shared 
listeners.
+      Assertions.assertEquals(2, eventBus.getEventListeners().size());
+
+      eventBus.dispatchEvent(DUMMY_POST_EVENT_INSTANCE);
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-isolated"),
+          isolatedListener.awaitPostEventThreads());
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-default"), 
sharedListener.awaitPostEventThreads());
+    } finally {
+      eventListenerManager.stop();
+    }
+  }
+
+  @Test
+  void testFirstSharedListenerAddedAfterStart() {
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(new HashMap<>());
+    eventListenerManager.start();
+    EventBus eventBus = eventListenerManager.createEventBus();
+
+    ThreadRecordingEventListener sharedListener =
+        new ThreadRecordingEventListener(Mode.ASYNC_SHARED);
+    try {
+      // No shared queue exists yet, so registering the listener creates and 
starts one.
+      eventListenerManager.addEventListener("shared", sharedListener);
+      Assertions.assertTrue(sharedListener.started.get());
+      Assertions.assertEquals(1, eventBus.getEventListeners().size());
+      Assertions.assertTrue(eventBus.getEventListeners().get(0) instanceof 
AsyncQueueListener);
+
+      eventBus.dispatchEvent(DUMMY_POST_EVENT_INSTANCE);
+      Assertions.assertEquals(
+          ImmutableList.of("async-queue-listener-default"), 
sharedListener.awaitPostEventThreads());
+    } finally {
+      eventListenerManager.stop();
+    }
+  }
+
+  @Test
+  void testInitTwiceFails() {
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(createSyncEventListenerConfig("sync1", "sync2"));
+
+    Assertions.assertThrows(
+        IllegalStateException.class,
+        () -> eventListenerManager.init(createSyncEventListenerConfig("sync1", 
"sync2")));
+  }
+
+  @Test
+  void testHighWatermarkIncludesListenerAddedAfterEventBusCreated() {
+    Map<String, String> properties = new HashMap<>();
+    
properties.put(EventListenerManager.GRAVITINO_EVENT_LISTENER_QUEUE_CAPACITY, 
"10");
+    EventListenerManager eventListenerManager = new EventListenerManager();
+    eventListenerManager.init(properties);
+    eventListenerManager.start();
+    EventBus eventBus = eventListenerManager.createEventBus();
+
+    CountDownLatch release = new CountDownLatch(1);
+    ThreadRecordingEventListener blockedListener =
+        new ThreadRecordingEventListener(Mode.ASYNC_ISOLATED, release);
+    eventListenerManager.addEventListener("blocked", blockedListener);
+    Assertions.assertFalse(eventBus.isHighWatermark());
+
+    try {
+      // Block the dispatcher thread on the first event, then fill the queue.
+      eventBus.dispatchEvent(DUMMY_POST_EVENT_INSTANCE);
+      Awaitility.await()
+          .atMost(20, TimeUnit.SECONDS)
+          .until(() -> blockedListener.receivedEvents.get() == 1);
+      for (int i = 0; i < 10; i++) {
+        eventBus.dispatchEvent(DUMMY_POST_EVENT_INSTANCE);
+      }
+      Assertions.assertTrue(eventBus.isHighWatermark());
+    } finally {
+      release.countDown();
+      eventListenerManager.stop();
+    }
+  }
+
   private Map<String, String> createIsolatedAsyncEventListenerConfig(String 
async1, String async2) {
     Map<String, String> config = new HashMap<>();
 

Reply via email to