diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md index cee9f65efa..e5de653068 100644 --- a/docs/content/en/docs/documentation/operations/configuration.md +++ b/docs/content/en/docs/documentation/operations/configuration.md @@ -101,6 +101,24 @@ See also the [integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace) for this feature. +## Keeping the JVM Alive + +The operator normally keeps the JVM running through the non-daemon threads of its thread pools. If +nothing else does, for example because all the threads of the operator are daemon threads as it is +the case with virtual threads, the JVM exits as soon as the `main` method returns after starting +the operator. Call `Operator.keepAlive()` to have a non-daemon thread keep the JVM running until the +operator is stopped: + +```java +public static void main(String[] args) { + Operator operator = new Operator(); + operator.register(new MyReconciler()); + operator.installShutdownHook(); + operator.start(); + operator.keepAlive(); +} +``` + ## DependentResource-level configuration It is possible to define custom annotations to configure custom `DependentResource` implementations. In order to provide diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java index 1e9f927f79..780cd52813 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java @@ -20,6 +20,8 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; import java.util.function.Consumer; import org.slf4j.Logger; @@ -45,6 +47,7 @@ public class Operator implements LifecycleAware { private ConfigurationService configurationService; private volatile boolean started = false; private final AtomicBoolean shutdownHookInstalled = new AtomicBoolean(false); + private final AtomicReference keepAliveThread = new AtomicReference<>(); public Operator() { init(initConfigurationService(null, null), true); @@ -175,6 +178,38 @@ public void installShutdownHook(Duration gracefulShutdownTimeout) { installShutdownHook(); } + /** + * Keeps the JVM running until this operator is stopped, by starting a non-daemon thread that does + * nothing but wait. This is useful when the {@code main} method of the application returns after + * starting the operator and nothing else keeps the JVM alive, for example when the threads of the + * operator are all daemon threads, as is the case with virtual threads. + * + *

It can be called before or after {@link #start()}, and calling it again while the thread is + * already running has no effect. The thread ends when {@link #stop()} is called, so the operator + * can be stopped from a shutdown hook, see {@link #installShutdownHook()}. + */ + public void keepAlive() { + var thread = + new Thread( + () -> { + while (!Thread.currentThread().isInterrupted()) { + LockSupport.park(); + } + }, + "josdk-keep-alive"); + thread.setDaemon(false); + if (keepAliveThread.compareAndSet(null, thread)) { + thread.start(); + } + } + + private void stopKeepAlive() { + var thread = keepAliveThread.getAndSet(null); + if (thread != null) { + thread.interrupt(); + } + } + public KubernetesClient getKubernetesClient() { return configurationService.getKubernetesClient(); } @@ -240,6 +275,7 @@ public synchronized void start() { */ @Override public void stop() throws OperatorException { + stopKeepAlive(); Duration reconciliationTerminationTimeout = configurationService.reconciliationTerminationTimeout(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorKeepAliveTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorKeepAliveTest.java new file mode 100644 index 0000000000..89bb2c2b70 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorKeepAliveTest.java @@ -0,0 +1,95 @@ +/* + * Copyright Java Operator SDK 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 + * + * http://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 io.javaoperatorsdk.operator; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class OperatorKeepAliveTest { + + @Test + void keepsTheJvmAliveWithANonDaemonThreadUntilStopped() { + var operator = newOperator(); + + operator.keepAlive(); + + assertThat(keepAliveThread()).hasValueSatisfying(t -> assertThat(t.isDaemon()).isFalse()); + + operator.stop(); + + await().untilAsserted(() -> assertThat(keepAliveThread()).isEmpty()); + } + + @Test + void callingItAgainDoesNotStartAnotherThread() { + var operator = newOperator(); + try { + operator.keepAlive(); + operator.keepAlive(); + + assertThat(keepAliveThreads()).isEqualTo(1); + } finally { + operator.stop(); + } + } + + @Test + void canBeStartedAgainAfterTheOperatorWasStopped() { + var operator = newOperator(); + operator.keepAlive(); + operator.stop(); + await().untilAsserted(() -> assertThat(keepAliveThread()).isEmpty()); + + operator.keepAlive(); + try { + assertThat(keepAliveThread()).isPresent(); + } finally { + operator.stop(); + } + } + + @Test + void noThreadIsStartedUnlessRequested() { + var operator = newOperator(); + try { + assertThat(keepAliveThread()).isEmpty(); + } finally { + operator.stop(); + } + } + + private static Operator newOperator() { + return new Operator(MockKubernetesClient.client(TestCustomResource.class)); + } + + private static long keepAliveThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().equals("josdk-keep-alive") && t.isAlive()) + .count(); + } + + private static Optional keepAliveThread() { + return Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().equals("josdk-keep-alive") && t.isAlive()) + .findFirst(); + } +}