This is an automated email from the ASF dual-hosted git repository.

janhoy 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 dc441bc7beb SOLR-18472 TikaServerExtractionBackend can hand out a 
reference to a stopped HttpClient (#4930)
dc441bc7beb is described below

commit dc441bc7bebdff99346c3761b0a5e55904a8bd6d
Author: Jan Høydahl <[email protected]>
AuthorDate: Fri Oct 9 00:11:53 2026 +0200

    SOLR-18472 TikaServerExtractionBackend can hand out a reference to a 
stopped HttpClient (#4930)
---
 .../SOLR-18472-tikaserver-httpclient-refcount.yml  | 10 ++++
 .../extraction/TikaServerExtractionBackend.java    | 39 ++++++-------
 .../TikaServerExtractionBackendTest.java           | 65 ++++++++++++++++++++++
 3 files changed, 93 insertions(+), 21 deletions(-)

diff --git a/changelog/unreleased/SOLR-18472-tikaserver-httpclient-refcount.yml 
b/changelog/unreleased/SOLR-18472-tikaserver-httpclient-refcount.yml
new file mode 100644
index 00000000000..5caf01ba1fa
--- /dev/null
+++ b/changelog/unreleased/SOLR-18472-tikaserver-httpclient-refcount.yml
@@ -0,0 +1,10 @@
+title: >
+  Fixed a race in the Tika Server extraction backend where closing or 
reloading one core could leave
+  another core using a stopped HTTP client.
+type: fixed
+authors:
+  - name: Jan Høydahl
+    nick: janhoy
+links:
+  - name: SOLR-18472
+    url: https://issues.apache.org/jira/browse/SOLR-18472
diff --git 
a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java
 
b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java
index a6d08bb11f7..2c422f7ebfe 100644
--- 
a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java
+++ 
b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java
@@ -33,6 +33,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.util.ExecutorUtil;
 import org.apache.solr.common.util.NamedList;
@@ -67,11 +68,13 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
   private HashMap<String, Object> initArgsMap = new HashMap<>();
   private final long maxCharsLimit;
 
-  // Singleton holder for the shared HttpClient/Executor resources (one per 
JVM)
-  private static volatile RefCounted<HttpClientResources> SHARED_RESOURCES;
+  // Singleton holder for the shared HttpClient/Executor resources (one per 
JVM), guarded by
+  // INIT_LOCK so that acquiring a reference cannot race with the last 
decref() closing it
+  private static RefCounted<HttpClientResources> SHARED_RESOURCES;
   // Per-backend handle (same RefCounted instance as SHARED_RESOURCES) that 
this instance will
   // decref() on close
-  private RefCounted<HttpClientResources> acquiredResourcesRef;
+  private final RefCounted<HttpClientResources> acquiredResourcesRef;
+  private final AtomicBoolean closed = new AtomicBoolean();
 
   public TikaServerExtractionBackend(String baseUrl) {
     this(baseUrl, DEFAULT_TIMEOUT_SECONDS, null, DEFAULT_MAXCHARS_LIMIT);
@@ -117,7 +120,7 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
         Duration.ofSeconds(timeoutSeconds > 0 ? timeoutSeconds : 
DEFAULT_TIMEOUT_SECONDS);
 
     // Acquire a reference to the shared resources; keep a handle so we can 
decref() on close
-    acquiredResourcesRef = initializeHttpClient().incref();
+    acquiredResourcesRef = acquireSharedResources();
   }
 
   public static final String NAME = "tikaserver";
@@ -367,6 +370,7 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
 
     @Override
     protected void close() {
+      assert Thread.holdsLock(INIT_LOCK);
       // stop client and shutdown executor
       try {
         if (resource.client != null) resource.client.stop();
@@ -376,20 +380,16 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
         if (resource.executor != null) resource.executor.shutdownNow();
       } catch (Throwable ignore) {
       }
-      synchronized (INIT_LOCK) {
-        // clear the shared reference when closed
-        if (SHARED_RESOURCES == this) {
-          SHARED_RESOURCES = null;
-        }
+      // clear the shared reference when closed
+      if (SHARED_RESOURCES == this) {
+        SHARED_RESOURCES = null;
       }
     }
   }
 
