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 {