Skip to content

Commit 7a0c612

Browse files
fix: shutdown hook deadlock under leader election and deprecate Operator#installShutdownHook(Duration) (#3383)
Signed-off-by: Dennis-Mircea Ciupitu <dennis.mircea.ciupitu@gmail.com>
1 parent e02cd56 commit 7a0c612

3 files changed

Lines changed: 115 additions & 15 deletions

File tree

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/LeaderElectionManager.java

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
*/
1616
package io.javaoperatorsdk.operator;
1717

18-
import java.util.Arrays;
1918
import java.util.Collection;
19+
import java.util.List;
2020
import java.util.UUID;
2121
import java.util.concurrent.CompletableFuture;
22+
import java.util.concurrent.atomic.AtomicBoolean;
2223
import java.util.function.Predicate;
2324
import java.util.stream.Collectors;
2425

@@ -36,9 +37,39 @@
3637
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
3738
import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration;
3839

40+
/**
41+
* Manages the leader-election lifecycle for an {@link Operator} instance. Leader election ensures
42+
* that, in a high-availability setup with multiple replicas of the same operator, only one replica
43+
* at a time actively reconciles resources. The replica currently holding the lease is referred to
44+
* as the leader, and the others stand by until the lease becomes available.
45+
*
46+
* <p>Leader election is opt-in. It is enabled when a {@link LeaderElectionConfiguration} is
47+
* supplied via {@link
48+
* io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider#withLeaderElectionConfiguration(LeaderElectionConfiguration)
49+
* ConfigurationServiceOverrider#withLeaderElectionConfiguration(LeaderElectionConfiguration)}. The
50+
* configuration controls the lease name, namespace, durations, and optional user-supplied {@link
51+
* LeaderCallbacks}.
52+
*
53+
* <p>{@link #stopLeading()} behaves differently depending on how it was triggered:
54+
*
55+
* <ul>
56+
* <li>If {@link #stop()} has already been called (graceful shutdown), it logs and returns without
57+
* exiting. This avoids deadlocking against the JVM shutdown hook lock when {@link
58+
* Operator#stop()} is invoked from a JVM shutdown hook.
59+
* <li>Otherwise, if the configured {@link LeaderElectionConfiguration#isExitOnStopLeading()} is
60+
* {@code true} (the default), it calls {@code System.exit(1)} so the process restarts and
61+
* another replica can take over.
62+
* <li>If {@code isExitOnStopLeading()} is {@code false}, it only logs and returns.
63+
* </ul>
64+
*
65+
* <p>The lifecycle methods {@link #start()} and {@link #stop()} are called by {@link Operator} as
66+
* part of {@link Operator#start()} and {@link Operator#stop()} respectively. Users typically do not
67+
* interact with this class directly.
68+
*/
3969
public class LeaderElectionManager {
4070

4171
private static final Logger log = LoggerFactory.getLogger(LeaderElectionManager.class);
72+
private static final List<String> REQUIRED_VERBS = List.of("create", "update", "get");
4273

4374
public static final String NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE =
4475
"No permission to lease resource.";
@@ -53,6 +84,10 @@ public class LeaderElectionManager {
5384
private final ConfigurationService configurationService;
5485
private String leaseNamespace;
5586
private String leaseName;
87+
// Set in stop() before cancelling the leader-election future. Checked in stopLeading() so that
88+
// a graceful shutdown does not call System.exit, which would otherwise deadlock against the
89+
// JVM shutdown hook lock when stop() is invoked from a JVM shutdown hook.
90+
private final AtomicBoolean stoppingGracefully = new AtomicBoolean(false);
5691

5792
LeaderElectionManager(
5893
ControllerManager controllerManager, ConfigurationService configurationService) {
@@ -118,7 +153,11 @@ private void startLeading() {
118153
controllerManager.startEventProcessing();
119154
}
120155

121-
private void stopLeading() {
156+
void stopLeading() {
157+
if (stoppingGracefully.get()) {
158+
log.info("Stopped leading for identity: {} during graceful shutdown.", identity);
159+
return;
160+
}
122161
if (configurationService.getLeaderElectionConfiguration().orElseThrow().isExitOnStopLeading()) {
123162
log.info("Stopped leading for identity: {}. Exiting.", identity);
124163
// When leader stops leading the process ends immediately to prevent multiple reconciliations
@@ -147,13 +186,13 @@ public void start() {
147186
}
148187

149188
public void stop() {
189+
stoppingGracefully.set(true);
150190
if (leaderElectionFuture != null) {
151191
leaderElectionFuture.cancel(false);
152192
}
153193
}
154194

155195
private void checkLeaseAccess() {
156-
var verbsRequired = Arrays.asList("create", "update", "get");
157196
SelfSubjectRulesReview review = new SelfSubjectRulesReview();
158197
review.setSpec(new SelfSubjectRulesReviewSpecBuilder().withNamespace(leaseNamespace).build());
159198
var reviewResult = configurationService.getKubernetesClient().resource(review).create();
@@ -168,16 +207,15 @@ private void checkLeaseAccess() {
168207
|| rule.getResourceNames().contains(leaseName))
169208
.map(ResourceRule::getVerbs)
170209
.flatMap(Collection::stream)
171-
.distinct()
172-
.collect(Collectors.toList());
173-
if (verbsAllowed.contains(UNIVERSAL_VALUE) || verbsAllowed.containsAll(verbsRequired)) {
210+
.collect(Collectors.toUnmodifiableSet());
211+
if (verbsAllowed.contains(UNIVERSAL_VALUE) || verbsAllowed.containsAll(REQUIRED_VERBS)) {
174212
return;
175213
}
176214

177215
var missingVerbs =
178-
verbsRequired.stream()
216+
REQUIRED_VERBS.stream()
179217
.filter(Predicate.not(verbsAllowed::contains))
180-
.collect(Collectors.toList());
218+
.collect(Collectors.joining(","));
181219

182220
throw new OperatorException(
183221
NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE

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

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.HashSet;
2020
import java.util.Optional;
2121
import java.util.Set;
22+
import java.util.concurrent.atomic.AtomicBoolean;
2223
import java.util.function.Consumer;
2324

2425
import org.slf4j.Logger;
@@ -43,6 +44,7 @@ public class Operator implements LifecycleAware {
4344
private LeaderElectionManager leaderElectionManager;
4445
private ConfigurationService configurationService;
4546
private volatile boolean started = false;
47+
private final AtomicBoolean shutdownHookInstalled = new AtomicBoolean(false);
4648

4749
public Operator() {
4850
init(initConfigurationService(null, null), true);
@@ -129,6 +131,32 @@ protected ConfigurationService initConfigurationService(
129131
return ConfigurationService.newOverriddenConfigurationService(overrider);
130132
}
131133

134+
/**
135+
* Adds a JVM shutdown hook that automatically calls {@link #stop()} when the application shuts
136+
* down. The shutdown timeout used while waiting for in-flight reconciliations to complete is
137+
* taken from {@link
138+
* io.javaoperatorsdk.operator.api.config.ConfigurationService#reconciliationTerminationTimeout()}.
139+
* Configure it via {@link
140+
* io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider#withReconciliationTerminationTimeout(Duration)
141+
* ConfigurationServiceOverrider#withReconciliationTerminationTimeout(Duration)}.
142+
*
143+
* <p>The hook is registered regardless of whether leader election is enabled. A leader pod
144+
* receiving {@code SIGTERM} will therefore release its lease cleanly so that a standby replica
145+
* can take over without waiting for lease expiry.
146+
*
147+
* <p><b>NOTE:</b> You may also want to tune the Pod's {@code terminationGracePeriodSeconds} to be
148+
* at least as long as the configured {@code reconciliationTerminationTimeout}, plus a small
149+
* buffer for the rest of the shutdown sequence (releasing the leader-election lease and closing
150+
* the Kubernetes client). If the grace period elapses before {@link #stop()} returns, the kubelet
151+
* sends {@code SIGKILL}, in-flight reconciliations are abandoned, and any held leader-election
152+
* lease is not released cleanly.
153+
*/
154+
public void installShutdownHook() {
155+
if (shutdownHookInstalled.compareAndSet(false, true)) {
156+
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
157+
}
158+
}
159+
132160
/**
133161
* Adds a shutdown hook that automatically calls {@link #stop()} when the app shuts down. Note
134162
* that graceful shutdown is usually not needed, but some {@link Reconciler} implementations might
@@ -137,16 +165,14 @@ protected ConfigurationService initConfigurationService(
137165
* <p>Note that you might want to tune "terminationGracePeriodSeconds" for the Pod running the
138166
* controller.
139167
*
140-
* @param gracefulShutdownTimeout timeout to wait for executor threads to complete actual
141-
* reconciliations
168+
* @param gracefulShutdownTimeout ignored, configure {@link
169+
* ConfigurationService#reconciliationTerminationTimeout()} instead
170+
* @deprecated Use {@link #installShutdownHook()} instead
142171
*/
172+
@Deprecated(forRemoval = true)
143173
@SuppressWarnings("unused")
144174
public void installShutdownHook(Duration gracefulShutdownTimeout) {
145-
if (!leaderElectionManager.isLeaderElectionEnabled()) {
146-
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
147-
} else {
148-
log.warn("Leader election is on, shutdown hook will not be installed.");
149-
}
175+
installShutdownHook();
150176
}
151177

152178
public KubernetesClient getKubernetesClient() {
@@ -188,6 +214,30 @@ public synchronized void start() {
188214
}
189215
}
190216

217+
/**
218+
* Stops the operator and releases its resources. The shutdown sequence is:
219+
*
220+
* <ol>
221+
* <li>Stop the controller manager, halting reconciliation of all registered controllers.
222+
* <li>Stop the executor service manager, waiting up to {@link
223+
* io.javaoperatorsdk.operator.api.config.ConfigurationService#reconciliationTerminationTimeout()}
224+
* for in-flight reconciliations to complete.
225+
* <li>Stop the leader-election manager, cancelling the leader-election future and releasing any
226+
* held lease.
227+
* <li>Close the {@link KubernetesClient} if {@link
228+
* io.javaoperatorsdk.operator.api.config.ConfigurationService#closeClientOnStop()} is
229+
* {@code true} (the default).
230+
* </ol>
231+
*
232+
* <p>It is safe to call this method from a JVM shutdown hook (see {@link #installShutdownHook()})
233+
* as the graceful-shutdown path coordinates with the leader-election callbacks so that {@code
234+
* System.exit} is not invoked while the JVM is already shutting down.
235+
*
236+
* <p>If the operator was never successfully started, this method only stops the executor service
237+
* manager so that no thread pools are leaked.
238+
*
239+
* @throws OperatorException if an error occurs during shutdown
240+
*/
191241
@Override
192242
public void stop() throws OperatorException {
193243
Duration reconciliationTerminationTimeout =

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/LeaderElectionManagerTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ void testInitPermissionsMultipleRulesWithResourceName(@TempDir Path tempDir) thr
109109
assertTrue(leaderElectionManager.isLeaderElectionEnabled());
110110
}
111111

112+
@Test
113+
void stopLeadingDoesNotInvokeSystemExitWhenStopWasCalledFirst() {
114+
// When stop() is called before the onStopLeading callback fires (which is what happens when
115+
// stop()'s future cancellation triggers the callback), stopLeading() must skip
116+
// System.exit(1). Otherwise calling stop() from inside a JVM shutdown hook deadlocks against
117+
// the java.lang.Shutdown class lock. If this regression is ever reintroduced, this test
118+
// method would terminate the JUnit JVM via System.exit(1) instead of failing cleanly.
119+
final var leaderElectionManager = leaderElectionManager(null);
120+
leaderElectionManager.stop();
121+
leaderElectionManager.stopLeading();
122+
}
123+
112124
@Test
113125
void testFailedToInitMissingPermission(@TempDir Path tempDir) throws IOException {
114126
var namespace = "foo";

0 commit comments

Comments
 (0)