-  private static RefCounted<HttpClientResources> initializeHttpClient() {
-    RefCounted<HttpClientResources> ref = SHARED_RESOURCES;
-    if (ref != null) return ref;
+  private static RefCounted<HttpClientResources> acquireSharedResources() {
     synchronized (INIT_LOCK) {
-      if (SHARED_RESOURCES != null) return SHARED_RESOURCES;
+      if (SHARED_RESOURCES != null) return SHARED_RESOURCES.incref();
       ThreadFactory tf = new SolrNamedThreadFactory("TikaServerHttpClient");
       ExecutorService exec = ExecutorUtil.newMDCAwareCachedThreadPool(tf);
       HttpClient client = new HttpClient();
@@ -406,7 +406,7 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
             SolrException.ErrorCode.SERVER_ERROR, "Failed to start shared 
Jetty HttpClient", e);
       }
       SHARED_RESOURCES = new ResourcesRef(new HttpClientResources(client, 
exec));
-      return SHARED_RESOURCES;
+      return SHARED_RESOURCES.incref();
     }
   }
 
@@ -447,13 +447,10 @@ public class TikaServerExtractionBackend implements 
ExtractionBackend {
 
   @Override
   public void close() {
-    RefCounted<HttpClientResources> ref;
-    synchronized (INIT_LOCK) {
-      ref = acquiredResourcesRef;
-      acquiredResourcesRef = null;
-    }
-    if (ref != null) {
-      ref.decref();
+    if (closed.compareAndSet(false, true)) {
+      synchronized (INIT_LOCK) {
+        acquiredResourcesRef.decref();
+      }
     }
   }
 }
diff --git 
a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java
 
b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java
index 326ab818596..4505a346fe4 100644
--- 
a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java
+++ 
b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java
@@ -24,10 +24,15 @@ import java.nio.file.Files;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.apache.lucene.tests.util.QuickPatchThreadsFilter;
 import org.apache.solr.SolrIgnoredThreadsFilter;
 import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.ExecutorUtil;
+import org.apache.solr.common.util.SolrNamedThreadFactory;
 import org.apache.solr.handler.extraction.fromtika.ToXMLContentHandler;
 import org.junit.ClassRule;
 import org.junit.Test;
@@ -195,4 +200,64 @@ public class TikaServerExtractionBackendTest extends 
SolrTestCaseJ4 {
       }
     }
   }
+
+  /**
+   * Races the last backend's close() (which stops the shared HttpClient) 
against construction of a
+   * new backend, which must end up with a working client rather than the one 
being stopped.
+   */
+  @Test
+  public void testConcurrentCloseAndAcquireSharedResources() throws Exception {
+    String baseUrl = tikaContainer.getBaseUrl();
+    int iterations = atLeast(25);
+    ExecutorService exec =
+        ExecutorUtil.newMDCAwareFixedThreadPool(2, new 
SolrNamedThreadFactory("tikaRace"));
+    try {
+      for (int i = 0; i < iterations; i++) {
+        TikaServerExtractionBackend last = new 
TikaServerExtractionBackend(baseUrl);
+        CyclicBarrier barrier = new CyclicBarrier(2);
+        Future<?> closer =
+            exec.submit(
+                () -> {
+                  barrier.await();
+                  last.close();
+                  return null;
+                });
+        Future<TikaServerExtractionBackend> opener =
+            exec.submit(
+                () -> {
+                  barrier.await();
+                  return new TikaServerExtractionBackend(baseUrl);
+                });
+        closer.get();
+        try (TikaServerExtractionBackend fresh = opener.get()) {
+          assertExtracts(fresh, "iteration " + i);
+        }
+      }
+    } finally {
+      ExecutorUtil.shutdownAndAwaitTermination(exec);
+    }
+  }
+
+  @Test
+  public void testCloseIsIdempotent() throws Exception {
+    String baseUrl = tikaContainer.getBaseUrl();
+    TikaServerExtractionBackend first = new 
TikaServerExtractionBackend(baseUrl);
+    try (TikaServerExtractionBackend second = new 
TikaServerExtractionBackend(baseUrl)) {
+      first.close();
+      first.close();
+      assertExtracts(second, "after double close");
+    }
+    try (TikaServerExtractionBackend third = new 
TikaServerExtractionBackend(baseUrl)) {
+      assertExtracts(third, "after all closed");
+    }
+  }
+
+  private void assertExtracts(TikaServerExtractionBackend backend, String 
marker) throws Exception {
+    byte[] data = ("Hello " + marker).getBytes(StandardCharsets.UTF_8);
+    try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
+      ExtractionResult res = backend.extract(in, newRequest("test.txt", 
"text/plain", "text"));
+      assertNotNull(res);
+      assertTrue(res.getContent().contains("Hello " + marker));
+    }
+  }
 }

Reply via email to