Skip to content

Commit 18e3e22

Browse files
aepfliCopilot
andcommitted
perf(flagd): parallelize e2e scenarios via container pool
Replace the single shared ComposeContainer with a pool of pre-warmed containers (ContainerPool). Each Cucumber scenario borrows its own ContainerEntry (ComposeContainer + dedicated temp dir) and returns it after teardown, giving full isolation across parallel scenarios: - No shared flagd process: /start, /stop, /restart, /change operations in one scenario cannot affect any other scenario. - Pool initialized once in @BeforeAll with all N containers started in parallel, so startup cost (~45s) is paid once regardless of pool size. - ContainerEntry carries its own temp dir, isolating FILE resolver socket paths and flag file paths per scenario. - State.containerEntry replaces the previous static container/sharedTempDir fields in ProviderSteps, making the container reference per-scenario. - junit-platform.properties enables cucumber.execution.parallel with fixed parallelism=4 matching the default pool size. Pool size is tunable via -Dflagd.e2e.pool.size=N. Expected cumulative improvement after this + Surefire forkCount=3: Before: ~264s (4:24) — sequential runners, sequential scenarios Phase 1: ~130s (2:10) — parallel runners (forkCount=3) Phase 2: ~60-80s (1:00-1:20) — parallel scenarios within each runner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 921bb09 commit 18e3e22

File tree

5 files changed

+172
-46
lines changed

5 files changed

