diff --git a/dubbo-config/dubbo-config-spring/src/main/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListener.java b/dubbo-config/dubbo-config-spring/src/main/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListener.java
index a52246bf9088..e03eafc4d17b 100644
--- a/dubbo-config/dubbo-config-spring/src/main/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListener.java
+++ b/dubbo-config/dubbo-config-spring/src/main/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListener.java
@@ -32,39 +32,68 @@
import org.apache.dubbo.rpc.model.ModuleModel;
import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.jspecify.annotations.NonNull;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
-import org.springframework.context.ApplicationListener;
-import org.springframework.context.event.ApplicationContextEvent;
-import org.springframework.context.event.ContextClosedEvent;
-import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.context.SmartLifecycle;
import org.springframework.core.Ordered;
import static org.apache.dubbo.common.constants.LoggerCodeConstants.CONFIG_FAILED_START_MODEL;
import static org.apache.dubbo.common.constants.LoggerCodeConstants.CONFIG_STOP_DUBBO_ERROR;
-import static org.springframework.util.ObjectUtils.nullSafeEquals;
/**
- * An ApplicationListener to control Dubbo application.
+ * Integrates Dubbo lifecycle management with Spring.
+ *
+ *
Uses {@link SmartLifecycle} to ensure Dubbo starts automatically with the
+ * Spring context and shuts down last to support graceful shutdown.
+ * The legacy name {@code DubboDeployApplicationListener} is retained for
+ * backward compatibility.
*/
-public class DubboDeployApplicationListener
- implements ApplicationListener, ApplicationContextAware, Ordered {
+public class DubboDeployApplicationListener implements SmartLifecycle, ApplicationContextAware, Ordered {
private static final ErrorTypeAwareLogger logger =
LoggerFactory.getErrorTypeAwareLogger(DubboDeployApplicationListener.class);
+ private static final String DUBBO_SHUTDOWN_PHASE_KEY = "dubbo.spring.shutdown.phase";
+
private ApplicationContext applicationContext;
private ApplicationModel applicationModel;
private ModuleModel moduleModel;
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private volatile int shutdownPhase = Integer.MIN_VALUE + 2000;
+
@Override
- public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
this.applicationModel = DubboBeanUtils.getApplicationModel(applicationContext);
this.moduleModel = DubboBeanUtils.getModuleModel(applicationContext);
+
+ String configuredValue = null;
+ try {
+ // Parse the user-configured shutdown phase.
+ // Spring stops SmartLifecycle beans in descending phase order.
+ // To ensure Dubbo shuts down LAST, we use a very LOW phase value by default.
+ configuredValue = ConfigurationUtils.getProperty(moduleModel, DUBBO_SHUTDOWN_PHASE_KEY);
+ if (configuredValue != null) {
+ int parsed = Integer.parseInt(configuredValue.trim());
+ shutdownPhase = Math.max(Integer.MIN_VALUE + 1, parsed);
+ }
+ } catch (NumberFormatException nfe) {
+ String msg = "Invalid integer value for property: " + DUBBO_SHUTDOWN_PHASE_KEY
+ + " = '" + configuredValue + "'. "
+ + "Expected an integer between " + (Integer.MIN_VALUE + 1) + " and " + Integer.MAX_VALUE + ".";
+ logger.warn(CONFIG_FAILED_START_MODEL, "", "", msg, nfe);
+ } catch (Exception e) {
+ String msg = "Failed to read property: " + DUBBO_SHUTDOWN_PHASE_KEY + ". Using default shutdown phase = "
+ + shutdownPhase + ".";
+ logger.warn(CONFIG_FAILED_START_MODEL, "", "", msg, e);
+ }
+
// listen deploy events and publish DubboApplicationStateEvent
applicationModel.getDeployer().addDeployListener(new DeployListenerAdapter() {
@Override
@@ -94,7 +123,7 @@ public void onStopped(ApplicationModel scopeModel) {
@Override
public void onFailure(ApplicationModel scopeModel, Throwable cause) {
- publishApplicationEvent(DeployState.FAILED, cause);
+ publishApplicationEvent(cause);
}
});
moduleModel.getDeployer().addDeployListener(new DeployListenerAdapter() {
@@ -125,7 +154,7 @@ public void onStopped(ModuleModel scopeModel) {
@Override
public void onFailure(ModuleModel scopeModel, Throwable cause) {
- publishModuleEvent(DeployState.FAILED, cause);
+ publishModuleEvent(cause);
}
});
}
@@ -134,80 +163,130 @@ private void publishApplicationEvent(DeployState state) {
applicationContext.publishEvent(new DubboApplicationStateEvent(applicationModel, state));
}
- private void publishApplicationEvent(DeployState state, Throwable cause) {
- applicationContext.publishEvent(new DubboApplicationStateEvent(applicationModel, state, cause));
+ private void publishApplicationEvent(Throwable cause) {
+ applicationContext.publishEvent(new DubboApplicationStateEvent(applicationModel, DeployState.FAILED, cause));
}
private void publishModuleEvent(DeployState state) {
applicationContext.publishEvent(new DubboModuleStateEvent(moduleModel, state));
}
- private void publishModuleEvent(DeployState state, Throwable cause) {
- applicationContext.publishEvent(new DubboModuleStateEvent(moduleModel, state, cause));
+ private void publishModuleEvent(Throwable cause) {
+ applicationContext.publishEvent(new DubboModuleStateEvent(moduleModel, DeployState.FAILED, cause));
+ }
+
+ @Override
+ public boolean isAutoStartup() {
+ return true;
}
@Override
- public void onApplicationEvent(ApplicationContextEvent event) {
- if (nullSafeEquals(applicationContext, event.getSource())) {
- if (event instanceof ContextRefreshedEvent) {
- onContextRefreshedEvent((ContextRefreshedEvent) event);
- } else if (event instanceof ContextClosedEvent) {
- onContextClosedEvent((ContextClosedEvent) event);
+ public void start() {
+ // Atomic check to ensure start logic runs only once.
+ if (running.compareAndSet(false, true)) {
+ ModuleDeployer deployer = moduleModel.getDeployer();
+ Assert.notNull(deployer, "Module deployer is null");
+ Object singletonMutex = LockUtils.getSingletonMutex(applicationContext);
+
+ Future> future;
+ synchronized (singletonMutex) {
+ // Start the Dubbo module via the deployer.
+ future = deployer.start();
+ }
+
+ // If not running in background, wait for the startup to finish.
+ if (!deployer.isBackground()) {
+ try {
+ future.get();
+ } catch (InterruptedException e) {
+ // Preserve interrupt status
+ Thread.currentThread().interrupt();
+ logger.warn(
+ CONFIG_FAILED_START_MODEL,
+ "",
+ "",
+ "Interrupted while waiting for dubbo module start: " + e.getMessage());
+ running.set(false);
+ } catch (Exception e) {
+ logger.warn(CONFIG_FAILED_START_MODEL, "", "", "Error starting dubbo module: " + e.getMessage(), e);
+ // If start fails, reset the running state to allow proper shutdown
+ running.set(false);
+ }
}
}
}
- private void onContextRefreshedEvent(ContextRefreshedEvent event) {
- ModuleDeployer deployer = moduleModel.getDeployer();
- Assert.notNull(deployer, "Module deployer is null");
- Object singletonMutex = LockUtils.getSingletonMutex(applicationContext);
- // start module
- Future future = null;
- synchronized (singletonMutex) {
- future = deployer.start();
- }
+ @Override
+ public void stop() {
+ stopInternal();
+ }
- // if the module does not start in background, await finish
- if (!deployer.isBackground()) {
+ @Override
+ public void stop(@NonNull Runnable callback) {
+ try {
+ stopInternal();
+ } finally {
try {
- future.get();
- } catch (InterruptedException e) {
+ callback.run();
+ } catch (Throwable t) {
logger.warn(
- CONFIG_FAILED_START_MODEL,
- "",
- "",
- "Interrupted while waiting for dubbo module start: " + e.getMessage());
- } catch (Exception e) {
- logger.warn(
- CONFIG_FAILED_START_MODEL,
- "",
- "",
- "An error occurred while waiting for dubbo module start: " + e.getMessage(),
- e);
+ CONFIG_STOP_DUBBO_ERROR, "", "", "Exception while executing SmartLifecycle stop callback", t);
}
}
}
- private void onContextClosedEvent(ContextClosedEvent event) {
- try {
- Object value = moduleModel.getAttribute(ModelConstants.KEEP_RUNNING_ON_SPRING_CLOSED);
- if (value == null) {
- value = ConfigurationUtils.getProperty(moduleModel, ModelConstants.KEEP_RUNNING_ON_SPRING_CLOSED_KEY);
+ private void stopInternal() {
+ // Ensure shutdown logic is executed only once.
+ boolean changed = running.compareAndSet(true, false);
+ if (changed) {
+ logger.info("Stopping Dubbo module (SmartLifecycle) — phase={}", shutdownPhase);
+ try {
+ // Determine whether Dubbo should remain running after Spring context shutdown
+ Object value = moduleModel.getAttribute(ModelConstants.KEEP_RUNNING_ON_SPRING_CLOSED);
+ if (value == null) {
+ value = ConfigurationUtils.getProperty(
+ moduleModel, ModelConstants.KEEP_RUNNING_ON_SPRING_CLOSED_KEY);
+ }
+ boolean keepRunningOnClosed = Boolean.parseBoolean(String.valueOf(value));
+
+ // Destroy the module only if not explicitly configured to keep running.
+ if (!keepRunningOnClosed && !moduleModel.isDestroyed()) {
+ moduleModel.destroy();
+ } else {
+ logger.info("KEEP_RUNNING_ON_SPRING_CLOSED is true — skipping module destroy");
+ }
+ } catch (Throwable e) {
+ logger.error(CONFIG_STOP_DUBBO_ERROR, "", "", "Error stopping dubbo module: " + e.getMessage(), e);
+ } finally {
+ try {
+ DubboSpringInitializer.remove(applicationContext);
+ } catch (Throwable t) {
+ logger.warn(CONFIG_STOP_DUBBO_ERROR, "", "", "Failed to remove DubboSpringInitializer binding", t);
+ }
}
- boolean keepRunningOnClosed = Boolean.parseBoolean(String.valueOf(value));
- if (!keepRunningOnClosed && !moduleModel.isDestroyed()) {
- moduleModel.destroy();
+ } else {
+ // Even if already stopped, ensure cleanup happens to be safe.
+ try {
+ DubboSpringInitializer.remove(applicationContext);
+ } catch (Throwable t) {
+ logger.warn(
+ CONFIG_STOP_DUBBO_ERROR,
+ "",
+ "",
+ "Failed to remove DubboSpringInitializer binding on repeated stop",
+ t);
}
- } catch (Exception e) {
- logger.error(
- CONFIG_STOP_DUBBO_ERROR,
- "",
- "",
- "Unexpected error occurred when stop dubbo module: " + e.getMessage(),
- e);
}
- // remove context bind cache
- DubboSpringInitializer.remove(event.getApplicationContext());
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running.get();
+ }
+
+ @Override
+ public int getPhase() {
+ return shutdownPhase;
}
@Override
diff --git a/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListenerTest.java b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListenerTest.java
new file mode 100644
index 000000000000..c74b8263ccc7
--- /dev/null
+++ b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboDeployApplicationListenerTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.dubbo.config.spring.context;
+
+import org.apache.dubbo.common.deploy.ModuleDeployer;
+import org.apache.dubbo.config.bootstrap.DubboBootstrap;
+import org.apache.dubbo.config.spring.SysProps;
+import org.apache.dubbo.rpc.model.ModuleModel;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+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.springframework.context.support.ClassPathXmlApplicationContext;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DubboDeployApplicationListenerTest {
+
+ private static final String RESOURCE = "org/apache/dubbo/config/spring/demo-provider.xml";
+
+ @AfterEach
+ void tearDown() {
+ DubboBootstrap.getInstance().stop();
+ SysProps.clear();
+ System.clearProperty("dubbo.protocol.name");
+ System.clearProperty("dubbo.registry.address");
+ }
+
+ @Test
+ void testIntegrationLifecycle() {
+ try (ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(RESOURCE)) {
+ context.start();
+
+ DubboDeployApplicationListener listener = context.getBean(DubboDeployApplicationListener.class);
+ Assertions.assertTrue(listener.isRunning(), "Dubbo Lifecycle should be running after context start");
+
+ // Verify shutdown phase is configured low (to stop last)
+ Assertions.assertTrue(listener.getPhase() < 0, "Dubbo should use a low phase to stop last");
+
+ context.close();
+ Assertions.assertFalse(listener.isRunning(), "Dubbo Lifecycle should stop after context close");
+ }
+ }
+
+ @Test
+ void testStartMethodInterrupted() throws ExecutionException, InterruptedException {
+ // Expectations: Interrupt status is preserved
+ DubboDeployApplicationListener listener = new DubboDeployApplicationListener();
+ ModuleModel mockModuleModel = mock(ModuleModel.class);
+ ModuleDeployer mockDeployer = mock(ModuleDeployer.class);
+ Future mockFuture = mock(Future.class);
+
+ when(mockModuleModel.getDeployer()).thenReturn(mockDeployer);
+ when(mockDeployer.start()).thenReturn(mockFuture);
+ when(mockDeployer.isBackground()).thenReturn(false);
+ when(mockFuture.get()).thenThrow(new InterruptedException("Simulated Interrupt"));
+
+ ReflectionTestUtils.setField(listener, "moduleModel", mockModuleModel);
+ GenericApplicationContext context = new GenericApplicationContext();
+ context.refresh();
+
+ ReflectionTestUtils.setField(listener, "applicationContext", context);
+ ReflectionTestUtils.setField(listener, "running", new AtomicBoolean(false));
+
+ listener.start();
+
+ Assertions.assertFalse(listener.isRunning(), "Running state should reset on interrupt");
+ Assertions.assertTrue(Thread.currentThread().isInterrupted(), "Interrupt status should be preserved");
+ Thread.interrupted();
+ }
+
+ @Test
+ void testStartMethodException() throws ExecutionException, InterruptedException {
+ // Expectations: Exception does not escape start()
+ DubboDeployApplicationListener listener = new DubboDeployApplicationListener();
+ ModuleModel mockModuleModel = mock(ModuleModel.class);
+ ModuleDeployer mockDeployer = mock(ModuleDeployer.class);
+ Future mockFuture = mock(Future.class);
+
+ when(mockModuleModel.getDeployer()).thenReturn(mockDeployer);
+ when(mockDeployer.start()).thenReturn(mockFuture);
+ when(mockDeployer.isBackground()).thenReturn(false);
+ when(mockFuture.get()).thenThrow(new RuntimeException("Simulated Failure"));
+
+ ReflectionTestUtils.setField(listener, "moduleModel", mockModuleModel);
+ GenericApplicationContext context = new GenericApplicationContext();
+ context.refresh();
+
+ ReflectionTestUtils.setField(listener, "applicationContext", context);
+ ReflectionTestUtils.setField(listener, "running", new AtomicBoolean(false));
+
+ listener.start();
+
+ Assertions.assertFalse(listener.isRunning(), "Running state should reset on exception");
+ verify(mockDeployer, times(1)).start();
+ }
+
+ @BeforeEach
+ void setupDubboEnv() {
+ System.setProperty("dubbo.protocol.name", "dubbo");
+ System.setProperty("dubbo.registry.address", "N/A");
+ }
+}
diff --git a/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboShutdownPhaseTest.java b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboShutdownPhaseTest.java
new file mode 100644
index 000000000000..1c2b4e826cdf
--- /dev/null
+++ b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/DubboShutdownPhaseTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.dubbo.config.spring.context;
+
+import org.apache.dubbo.config.bootstrap.DubboBootstrap;
+import org.apache.dubbo.config.spring.SysProps;
+
+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.springframework.context.support.ClassPathXmlApplicationContext;
+
+class DubboShutdownPhaseTest {
+
+ private static final String PHASE_KEY = "dubbo.spring.shutdown.phase";
+ private static final int DEFAULT_PHASE = Integer.MIN_VALUE + 2000;
+
+ @AfterEach
+ void tearDown() {
+ DubboBootstrap.getInstance().stop();
+ SysProps.clear();
+ System.clearProperty(PHASE_KEY);
+ System.clearProperty("dubbo.protocol.name");
+ System.clearProperty("dubbo.registry.address");
+ }
+
+ @Test
+ void testDefaultPhase() {
+ try (ClassPathXmlApplicationContext context =
+ new ClassPathXmlApplicationContext("org/apache/dubbo/config/spring/demo-provider.xml")) {
+ context.start();
+ DubboDeployApplicationListener listener = context.getBean(DubboDeployApplicationListener.class);
+ Assertions.assertEquals(DEFAULT_PHASE, listener.getPhase());
+ }
+ }
+
+ @Test
+ void testValidConfiguredPhase() {
+ System.setProperty(PHASE_KEY, "100");
+
+ try (ClassPathXmlApplicationContext context =
+ new ClassPathXmlApplicationContext("org/apache/dubbo/config/spring/demo-provider.xml")) {
+ context.start();
+ DubboDeployApplicationListener listener = context.getBean(DubboDeployApplicationListener.class);
+ Assertions.assertEquals(100, listener.getPhase());
+ }
+ }
+
+ @Test
+ void testInvalidConfiguredPhase() {
+ System.setProperty(PHASE_KEY, "invalid-text");
+
+ try (ClassPathXmlApplicationContext context =
+ new ClassPathXmlApplicationContext("org/apache/dubbo/config/spring/demo-provider.xml")) {
+ context.start();
+ DubboDeployApplicationListener listener = context.getBean(DubboDeployApplicationListener.class);
+ Assertions.assertEquals(
+ DEFAULT_PHASE,
+ listener.getPhase(),
+ "Default shutdown phase should be used when no configuration is provided");
+ }
+ }
+
+ @Test
+ void testBoundaryValuePhase() {
+ System.setProperty(PHASE_KEY, String.valueOf(Integer.MIN_VALUE));
+
+ try (ClassPathXmlApplicationContext context =
+ new ClassPathXmlApplicationContext("org/apache/dubbo/config/spring/demo-provider.xml")) {
+ context.start();
+ DubboDeployApplicationListener listener = context.getBean(DubboDeployApplicationListener.class);
+ Assertions.assertEquals(Integer.MIN_VALUE + 1, listener.getPhase());
+ }
+ }
+
+ @BeforeEach
+ void setupDubboEnv() {
+ System.setProperty("dubbo.protocol.name", "dubbo");
+ System.setProperty("dubbo.registry.address", "N/A");
+ }
+}
diff --git a/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/KeepRunningOnSpringClosedTest.java b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/KeepRunningOnSpringClosedTest.java
index afcf4140e4b0..61bfac9a0000 100644
--- a/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/KeepRunningOnSpringClosedTest.java
+++ b/dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/context/KeepRunningOnSpringClosedTest.java
@@ -34,9 +34,7 @@ class KeepRunningOnSpringClosedTest {
void test() {
// set KeepRunningOnSpringClosed flag for next spring context
- DubboSpringInitCustomizerHolder.get().addCustomizer(context -> {
- context.setKeepRunningOnSpringClosed(true);
- });
+ DubboSpringInitCustomizerHolder.get().addCustomizer(context -> context.setKeepRunningOnSpringClosed(true));
ClassPathXmlApplicationContext providerContext = null;
try {