Skip to content

Commit 4d2bb15

Browse files
csviriclaude
andauthored
feat: add Operator.keepAlive to keep the JVM running until the operator is stopped (#3669)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 9697022 commit 4d2bb15

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

‎docs/content/en/docs/documentation/operations/configuration.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ See also
101101
the [integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace)
102102
for this feature.
103103

104+
## Keeping the JVM Alive
105+
106+
The operator normally keeps the JVM running through the non-daemon threads of its thread pools. If
107+
nothing else does, for example because all the threads of the operator are daemon threads as it is
108+
the case with virtual threads, the JVM exits as soon as the `main` method returns after starting
109+
the operator. Call `Operator.keepAlive()` to have a non-daemon thread keep the JVM running until the
110+
operator is stopped:
111+
112+
```java
113+
public static void main(String[] args) {
114+
Operator operator = new Operator();
115+
operator.register(new MyReconciler());
116+
operator.installShutdownHook();
117+
operator.start();
118+
operator.keepAlive();
119+
}
120+
```
121+
104122
## DependentResource-level configuration
105123

106124
It is possible to define custom annotations to configure custom `DependentResource` implementations. In order to provide

‎operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import java.util.Optional;
2121
import java.util.Set;
2222
import java.util.concurrent.atomic.AtomicBoolean;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
import java.util.concurrent.locks.LockSupport;
2325
import java.util.function.Consumer;
2426

2527
import org.slf4j.Logger;
@@ -45,6 +47,7 @@ public class Operator implements LifecycleAware {
4547
private ConfigurationService configurationService;
4648
private volatile boolean started = false;
4749
private final AtomicBoolean shutdownHookInstalled = new AtomicBoolean(false);
50+
private final AtomicReference<Thread> keepAliveThread = new AtomicReference<>();
4851

4952
public Operator() {
5053
init(initConfigurationService(null, null), true);
@@ -175,6 +178,38 @@ public void installShutdownHook(Duration gracefulShutdownTimeout) {
175178
installShutdownHook();
176179
}
177180

181+
/**
182+
* Keeps the JVM running until this operator is stopped, by starting a non-daemon thread that does
183+
* nothing but wait. This is useful when the {@code main} method of the application returns after
184+
* starting the operator and nothing else keeps the JVM alive, for example when the threads of the
185+
* operator are all daemon threads, as is the case with virtual threads.
186+
*
187+
* <p>It can be called before or after {@link #start()}, and calling it again while the thread is
188+
* already running has no effect. The thread ends when {@link #stop()} is called, so the operator
189+
* can be stopped from a shutdown hook, see {@link #installShutdownHook()}.
190+
*/
191+
public void keepAlive() {
192+
var thread =
193+
new Thread(
194+
() -> {
195+
while (!Thread.currentThread().isInterrupted()) {
196+
LockSupport.park();
197+
}
198+
},
199+
"josdk-keep-alive");
200+
thread.setDaemon(false);
201+
if (keepAliveThread.compareAndSet(null, thread)) {
202+
thread.start();
203+
}
204+
}
205+
206+
private void stopKeepAlive() {
207+
var thread = keepAliveThread.getAndSet(null);
208+
if (thread != null) {
209+
thread.interrupt();
210+
}
211+
}
212+
178213
public KubernetesClient getKubernetesClient() {
179214
return configurationService.getKubernetesClient();
180215
}
@@ -240,6 +275,7 @@ public synchronized void start() {
240275
*/
241276
@Override
242277
public void stop() throws OperatorException {
278+
stopKeepAlive();
243279
Duration reconciliationTerminationTimeout =
244280
configurationService.reconciliationTerminationTimeout();
245281

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator;
17+
18+
import java.util.Optional;
19+
20+
import org.junit.jupiter.api.Test;
21+
22+
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.awaitility.Awaitility.await;
26+
27+
class OperatorKeepAliveTest {
28+
29+
@Test
30+
void keepsTheJvmAliveWithANonDaemonThreadUntilStopped() {
31+
var operator = newOperator();
32+
33+
operator.keepAlive();
34+
35+
assertThat(keepAliveThread()).hasValueSatisfying(t -> assertThat(t.isDaemon()).isFalse());
36+
37+
operator.stop();
38+
39+
await().untilAsserted(() -> assertThat(keepAliveThread()).isEmpty());
40+
}
41+
42+
@Test
43+
void callingItAgainDoesNotStartAnotherThread() {
44+
var operator = newOperator();
45+
try {
46+
operator.keepAlive();
47+
operator.keepAlive();
48+
49+
assertThat(keepAliveThreads()).isEqualTo(1);
50+
} finally {
51+
operator.stop();
52+
}
53+
}
54+
55+
@Test
56+
void canBeStartedAgainAfterTheOperatorWasStopped() {
57+
var operator = newOperator();
58+
operator.keepAlive();
59+
operator.stop();
60+
await().untilAsserted(() -> assertThat(keepAliveThread()).isEmpty());
61+
62+
operator.keepAlive();
63+
try {
64+
assertThat(keepAliveThread()).isPresent();
65+
} finally {
66+
operator.stop();
67+
}
68+
}
69+
70+
@Test
71+
void noThreadIsStartedUnlessRequested() {
72+
var operator = newOperator();
73+
try {
74+
assertThat(keepAliveThread()).isEmpty();
75+
} finally {
76+
operator.stop();
77+
}
78+
}
79+
80+
private static Operator newOperator() {
81+
return new Operator(MockKubernetesClient.client(TestCustomResource.class));
82+
}
83+
84+
private static long keepAliveThreads() {
85+
return Thread.getAllStackTraces().keySet().stream()
86+
.filter(t -> t.getName().equals("josdk-keep-alive") && t.isAlive())
87+
.count();
88+
}
89+
90+
private static Optional<Thread> keepAliveThread() {
91+
return Thread.getAllStackTraces().keySet().stream()
92+
.filter(t -> t.getName().equals("josdk-keep-alive") && t.isAlive())
93+
.findFirst();
94+
}
95+
}

0 commit comments

Comments
 (0)