This is an automated email from the ASF dual-hosted git repository.
sigram pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 7391b7d53d4 SOLR-18564: CoreContainer.shutdown: run the remaining
closes when the client close pool times out (#5192)
7391b7d53d4 is described below
commit 7391b7d53d416dd06f5ca15a1dd712b32743fc93
Author: Nick <[email protected]>
AuthorDate: Mon Nov 2 06:28:08 2026 -0700
SOLR-18564: CoreContainer.shutdown: run the remaining closes when the
client close pool times out (#5192)
---
changelog/unreleased/SOLR-18564.yml | 7 +
.../java/org/apache/solr/core/CoreContainer.java | 13 +-
.../solr/core/CoreContainerShutdownTest.java | 163 +++++++++++++++++++++
.../org/apache/solr/common/util/ExecutorUtil.java | 17 ++-
.../apache/solr/common/util/ExecutorUtilTest.java | 21 ++-
5 files changed, 212 insertions(+), 9 deletions(-)
diff --git a/changelog/unreleased/SOLR-18564.yml
b/changelog/unreleased/SOLR-18564.yml
new file mode 100644
index 00000000000..545febcbec1
--- /dev/null
+++ b/changelog/unreleased/SOLR-18564.yml
@@ -0,0 +1,7 @@
+title: "CoreContainer.shutdown: a client close pool timeout skips the
remaining closes and leaks metrics contexts"
+type: fixed
+authors:
+ - name: Nick Shanin
+links:
+ - name: SOLR-18564
+ url: https://issues.apache.org/jira/browse/SOLR-18564
diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
index 5b73171eacc..f5d62750d2a 100644
--- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
+++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
@@ -93,6 +93,7 @@ import org.apache.solr.common.params.ModifiableSolrParams;
import org.apache.solr.common.util.CollectionUtil;
import org.apache.solr.common.util.EnvUtils;
import org.apache.solr.common.util.ExecutorUtil;
+import
org.apache.solr.common.util.ExecutorUtil.ExecutorTerminationTimeoutException;
import org.apache.solr.common.util.IOUtils;
import org.apache.solr.common.util.ObjectCache;
import org.apache.solr.common.util.SolrNamedThreadFactory;
@@ -1312,7 +1313,17 @@ public class CoreContainer {
// we want to close zk stuff last
zkSys.close();
} finally {
- ExecutorUtil.shutdownAndAwaitTermination(customThreadPool);
+ try {
+ ExecutorUtil.shutdownAndAwaitTermination(customThreadPool);
+ } catch (ExecutorTerminationTimeoutException e) {
+ // A close task that outlives the pool's termination budget must
not abort the
+ // rest of shutdown. The closes after this block (the plugin
closes, the
+ // container handlers and the root metrics context) are the only
release path
+ // for the container's metrics contexts, so skipping them leaks
the whole
+ // metrics tree of this container.
+ log.warn(
+ "The closeThreadPool did not terminate; continuing
CoreContainer shutdown.", e);
+ }
}
}
}
diff --git
a/solr/core/src/test/org/apache/solr/core/CoreContainerShutdownTest.java
b/solr/core/src/test/org/apache/solr/core/CoreContainerShutdownTest.java
new file mode 100644
index 00000000000..ea14a6b2ad9
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/core/CoreContainerShutdownTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.core;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.util.ObjectReleaseTracker;
+import org.apache.solr.handler.RequestHandlerBase;
+import org.apache.solr.handler.component.HttpShardHandlerFactory;
+import org.apache.solr.handler.component.ShardHandlerFactory;
+import org.apache.solr.metrics.SolrMetricsContext;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.security.AuthorizationContext;
+import org.apache.solr.security.PermissionNameProvider;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Shutdown must run its trailing closes even when a close task outlives the
closeThreadPool's
+ * termination budget and the pool wait throws. Those closes are the only
release path for the
+ * container's metrics contexts; skipping them leaks the whole metrics tree,
and in a test JVM the
+ * leaked contexts are then charged to an unrelated suite by the object
release tracker.
+ */
+public class CoreContainerShutdownTest extends SolrTestCase {
+
+ private static final String SOLR_XML =
+ "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n"
+ + "<solr>\n"
+ + "<str name=\"configSetBaseDir\">${configsets:configsets}</str>\n"
+ + "</solr>";
+
+ @BeforeClass
+ public static void beforeClass() {
+ System.setProperty(
+ "configsets",
SolrTestCaseJ4.getFile("solr/configsets").toAbsolutePath().toString());
+ }
+
+ @Test
+ public void testShutdownRunsTrailingClosesWhenClosePoolTimesOut() throws
Exception {
+ Path solrHome = createTempDir();
+ CoreContainer cc = new CoreContainer(SolrXmlConfig.fromString(solrHome,
SOLR_XML));
+ cc.load();
+ BlockingShardHandlerFactory blockingFactory = null;
+ try {
+ assertTrue(
+ "the root metrics context is tracked after load",
+
ObjectReleaseTracker.getTrackedObjectsOfType(SolrMetricsContext.class)
+ .contains(cc.solrMetricsContext));
+
+ CloseTrackingHandler handler = new CloseTrackingHandler();
+ cc.getRequestHandlers().put("/close-tracking", handler);
+
+ // Make one close task outlive the pool's whole termination budget (both
60 second
+ // awaits), so the pool wait throws the way it did in the incident this
test pins down.
+ // This makes the test take about two minutes; the wait is the
production timeout,
+ // not a test sleep.
+ blockingFactory = new
BlockingShardHandlerFactory(cc.shardHandlerFactory);
+ cc.shardHandlerFactory = blockingFactory;
+
+ cc.shutdown();
+
+ assertTrue(
+ "container handlers must be closed even when the close pool times
out",
+ handler.closed.get());
+ assertFalse(
+ "the root metrics context must be released even when the close pool
times out",
+
ObjectReleaseTracker.getTrackedObjectsOfType(SolrMetricsContext.class)
+ .contains(cc.solrMetricsContext));
+ } finally {
+ if (blockingFactory != null) {
+ blockingFactory.releaseClose();
+ // Wait for the blocked close task to finish so the delegate factory
is closed
+ // before the framework's post-class object release check runs.
+ assertTrue(blockingFactory.awaitCloseFinished(30, TimeUnit.SECONDS));
+ }
+ }
+ }
+
+ /** A container handler whose close is observable. */
+ private static final class CloseTrackingHandler extends RequestHandlerBase {
+ final AtomicBoolean closed = new AtomicBoolean();
+
+ @Override
+ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp)
{}
+
+ @Override
+ public String getDescription() {
+ return "test handler that records its close";
+ }
+
+ @Override
+ public PermissionNameProvider.Name getPermissionName(AuthorizationContext
request) {
+ return PermissionNameProvider.Name.ALL;
+ }
+
+ @Override
+ public void close() throws IOException {
+ closed.set(true);
+ super.close();
+ }
+ }
+
+ /**
+ * Wraps the real shard handler factory with a close that blocks until
released, surviving the
+ * pool's force-stop interrupt the way a stuck Jetty client close does, and
only then delegates.
+ */
+ private static final class BlockingShardHandlerFactory extends
HttpShardHandlerFactory {
+ private final ShardHandlerFactory delegate;
+ private final CountDownLatch release = new CountDownLatch(1);
+ private final CountDownLatch closeFinished = new CountDownLatch(1);
+
+ BlockingShardHandlerFactory(ShardHandlerFactory delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void close() {
+ try {
+ boolean released = false;
+ while (!released) {
+ try {
+ release.await();
+ released = true;
+ } catch (InterruptedException e) {
+ // Keep blocking: the pool's force-stop interrupts its workers,
and a close stuck
+ // inside the client shutdown survives that interrupt without
finishing.
+ }
+ }
+ delegate.close();
+ } finally {
+ closeFinished.countDown();
+ }
+ }
+
+ void releaseClose() {
+ release.countDown();
+ }
+
+ boolean awaitCloseFinished(long timeout, TimeUnit unit) throws
InterruptedException {
+ return closeFinished.await(timeout, unit);
+ }
+ }
+}
diff --git a/solr/solrj/src/java/org/apache/solr/common/util/ExecutorUtil.java
b/solr/solrj/src/java/org/apache/solr/common/util/ExecutorUtil.java
index e4814f7b094..adf685cfd91 100644
--- a/solr/solrj/src/java/org/apache/solr/common/util/ExecutorUtil.java
+++ b/solr/solrj/src/java/org/apache/solr/common/util/ExecutorUtil.java
@@ -112,6 +112,7 @@ public class ExecutorUtil {
* detail on the waiting can be found in {@link
#awaitTermination(ExecutorService)}.
*
* @param pool The ExecutorService to shut down and wait on
+ * @throws ExecutorTerminationTimeoutException if the pool does not
terminate after both waits
*/
public static void shutdownAndAwaitTermination(ExecutorService pool) {
if (pool == null) return;
@@ -119,6 +120,11 @@ public class ExecutorUtil {
awaitTermination(pool);
}
+ /**
+ * Interrupt the pool's tasks and wait for termination.
+ *
+ * @throws ExecutorTerminationTimeoutException if the pool does not
terminate after both waits
+ */
public static void shutdownNowAndAwaitTermination(ExecutorService pool) {
if (pool == null) return;
pool.shutdownNow(); // Disable new tasks from being submitted; interrupt
existing tasks
@@ -130,6 +136,7 @@ public class ExecutorUtil {
* shutdown the remaining threads and wait another 60 seconds.
*
* @param pool the ExecutorService to wait on
+ * @throws ExecutorTerminationTimeoutException if the pool does not
terminate after both waits
*/
public static void awaitTermination(ExecutorService pool) {
awaitTermination(pool, 60, TimeUnit.SECONDS);
@@ -146,7 +153,8 @@ public class ExecutorUtil {
if (!pool.awaitTermination(timeout, unit)) {
String executorDetails = describeExecutorForLogging(pool);
log.error("Threads from pool did not forcefully stop. {}",
executorDetails);
- throw new RuntimeException("Timeout waiting for pool to shutdown. "
+ executorDetails);
+ throw new ExecutorTerminationTimeoutException(
+ "Timeout waiting for pool to shutdown. " + executorDetails);
}
}
} catch (InterruptedException ie) {
@@ -157,6 +165,13 @@ public class ExecutorUtil {
}
}
+ /** The executor did not terminate within either the graceful or forced
shutdown wait. */
+ public static final class ExecutorTerminationTimeoutException extends
RuntimeException {
+ private ExecutorTerminationTimeoutException(String message) {
+ super(message);
+ }
+ }
+
/** Executor logging details which include pool name when executor fails to
terminate. */
public static String describeExecutorForLogging(ExecutorService pool) {
if (pool == null) return "";
diff --git
a/solr/solrj/src/test/org/apache/solr/common/util/ExecutorUtilTest.java
b/solr/solrj/src/test/org/apache/solr/common/util/ExecutorUtilTest.java
index ed739632e84..6fcac5df494 100644
--- a/solr/solrj/src/test/org/apache/solr/common/util/ExecutorUtilTest.java
+++ b/solr/solrj/src/test/org/apache/solr/common/util/ExecutorUtilTest.java
@@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.lucene.util.NamedThreadFactory;
import org.apache.solr.SolrTestCase;
+import
org.apache.solr.common.util.ExecutorUtil.ExecutorTerminationTimeoutException;
import org.apache.solr.logging.MDCLoggingContext;
import org.apache.solr.util.TimeOut;
import org.junit.Test;
@@ -52,9 +53,9 @@ public class ExecutorUtilTest extends SolrTestCase {
public void testExecutorUtilAwaitsTerminationWhenTaskIgnoresInterupt()
throws Exception {
final Worker w = new Worker(false);
+ final String poolName = this.getClass().getSimpleName() +
"non-interruptable";
final ExecutorService executorService =
- ExecutorUtil.newMDCAwareSingleThreadExecutor(
- new SolrNamedThreadFactory(this.getClass().getSimpleName() +
"non-interruptable"));
+ ExecutorUtil.newMDCAwareSingleThreadExecutor(new
SolrNamedThreadFactory(poolName));
try {
final Future<Boolean> f = executorService.submit(w);
@@ -63,11 +64,17 @@ public class ExecutorUtilTest extends SolrTestCase {
assertTrue(
"Worker not interupted by shutdown in a sane amount of time",
w.awaitWorkerInteruptedAtLeastOnce());
- assertThrows(
- RuntimeException.class,
- () ->
- ExecutorUtil.awaitTermination(
- executorService, MAX_AWAIT_TERMINATION_ARG_MS,
TimeUnit.MILLISECONDS));
+ RuntimeException timeout =
+ assertThrows(
+ ExecutorTerminationTimeoutException.class,
+ () ->
+ ExecutorUtil.awaitTermination(
+ executorService, MAX_AWAIT_TERMINATION_ARG_MS,
TimeUnit.MILLISECONDS));
+ assertTrue(
+ timeout.getMessage(),
+ timeout.getMessage().startsWith("Timeout waiting for pool to
shutdown. "));
+ assertTrue(
+ timeout.getMessage(), timeout.getMessage().contains("[poolName=" +
poolName + "]"));
assertFalse("Worker should not finish due to shutdown or
awaitTermination", f.isDone());
assertTrue("test sanity check: WTF? how did we get here?",
w.getNumberOfInterupts() > 0);