Skip to content

Commit 7dfe36a

Browse files
fix(hotreload): Unset INSTANCE of executor on shutdown (#324)
--------- Co-authored-by: Gastón Fournier <gaston@getunleash.io>
1 parent 780b8ae commit 7dfe36a

File tree

4 files changed

+91
-12
lines changed

4 files changed

+91
-12
lines changed
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
package io.getunleash.util;
22

3-
import io.getunleash.lang.Nullable;
43
import java.util.concurrent.Future;
54
import java.util.concurrent.RejectedExecutionException;
6-
import java.util.concurrent.ScheduledFuture;
75

86
public interface UnleashScheduledExecutor {
9-
@Nullable
10-
ScheduledFuture setInterval(Runnable command, long initialDelaySec, long periodSec)
7+
8+
void setInterval(Runnable command, long initialDelaySec, long periodSec)
119
throws RejectedExecutionException;
1210

1311
Future<Void> scheduleOnce(Runnable runnable);
1412

15-
public default void shutdown() {}
13+
default void shutdown() {}
14+
15+
default void shutdownNow() {}
1616
}

src/main/java/io/getunleash/util/UnleashScheduledExecutorImpl.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,12 @@ public static synchronized UnleashScheduledExecutorImpl getInstance() {
3737
}
3838

3939
@Override
40-
public @Nullable ScheduledFuture setInterval(
41-
Runnable command, long initialDelaySec, long periodSec) {
40+
public void setInterval(Runnable command, long initialDelaySec, long periodSec) {
4241
try {
43-
return scheduledThreadPoolExecutor.scheduleAtFixedRate(
42+
scheduledThreadPoolExecutor.scheduleAtFixedRate(
4443
command, initialDelaySec, periodSec, TimeUnit.SECONDS);
4544
} catch (RejectedExecutionException ex) {
4645
LOG.error("Unleash background task crashed", ex);
47-
return null;
4846
}
4947
}
5048

@@ -54,7 +52,16 @@ public Future<Void> scheduleOnce(Runnable runnable) {
5452
}
5553

5654
@Override
57-
public void shutdown() {
55+
public synchronized void shutdown() {
5856
this.scheduledThreadPoolExecutor.shutdown();
57+
this.executorService.shutdown();
58+
INSTANCE = null;
59+
}
60+
61+
@Override
62+
public synchronized void shutdownNow() {
63+
this.scheduledThreadPoolExecutor.shutdownNow();
64+
this.executorService.shutdownNow();
65+
INSTANCE = null;
5966
}
6067
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.getunleash;
2+
3+
import io.getunleash.util.UnleashConfig;
4+
import org.junit.jupiter.api.*;
5+
6+
import java.lang.reflect.Field;
7+
import java.util.concurrent.ScheduledThreadPoolExecutor;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
class HotReloadSchedulerReuseTest {
12+
13+
14+
private UnleashConfig baseConfig() {
15+
return UnleashConfig.builder()
16+
.appName("hot-reload-test-app")
17+
.instanceId("A")
18+
.unleashAPI("http://localhost") // never hit
19+
.synchronousFetchOnInitialisation(false)
20+
.fetchTogglesInterval(100)
21+
.sendMetricsInterval(100)
22+
.build();
23+
}
24+
25+
private ScheduledThreadPoolExecutor currentGlobalExecutor() throws Exception {
26+
Class<?> execClazz = Class.forName("io.getunleash.util.UnleashScheduledExecutorImpl");
27+
Field instance = execClazz.getDeclaredField("INSTANCE");
28+
instance.setAccessible(true);
29+
Object inst = instance.get(null);
30+
if (inst == null) return null;
31+
32+
Field pool = execClazz.getDeclaredField("scheduledThreadPoolExecutor");
33+
pool.setAccessible(true);
34+
return (ScheduledThreadPoolExecutor) pool.get(inst);
35+
}
36+
37+
@Test
38+
void secondClientDoesNotReuseSchedulerExecutor() throws Exception {
39+
// 1) Create first client; let it schedule background tasks
40+
DefaultUnleash first = new DefaultUnleash(
41+
baseConfig()
42+
);
43+
44+
// Let it initialize/schedule
45+
Thread.sleep(150);
46+
47+
// Snapshot the global executor
48+
ScheduledThreadPoolExecutor execBeforeShutdown = currentGlobalExecutor();
49+
assertThat(execBeforeShutdown).isNotNull();
50+
assertThat(execBeforeShutdown.isShutdown()).isFalse();
51+
52+
// 2) Simulate app stop (DevTools restart) by shutting the client
53+
first.shutdown();
54+
55+
// After shutdown, the global executor instance is properly reset to null
56+
ScheduledThreadPoolExecutor execAfterShutdown = currentGlobalExecutor();
57+
assertThat(execAfterShutdown).isNull();
58+
59+
// 3) "Reloaded app": create a second client in the same JVM (statics still around)
60+
DefaultUnleash second = new DefaultUnleash(
61+
baseConfig()
62+
);
63+
64+
// 4) Assert that the second client creates a fresh executor (not reusing the terminated one)
65+
ScheduledThreadPoolExecutor execUsedBySecond = currentGlobalExecutor();
66+
assertThat(execUsedBySecond).isNotNull();
67+
assertThat(execUsedBySecond.isShutdown()).isFalse(); // it's a new one
68+
69+
// Clean up
70+
second.shutdown();
71+
}
72+
}

src/test/java/io/getunleash/SynchronousTestExecutor.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ public class SynchronousTestExecutor implements UnleashScheduledExecutor {
1313
private static final Logger LOG = LoggerFactory.getLogger(SynchronousTestExecutor.class);
1414

1515
@Override
16-
public ScheduledFuture setInterval(Runnable command, long initialDelaySec, long periodSec)
16+
public void setInterval(Runnable command, long initialDelaySec, long periodSec)
1717
throws RejectedExecutionException {
1818
LOG.warn("i will only do this once");
19-
return scheduleOnce(command);
19+
scheduleOnce(command);
2020
}
2121

2222
@Override

0 commit comments

Comments
 (0)