+172
-46
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package dev.openfeature.contrib.providers.flagd.e2e;
2+
3+
import java.io.File;
4+
import java.io.IOException;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.nio.file.Paths;
8+
import java.time.Duration;
9+
import org.apache.commons.io.FileUtils;
10+
import org.apache.commons.lang3.RandomStringUtils;
11+
import org.testcontainers.containers.ComposeContainer;
12+
import org.testcontainers.containers.wait.strategy.Wait;
13+
14+
/** A single pre-warmed Docker Compose stack (flagd + envoy) and its associated temp directory. */
15+
public class ContainerEntry {
16+
17+
public static final int FORBIDDEN_PORT = 9212;
18+
19+
public final ComposeContainer container;
20+
public final Path tempDir;
21+
22+
private ContainerEntry(ComposeContainer container, Path tempDir) {
23+
this.container = container;
24+
this.tempDir = tempDir;
25+
}
26+
27+
/** Start a new container entry. Blocks until all services are ready. */
28+
public static ContainerEntry start() throws IOException {
29+
Path tempDir = Files.createDirectories(
30+
Paths.get("tmp/" + RandomStringUtils.randomAlphanumeric(8).toLowerCase() + "/"));
31+
32+
ComposeContainer container = new ComposeContainer(new File("test-harness/docker-compose.yaml"))
33+
.withEnv("FLAGS_DIR", tempDir.toAbsolutePath().toString())
34+
.withExposedService("flagd", 8013, Wait.forListeningPort())
35+
.withExposedService("flagd", 8015, Wait.forListeningPort())
36+
.withExposedService("flagd", 8080, Wait.forListeningPort())
37+
.withExposedService("envoy", 9211, Wait.forListeningPort())
38+
.withExposedService("envoy", FORBIDDEN_PORT, Wait.forListeningPort())
39+
.withStartupTimeout(Duration.ofSeconds(45));
40+
container.start();
41+
42+
return new ContainerEntry(container, tempDir);
43+
}
44+
45+
/** Stop the container and clean up the temp directory. */
46+
public void stop() throws IOException {
47+
container.stop();
48+
FileUtils.deleteDirectory(tempDir.toFile());
49+
}
50+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package dev.openfeature.contrib.providers.flagd.e2e;
2+
3+
import java.io.IOException;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
import java.util.concurrent.BlockingQueue;
7+
import java.util.concurrent.ExecutorService;
8+
import java.util.concurrent.Executors;
9+
import java.util.concurrent.Future;
10+
import java.util.concurrent.LinkedBlockingQueue;
11+
import lombok.extern.slf4j.Slf4j;
12+
13+
/**
14+
* A pool of pre-warmed {@link ContainerEntry} instances.
15+
*
16+
* <p>All containers are started in parallel during {@link #initialize()}, paying the ~45s Docker
17+
* Compose startup cost only once. Scenarios borrow a container via {@link #acquire()} and return
18+
* it via {@link #release(ContainerEntry)} after teardown, allowing the next scenario to reuse it
19+
* immediately without any cold-start overhead.
20+
*
21+
* <p>Pool size is controlled by the system property {@code flagd.e2e.pool.size} (default: 4).
22+
*/
23+
@Slf4j
24+
public class ContainerPool {
25+
26+
private static final int POOL_SIZE =
27+
Integer.getInteger("flagd.e2e.pool.size", 4);
28+
29+
private static final BlockingQueue<ContainerEntry> pool = new LinkedBlockingQueue<>();
30+
private static final List<ContainerEntry> all = new ArrayList<>();
31+
32+
public static void initialize() throws Exception {
33+
log.info("Starting container pool of size {}...", POOL_SIZE);
34+
ExecutorService executor = Executors.newFixedThreadPool(POOL_SIZE);
35+
List<Future<ContainerEntry>> futures = new ArrayList<>();
36+
37+
for (int i = 0; i < POOL_SIZE; i++) {
38+
futures.add(executor.submit(ContainerEntry::start));
39+
}
40+
41+
for (Future<ContainerEntry> future : futures) {
42+
ContainerEntry entry = future.get();
43+
pool.add(entry);
44+
all.add(entry);
45+
}
46+
47+
executor.shutdown();
48+
log.info("Container pool ready ({} containers).", POOL_SIZE);
49+
}
50+
51+
public static void shutdown() {
52+
all.forEach(entry -> {
53+
try {
54+
entry.stop();
55+
} catch (IOException e) {
56+
log.warn("Error stopping container entry", e);
57+
}
58+
});
59+
pool.clear();
60+
all.clear();
61+
}
62+
63+
/**
64+
* Borrow a container from the pool, blocking until one becomes available.
65+
* The caller MUST call {@link #release(ContainerEntry)} when done.
66+
*/
67+
public static ContainerEntry acquire() throws InterruptedException {
68+
return pool.take();
69+
}
70+
71+
/** Return a container to the pool so the next scenario can use it. */
72+
public static void release(ContainerEntry entry) {
73+
pool.add(entry);
74+
}
75+
}

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class State {
1616
public ProviderType providerType;
1717
public Client client;
1818
public FeatureProvider provider;
19+
/** The container borrowed from {@link ContainerPool} for this scenario. */
20+
public ContainerEntry containerEntry;
1921
public ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
2022
public Optional<Event> lastEvent;
2123
public FlagSteps.Flag flag;

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java

Lines changed: 36 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import dev.openfeature.contrib.providers.flagd.Config;
77
import dev.openfeature.contrib.providers.flagd.FlagdOptions;
88
import dev.openfeature.contrib.providers.flagd.FlagdProvider;
9+
import dev.openfeature.contrib.providers.flagd.e2e.ContainerPool;
910
import dev.openfeature.contrib.providers.flagd.e2e.ContainerUtil;
1011
import dev.openfeature.contrib.providers.flagd.e2e.State;
1112
import dev.openfeature.sdk.FeatureProvider;
@@ -18,58 +19,39 @@
1819
import io.cucumber.java.en.Then;
1920
import io.cucumber.java.en.When;
2021
import java.io.File;
21-
import java.io.IOException;
22-
import java.nio.file.Files;
23-
import java.nio.file.Path;
24-
import java.nio.file.Paths;
25-
import java.time.Duration;
2622
import lombok.extern.slf4j.Slf4j;
27-
import org.apache.commons.io.FileUtils;
28-
import org.apache.commons.lang3.RandomStringUtils;
2923
import org.apache.commons.lang3.StringUtils;
3024
import org.testcontainers.containers.ComposeContainer;
31-
import org.testcontainers.containers.wait.strategy.Wait;
3225

3326
@Slf4j
3427
public class ProviderSteps extends AbstractSteps {
3528

3629
public static final int UNAVAILABLE_PORT = 9999;
37-
public static final int FORBIDDEN_PORT = 9212;
38-
static ComposeContainer container;
39-
40-
static Path sharedTempDir;
4130

4231
public ProviderSteps(State state) {
4332
super(state);
4433
}
4534

4635
@BeforeAll
47-
public static void beforeAll() throws IOException {
48-
sharedTempDir = Files.createDirectories(
49-
Paths.get("tmp/" + RandomStringUtils.randomAlphanumeric(8).toLowerCase() + "/"));
50-
container = new ComposeContainer(new File("test-harness/docker-compose.yaml"))
51-
.withEnv("FLAGS_DIR", sharedTempDir.toAbsolutePath().toString())
52-
.withExposedService("flagd", 8013, Wait.forListeningPort())
53-
.withExposedService("flagd", 8015, Wait.forListeningPort())
54-
.withExposedService("flagd", 8080, Wait.forListeningPort())
55-
.withExposedService("envoy", 9211, Wait.forListeningPort())
56-
.withExposedService("envoy", FORBIDDEN_PORT, Wait.forListeningPort())
57-
.withStartupTimeout(Duration.ofSeconds(45));
58-
container.start();
36+
public static void beforeAll() throws Exception {
37+
ContainerPool.initialize();
5938
}
6039

6140
@AfterAll
62-
public static void afterAll() throws IOException {
63-
container.stop();
64-
FileUtils.deleteDirectory(sharedTempDir.toFile());
41+
public static void afterAll() {
42+
ContainerPool.shutdown();
6543
}
6644

6745
@After
6846
public void tearDown() {
69-
if (state.client != null) {
70-
when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/stop")
71-
.then()
72-
.statusCode(200);
47+
if (state.containerEntry != null) {
48+
if (state.client != null) {
49+
when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/stop")
50+
.then()
51+
.statusCode(200);
52+
}
53+
ContainerPool.release(state.containerEntry);
54+
state.containerEntry = null;
7355
}
7456
if (state.provider != null) {
7557
state.provider.shutdown();
@@ -78,6 +60,9 @@ public void tearDown() {
7860

7961
@Given("a {} flagd provider")
8062
public void setupProvider(String providerType) throws InterruptedException {
63+
state.containerEntry = ContainerPool.acquire();
64+
ComposeContainer container = state.containerEntry.container;
65+
8166
String flagdConfig = "default";
8267
state.builder.deadline(1000).keepAlive(0).retryGracePeriod(2);
8368
boolean wait = true;
@@ -87,25 +72,25 @@ public void setupProvider(String providerType) throws InterruptedException {
8772
this.state.providerType = ProviderType.SOCKET;
8873
state.builder.port(UNAVAILABLE_PORT);
8974
if (State.resolverType == Config.Resolver.FILE) {
90-
9175
state.builder.offlineFlagSourcePath("not-existing");
9276
}
9377
wait = false;
9478
break;
9579
case "forbidden":
96-
state.builder.port(container.getServicePort("envoy", FORBIDDEN_PORT));
80+
state.builder.port(container.getServicePort("envoy", ContainerEntry.FORBIDDEN_PORT));
9781
wait = false;
9882
break;
9983
case "socket":
10084
this.state.providerType = ProviderType.SOCKET;
101-
String socketPath =
102-
sharedTempDir.resolve("socket.sock").toAbsolutePath().toString();
85+
String socketPath = state.containerEntry.tempDir
86+
.resolve("socket.sock")
87+
.toAbsolutePath()
88+
.toString();
10389
state.builder.socketPath(socketPath);
10490
state.builder.port(UNAVAILABLE_PORT);
10591
break;
10692
case "ssl":
10793
String path = "test-harness/ssl/custom-root-cert.crt";
108-
10994
File file = new File(path);
11095
String absolutePath = file.getAbsolutePath();
11196
this.state.providerType = ProviderType.SSL;
@@ -117,15 +102,14 @@ public void setupProvider(String providerType) throws InterruptedException {
117102
break;
118103
case "metadata":
119104
flagdConfig = "metadata";
120-
121105
if (State.resolverType == Config.Resolver.FILE) {
122106
FlagdOptions build = state.builder.build();
123107
String selector = build.getSelector();
124108
String replace = selector.replace("rawflags/", "");
125-
126109
state.builder
127110
.port(UNAVAILABLE_PORT)
128-
.offlineFlagSourcePath(new File("test-harness/flags/" + replace).getAbsolutePath());
111+
.offlineFlagSourcePath(
112+
new File("test-harness/flags/" + replace).getAbsolutePath());
129113
} else {
130114
state.builder.port(ContainerUtil.getPort(container, State.resolverType));
131115
}
@@ -137,10 +121,9 @@ public void setupProvider(String providerType) throws InterruptedException {
137121
case "stable":
138122
this.state.providerType = ProviderType.DEFAULT;
139123
if (State.resolverType == Config.Resolver.FILE) {
140-
141124
state.builder
142125
.port(UNAVAILABLE_PORT)
143-
.offlineFlagSourcePath(sharedTempDir
126+
.offlineFlagSourcePath(state.containerEntry.tempDir
144127
.resolve("allFlags.json")
145128
.toAbsolutePath()
146129
.toString());
@@ -155,12 +138,16 @@ public void setupProvider(String providerType) throws InterruptedException {
155138
// Setting TargetUri if this setting is set
156139
FlagdOptions tempBuild = state.builder.build();
157140
if (!StringUtils.isEmpty(tempBuild.getTargetUri())) {
158-
String replace = tempBuild.getTargetUri().replace("<port>", "" + container.getServicePort("envoy", 9211));
141+
String replace = tempBuild.getTargetUri()
142+
.replace("<port>", "" + container.getServicePort("envoy", 9211));
159143
state.builder.targetUri(replace);
160144
state.builder.port(UNAVAILABLE_PORT);
161145
}
162146

163-
when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/start?config={config}", flagdConfig)
147+
when().post(
148+
"http://" + ContainerUtil.getLaunchpadUrl(container)
149+
+ "/start?config={config}",
150+
flagdConfig)
164151
.then()
165152
.statusCode(200);
166153

@@ -182,21 +169,24 @@ public void setupProvider(String providerType) throws InterruptedException {
182169

183170
@When("the connection is lost")
184171
public void the_connection_is_lost() {
185-
when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/stop")
172+
when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/stop")
186173
.then()
187174
.statusCode(200);
188175
}
189176

190177
@When("the connection is lost for {int}s")
191178
public void the_connection_is_lost_for(int seconds) {
192-
when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/restart?seconds={seconds}", seconds)
179+
when().post(
180+
"http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container)
181+
+ "/restart?seconds={seconds}",
182+
seconds)
193183
.then()
194184
.statusCode(200);
195185
}
196186

197187
@When("the flag was modified")
198188
public void the_flag_was_modded() {
199-
when().post("http://" + ContainerUtil.getLaunchpadUrl(container) + "/change")
189+
when().post("http://" + ContainerUtil.getLaunchpadUrl(state.containerEntry.container) + "/change")
200190
.then()
201191
.statusCode(200);
202192
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Enable parallel scenario execution within each suite runner.
2+
# Each scenario borrows its own ContainerEntry from ContainerPool, so
3+
# concurrent scenarios are fully isolated — no shared flagd process.
4+
cucumber.execution.parallel.enabled=true
5+
cucumber.execution.parallel.config.strategy=fixed
6+
# Should match flagd.e2e.pool.size (default 4) so all pool slots are
7+
# utilized without scenarios blocking waiting for a free container.
8+
cucumber.execution.parallel.config.fixed.parallelism=4
9+
cucumber.execution.parallel.config.fixed.max-pool-size=4

0 commit comments

Comments
 (0)