Skip to content

Commit

Permalink
Implement dependsOn for cross-container dependencies (#1404)
Browse files Browse the repository at this point in the history
* Implement `dependsOn` for cross-container dependencies

* Add transitive test case

* fix the compilation on JDK 11

* avoid a recursive update in `computeIfAbsent`

* javadocs, add a test case for the diamond graph
  • Loading branch information
bsideup committed Jul 23, 2019
1 parent 4df20c2 commit 1d686a1
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 1 deletion.
Expand Up @@ -44,6 +44,7 @@
import org.testcontainers.images.RemoteDockerImage;
import org.testcontainers.images.builder.Transferable;
import org.testcontainers.lifecycle.Startable;
import org.testcontainers.lifecycle.Startables;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.utility.Base58;
Expand Down Expand Up @@ -74,6 +75,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
Expand All @@ -88,7 +90,6 @@
* Base class for that allows a container to be launched and controlled.
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class GenericContainer<SELF extends GenericContainer<SELF>>
extends FailureDetectingExternalResource
implements Container<SELF>, AutoCloseable, WaitStrategyTarget, Startable {
Expand Down Expand Up @@ -165,6 +166,8 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>

private Map<MountableFile, String> copyToFileContainerPathMap = new HashMap<>();

protected final Set<Startable> dependencies = new HashSet<>();

/*
* Unique instance of DockerClient for use by this container object.
*/
Expand Down Expand Up @@ -232,6 +235,26 @@ public GenericContainer(@NonNull final Future<String> image) {
this.image = image;
}

/**
* @see #dependsOn(List)
*/
public SELF dependsOn(Startable... startables) {
Collections.addAll(dependencies, startables);
return self();
}

/**
* Delays this container's creation and start until provided {@link Startable}s start first.
* Note that the circular dependencies are not supported.
*
* @param startables a list of {@link Startable} to depend on
* @see Startables#deepStart(Collection)
*/
public SELF dependsOn(List<Startable> startables) {
dependencies.addAll(startables);
return self();
}

public String getContainerId() {
return containerId;
}
Expand All @@ -240,10 +263,12 @@ public String getContainerId() {
* Starts the container using docker, pulling an image if necessary.
*/
@Override
@SneakyThrows({InterruptedException.class, ExecutionException.class})
public void start() {
if (containerId != null) {
return;
}
Startables.deepStart(dependencies).get();
doStart();
}

Expand Down Expand Up @@ -1221,6 +1246,16 @@ public SELF withTmpFs(Map<String, String> mapping) {
return self();
}

@Override
public boolean equals(Object o) {
return this == o;
}

@Override
public int hashCode() {
return System.identityHashCode(this);
}

/**
* Convenience class with access to non-public members of GenericContainer.
*
Expand Down
@@ -1,7 +1,14 @@
package org.testcontainers.lifecycle;

import java.util.Collections;
import java.util.Set;

public interface Startable extends AutoCloseable {

default Set<Startable> getDependencies() {
return Collections.emptySet();
}

void start();

void stop();
Expand Down
76 changes: 76 additions & 0 deletions core/src/main/java/org/testcontainers/lifecycle/Startables.java
@@ -0,0 +1,76 @@
package org.testcontainers.lifecycle;

import lombok.experimental.UtilityClass;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;

@UtilityClass
public class Startables {

private static final Executor EXECUTOR = Executors.newCachedThreadPool(new ThreadFactory() {

private final AtomicLong COUNTER = new AtomicLong(0);

@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, "testcontainers-lifecycle-" + COUNTER.getAndIncrement());
thread.setDaemon(true);
return thread;
}
});

/**
* @see #deepStart(Stream)
*/
public CompletableFuture<Void> deepStart(Collection<Startable> startables) {
return deepStart(startables.stream());
}

/**
* Start every {@link Startable} recursively and asynchronously and join on the result.
*
* Performance note:
* The method uses and returns {@link CompletableFuture}s to resolve as many {@link Startable}s at once as possible.
* This way, for the following graph:
* / b \
* a e
* c /
* d /
* "a", "c" and "d" will resolve in parallel, then "b".
*
* If we would call blocking {@link Startable#start()}, "e" would wait for "b", "b" for "a", and only then "c", and then "d".
* But, since "c" and "d" are independent from "a", there is no point in waiting for "a" to be resolved first.
*
* @param startables a {@link Stream} of {@link Startable}s to start and scan for transitive dependencies.
* @return a {@link CompletableFuture} that resolves once all {@link Startable}s have started.
*/
public CompletableFuture<Void> deepStart(Stream<Startable> startables) {
return deepStart(new HashMap<>(), startables);
}

/**
*
* @param started an intermediate storage for already started {@link Startable}s to prevent multiple starts.
* @param startables a {@link Stream} of {@link Startable}s to start and scan for transitive dependencies.
*/
private CompletableFuture<Void> deepStart(Map<Startable, CompletableFuture<Void>> started, Stream<Startable> startables) {
CompletableFuture[] futures = startables
.map(it -> {
// avoid a recursive update in `computeIfAbsent`
Map<Startable, CompletableFuture<Void>> subStarted = new HashMap<>(started);
CompletableFuture<Void> future = started.computeIfAbsent(it, startable -> {
return deepStart(subStarted, startable.getDependencies().stream()).thenRunAsync(startable::start, EXECUTOR);
});
started.putAll(subStarted);
return future;
})
.toArray(CompletableFuture[]::new);

return CompletableFuture.allOf(futures);
}
}
142 changes: 142 additions & 0 deletions core/src/test/java/org/testcontainers/junit/DependenciesTest.java
@@ -0,0 +1,142 @@
package org.testcontainers.junit;

import lombok.Getter;
import org.junit.Test;
import org.rnorth.visibleassertions.VisibleAssertions;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
import org.testcontainers.lifecycle.Startable;
import org.testcontainers.lifecycle.Startables;

import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;

public class DependenciesTest {

@Test
public void shouldWorkWithSimpleDependency() {
InvocationCountingStartable startable = new InvocationCountingStartable();

try (
GenericContainer container = new GenericContainer()
.withStartupCheckStrategy(new OneShotStartupCheckStrategy())
.dependsOn(startable)
) {
container.start();
}

VisibleAssertions.assertEquals("Started once", 1, startable.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("Does not trigger .stop()", 0, startable.getStopInvocationCount().intValue());
}

@Test
public void shouldWorkWithMutlipleDependencies() {
InvocationCountingStartable startable1 = new InvocationCountingStartable();
InvocationCountingStartable startable2 = new InvocationCountingStartable();

try (
GenericContainer container = new GenericContainer()
.withStartupCheckStrategy(new OneShotStartupCheckStrategy())
.dependsOn(startable1, startable2)
) {
container.start();
}

VisibleAssertions.assertEquals("Startable1 started once", 1, startable1.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("Startable2 started once", 1, startable2.getStartInvocationCount().intValue());
}

@Test
public void shouldStartEveryTime() {
InvocationCountingStartable startable = new InvocationCountingStartable();

try (
GenericContainer container = new GenericContainer()
.withStartupCheckStrategy(new OneShotStartupCheckStrategy())
.dependsOn(startable)
) {
container.start();
container.stop();

container.start();
container.stop();

container.start();
}

VisibleAssertions.assertEquals("Started multiple times", 3, startable.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("Does not trigger .stop()", 0, startable.getStopInvocationCount().intValue());
}

@Test
public void shouldStartTransitiveDependencies() {
InvocationCountingStartable transitiveOfTransitiveStartable = new InvocationCountingStartable();
InvocationCountingStartable transitiveStartable = new InvocationCountingStartable();
transitiveStartable.getDependencies().add(transitiveOfTransitiveStartable);

InvocationCountingStartable startable = new InvocationCountingStartable();
startable.getDependencies().add(transitiveStartable);

try (
GenericContainer container = new GenericContainer()
.withStartupCheckStrategy(new OneShotStartupCheckStrategy())
.dependsOn(startable)
) {
container.start();
container.stop();
}

VisibleAssertions.assertEquals("Root started", 1, startable.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("Transitive started", 1, transitiveStartable.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("Transitive of transitive started", 1, transitiveOfTransitiveStartable.getStartInvocationCount().intValue());
}

@Test
public void shouldHandleDiamondDependencies() throws Exception {
InvocationCountingStartable a = new InvocationCountingStartable();
InvocationCountingStartable b = new InvocationCountingStartable();
InvocationCountingStartable c = new InvocationCountingStartable();
InvocationCountingStartable d = new InvocationCountingStartable();
// / b \
// a d
// \ c /
b.getDependencies().add(a);
c.getDependencies().add(a);

d.getDependencies().add(b);
d.getDependencies().add(c);

Startables.deepStart(Stream.of(d)).get(1, TimeUnit.SECONDS);

VisibleAssertions.assertEquals("A started", 1, a.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("B started", 1, b.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("C started", 1, c.getStartInvocationCount().intValue());
VisibleAssertions.assertEquals("D started", 1, d.getStartInvocationCount().intValue());
}

private static class InvocationCountingStartable implements Startable {

@Getter
Set<Startable> dependencies = new HashSet<>();

@Getter
AtomicLong startInvocationCount = new AtomicLong(0);

@Getter
AtomicLong stopInvocationCount = new AtomicLong(0);

@Override
public void start() {
startInvocationCount.getAndIncrement();

}

@Override
public void stop() {
stopInvocationCount.getAndIncrement();
}
}
}

0 comments on commit 1d686a1

Please sign in to comment.