From 6ae113c18a6151972e8d462f2eba94fb6449f914 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Sat, 16 Dec 2023 21:57:16 -0800 Subject: [PATCH] Fix parallel startup of testcontainers Update `TestcontainersLifecycleBeanPostProcessor` so that containers can actually be started in parallel. Prior to this commit, `initializeStartables` would collect beans and in the process trigger the `postProcessAfterInitialization` method on each bean. This would see that `startablesInitialized` was `true` and call `startableBean.start` directly. The result of this was that beans were actually started sequentially and when the `start` method was finally called it had nothing to do. The updated code uses an enum rather than a boolean so that the `postProcessAfterInitialization` method no longer attempts to start beans unless `initializeStartables` has finished. Fixes gh-38831 --- ...tcontainersLifecycleBeanPostProcessor.java | 28 +++++-- ...ainersParallelStartupIntegrationTests.java | 75 +++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java index edafed5c7d0..3ec39b713a5 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java @@ -21,6 +21,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import org.apache.commons.logging.Log; @@ -63,7 +64,7 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo private final TestcontainersStartup startup; - private final AtomicBoolean startablesInitialized = new AtomicBoolean(); + private final AtomicReference startables = new AtomicReference<>(Startables.UNSTARTED); private final AtomicBoolean containersInitialized = new AtomicBoolean(); @@ -79,10 +80,11 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo initializeContainers(); } if (bean instanceof Startable startableBean) { - if (this.startablesInitialized.compareAndSet(false, true)) { + if (this.startables.compareAndExchange(Startables.UNSTARTED, Startables.STARTING) == Startables.UNSTARTED) { initializeStartables(startableBean, beanName); } - else { + else if (this.startables.get() == Startables.STARTED) { + logger.trace(LogMessage.format("Starting container %s", beanName)); startableBean.start(); } } @@ -90,17 +92,21 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo } private void initializeStartables(Startable startableBean, String startableBeanName) { + logger.trace(LogMessage.format("Initializing startables")); List beanNames = new ArrayList<>( List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false))); beanNames.remove(startableBeanName); List beans = getBeans(beanNames); if (beans == null) { - this.startablesInitialized.set(false); + logger.trace(LogMessage.format("Failed to obtain startables %s", beanNames)); + this.startables.set(Startables.UNSTARTED); return; } beanNames.add(startableBeanName); beans.add(startableBean); + logger.trace(LogMessage.format("Starting startables %s", beanNames)); start(beans); + this.startables.set(Startables.STARTED); if (!beanNames.isEmpty()) { logger.debug(LogMessage.format("Initialized and started startable beans '%s'", beanNames)); } @@ -115,8 +121,14 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo } private void initializeContainers() { + logger.trace("Initializing containers"); List beanNames = List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false)); - if (getBeans(beanNames) == null) { + List beans = getBeans(beanNames); + if (beans != null) { + logger.trace(LogMessage.format("Initialized containers %s", beanNames)); + } + else { + logger.trace(LogMessage.format("Failed to initialize containers %s", beanNames)); this.containersInitialized.set(false); } } @@ -164,4 +176,10 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo return (bean instanceof GenericContainer container) && container.isShouldBeReused(); } + enum Startables { + + UNSTARTED, STARTING, STARTED + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java new file mode 100644 index 00000000000..3b6ed3da25b --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.testcontainers.lifecycle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.lifecycle.TestContainersParallelStartupIntegrationTests.ContainerConfig; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for parallel startup. + * + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ContainerConfig.class) +@TestPropertySource(properties = "spring.testcontainers.beans.startup=parallel") +@DirtiesContext +@DisabledIfDockerUnavailable +@ExtendWith(OutputCaptureExtension.class) +public class TestContainersParallelStartupIntegrationTests { + + @Test + void startsInParallel(CapturedOutput out) { + assertThat(out).contains("-lifecycle-0").contains("-lifecycle-1").contains("-lifecycle-2"); + } + + @Configuration(proxyBeanMethods = false) + static class ContainerConfig { + + @Bean + static PostgreSQLContainer container1() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + @Bean + static PostgreSQLContainer container2() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + @Bean + static PostgreSQLContainer container3() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + } + +}