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

rzo1 pushed a commit to branch opennlp-2.x
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/opennlp-2.x by this push:
     new 2fe42dd99 [2.x]: GH-1506: Make AbstractClassPathModelFinder.findModels 
thread-safe (#1508)
2fe42dd99 is described below

commit 2fe42dd993cc0b74ce12a45976cd67d39f74c1d2
Author: Richard Zowalla <[email protected]>
AuthorDate: Thu Oct 22 09:53:48 2026 +0200

    [2.x]: GH-1506: Make AbstractClassPathModelFinder.findModels thread-safe 
(#1508)
    
    Backport of #1507. The model cache was published before it was filled, and
    callers got the mutable cache itself. Concurrent first calls of a shared
    finder or DefaultClassPathModelProvider could therefore see no models, scan
    the classpath several times, or fail with a ConcurrentModificationException.
    
    findModels now builds the set locally and publishes it as an unmodifiable
    set through a volatile field, and a lock makes concurrent first calls scan
    once. The Javadoc of the finder and provider types states their thread
    safety.
---
 .../tools/models/AbstractClassPathModelFinder.java |  66 +++++--
 .../opennlp/tools/models/ClassPathModelFinder.java |   2 +
 .../tools/models/ClassPathModelProvider.java       |   2 +
 .../models/DefaultClassPathModelProvider.java      |   3 +
 ...bstractClassPathModelFinderConcurrencyTest.java | 212 +++++++++++++++++++++
 5 files changed, 268 insertions(+), 17 deletions(-)

diff --git 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/AbstractClassPathModelFinder.java
 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/AbstractClassPathModelFinder.java
index 2050892c8..1a327ae78 100644
--- 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/AbstractClassPathModelFinder.java
+++ 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/AbstractClassPathModelFinder.java
@@ -22,6 +22,7 @@ import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.List;
@@ -33,6 +34,8 @@ import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.regex.Pattern;
 
+import opennlp.tools.commons.ThreadSafe;
+
 /**
  * A base implementation of a {@link ClassPathModelFinder} for the detection of
  * OpenNLP models in the classpath. By default, {@link 
AbstractClassPathModelFinder} will scan for
@@ -41,15 +44,22 @@ import java.util.regex.Pattern;
  * This search mask can be adjusted by using the one argument
  * {@link AbstractClassPathModelFinder#AbstractClassPathModelFinder(String) 
constructor}.
  * Wildcard search is supported by using asterisk symbol.
+ * <p>
+ * {@link #findModels(boolean)} may be called from several threads. Concurrent 
first calls scan
+ * the classpath once, and every caller receives the complete, unmodifiable 
set of models.
+ * Subclasses must keep {@link #getContext()} and {@link 
#getMatchingURIs(String, Object)} free
+ * of shared mutable state.
  *
  * @see ClassPathModelFinder
  */
+@ThreadSafe
 public abstract class AbstractClassPathModelFinder implements 
ClassPathModelFinder {
 
   protected static final String JAR = "jar";
 
   private final String jarModelPrefix;
-  private Set<ClassPathModelEntry> models;
+  private final Object scanLock = new Object();
+  private volatile Set<ClassPathModelEntry> models;
 
   /**
    * By default, it scans for {@link #OPENNLP_MODEL_JAR_PREFIX}.
@@ -67,28 +77,50 @@ public abstract class AbstractClassPathModelFinder 
implements ClassPathModelFind
     this.jarModelPrefix = jarModelPrefix;
   }
 
+  /**
+   * {@inheritDoc}
+   *
+   * <p>The result is cached; concurrent first calls scan the classpath once. 
The returned set
+   * is unmodifiable and does not change when the cache is reloaded.</p>
+   */
   @Override
   public Set<ClassPathModelEntry> findModels(boolean reloadCache) {
+    Set<ClassPathModelEntry> found = this.models;
+    if (found != null && !reloadCache) {
+      return found;
+    }
+    synchronized (scanLock) {
+      found = this.models;
+      if (found == null || reloadCache) {
+        found = scanModels();
+        this.models = found;
+      }
+      return found;
+    }
+  }
 
-    if (this.models == null || reloadCache) {
-      final List<URI> classpathModels = getMatchingURIs("*.bin", getContext());
-      final List<URI> classPathProperties = 
getMatchingURIs("model.properties", getContext());
-
-      this.models = new HashSet<>();
-
-      for (URI model : classpathModels) {
-        URI m = null;
-        for (URI prop : classPathProperties) {
-          if (jarPathsMatch(model, prop)) {
-            m = prop;
-            break;
-          }
+  /**
+   * Scans the classpath for models and pairs each model with the {@code 
model.properties} of
+   * its jar.
+   *
+   * @return The unmodifiable set of model entries. It may be empty.
+   */
+  private Set<ClassPathModelEntry> scanModels() {
+    final List<URI> classpathModels = getMatchingURIs("*.bin", getContext());
+    final List<URI> classPathProperties = getMatchingURIs("model.properties", 
getContext());
+
+    final Set<ClassPathModelEntry> found = new HashSet<>();
+    for (URI model : classpathModels) {
+      URI m = null;
+      for (URI prop : classPathProperties) {
+        if (jarPathsMatch(model, prop)) {
+          m = prop;
+          break;
         }
-        this.models.add(new ClassPathModelEntry(model, 
Optional.ofNullable(m)));
-
       }
+      found.add(new ClassPathModelEntry(model, Optional.ofNullable(m)));
     }
-    return this.models;
+    return Collections.unmodifiableSet(found);
   }
 
   /**
diff --git 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelFinder.java
 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelFinder.java
index 9e502e4ae..5139db18e 100644
--- 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelFinder.java
+++ 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelFinder.java
@@ -22,6 +22,8 @@ import java.util.Set;
  * Describes a scanner which detects OpenNLP specific model files in an 
applications's classpath.
  * If compatible models are present at runtime, each discovered element is 
made available
  * as {@link ClassPathModelEntry}.
+ * <p>
+ * Thread safety is implementation specific.
  */
 public interface ClassPathModelFinder {
 
diff --git 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelProvider.java
 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelProvider.java
index f21b86ea3..02679f8e8 100644
--- 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelProvider.java
+++ 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/ClassPathModelProvider.java
@@ -28,6 +28,8 @@ import opennlp.tools.util.model.BaseModel;
  * Providing models available in the classpath requires a {@link 
ClassPathModelFinder finder}
  * and a {@link ClassPathModelLoader} at runtime. Depending on the environment,
  * a custom {@code finder} can be used.
+ * <p>
+ * Thread safety is implementation specific.
  *
  * @see BaseModel
  * @see ClassPathModelFinder
diff --git 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/DefaultClassPathModelProvider.java
 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/DefaultClassPathModelProvider.java
index ee923e6cf..21de00155 100644
--- 
a/opennlp-tools-models/src/main/java/opennlp/tools/models/DefaultClassPathModelProvider.java
+++ 
b/opennlp-tools-models/src/main/java/opennlp/tools/models/DefaultClassPathModelProvider.java
@@ -25,6 +25,9 @@ import opennlp.tools.util.model.BaseModel;
 /**
  * A default implementation of {@link ClassPathModelProvider} which by default 
relies on
  * {@link SimpleClassPathModelFinder} to scan for models in the current 
classpath.
+ * <p>
+ * An instance can be shared between threads if its {@link 
ClassPathModelFinder} is thread-safe,
+ * as the finders based on {@link AbstractClassPathModelFinder} are.
  *
  * @see ClassPathModelFinder
  * @see ClassPathModelLoader
diff --git 
a/opennlp-tools-models/src/test/java/opennlp/tools/models/AbstractClassPathModelFinderConcurrencyTest.java
 
b/opennlp-tools-models/src/test/java/opennlp/tools/models/AbstractClassPathModelFinderConcurrencyTest.java
new file mode 100644
index 000000000..a12584e7f
--- /dev/null
+++ 
b/opennlp-tools-models/src/test/java/opennlp/tools/models/AbstractClassPathModelFinderConcurrencyTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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 opennlp.tools.models;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that {@link AbstractClassPathModelFinder#findModels(boolean)} can be 
called from several
+ * threads.
+ */
+class AbstractClassPathModelFinderConcurrencyTest {
+
+  private static final int THREADS = 8;
+
+  private static final long TIMEOUT_SECONDS = 10;
+
+  private static final List<URI> MODELS = List.of(
+      
URI.create("jar:file:/repo/opennlp-models-sentdetect-en.jar!/en-sent.bin"),
+      
URI.create("jar:file:/repo/opennlp-models-tokenizer-en.jar!/en-token.bin"),
+      URI.create("jar:file:/repo/opennlp-models-pos-en.jar!/en-pos.bin"));
+
+  private static final URI OTHER_MODEL =
+      
URI.create("jar:file:/repo/opennlp-models-langdetect.jar!/langdetect.bin");
+
+  /**
+   * Concurrent first calls scan once, and every caller gets the complete set, 
although all of
+   * them call while the first scan is still running.
+   */
+  @Test
+  void testConcurrentFirstCallsScanOnceAndReturnAllModels() throws 
InterruptedException {
+    final CountDownLatch release = new CountDownLatch(1);
+    final ScanCountingFinder finder = new ScanCountingFinder(MODELS, release);
+    final List<Set<ClassPathModelEntry>> results = new ArrayList<>();
+    final List<Thread> threads = new ArrayList<>();
+    for (int i = 0; i < THREADS; i++) {
+      final Thread thread = new Thread(() -> {
+        final Set<ClassPathModelEntry> found = finder.findModels(false);
+        synchronized (results) {
+          results.add(found);
+        }
+      });
+      threads.add(thread);
+      thread.start();
+    }
+
+    awaitAllWaiting(threads);
+    release.countDown();
+    for (Thread thread : threads) {
+      thread.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    }
+
+    assertEquals(1, finder.scans());
+    assertEquals(THREADS, results.size());
+    for (Set<ClassPathModelEntry> found : results) {
+      assertEquals(entries(MODELS), found);
+    }
+  }
+
+  /** The returned set is not the cache itself, so callers cannot change it. */
+  @Test
+  void testReturnedModelsAreUnmodifiable() {
+    final ScanCountingFinder finder = new ScanCountingFinder(MODELS, 
released());
+    final Set<ClassPathModelEntry> found = finder.findModels(false);
+    final ClassPathModelEntry other = new ClassPathModelEntry(OTHER_MODEL, 
Optional.empty());
+    assertThrows(UnsupportedOperationException.class, () -> found.add(other));
+  }
+
+  /** A reload scans again and replaces the cache; a set returned earlier 
keeps its content. */
+  @Test
+  void testReloadReplacesTheCacheAndKeepsEarlierResults() {
+    final ScanCountingFinder finder = new ScanCountingFinder(MODELS, 
released());
+    final Set<ClassPathModelEntry> first = finder.findModels(false);
+    assertSame(first, finder.findModels(false));
+
+    finder.setModels(List.of(OTHER_MODEL));
+    final Set<ClassPathModelEntry> reloaded = finder.findModels(true);
+
+    assertEquals(2, finder.scans());
+    assertEquals(entries(MODELS), first);
+    assertEquals(entries(List.of(OTHER_MODEL)), reloaded);
+    assertSame(reloaded, finder.findModels(false));
+  }
+
+  /**
+   * Waits until every thread waits, either for the scan to be released or for 
the scan lock.
+   *
+   * @param threads The threads that call the finder.
+   * @throws InterruptedException Thrown if the test thread is interrupted.
+   */
+  private static void awaitAllWaiting(List<Thread> threads) throws 
InterruptedException {
+    final long deadline = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS);
+    for (Thread thread : threads) {
+      while (!isWaiting(thread.getState())) {
+        assertTrue(System.nanoTime() < deadline, thread.getName() + " did not 
reach the finder");
+        Thread.sleep(1);
+      }
+    }
+  }
+
+  /**
+   * {@return whether a thread in {@code state} waits for a latch or a lock}
+   *
+   * @param state The state of a thread.
+   */
+  private static boolean isWaiting(Thread.State state) {
+    return state == Thread.State.WAITING || state == Thread.State.TIMED_WAITING
+        || state == Thread.State.BLOCKED;
+  }
+
+  /** {@return a latch that is already released} */
+  private static CountDownLatch released() {
+    return new CountDownLatch(0);
+  }
+
+  /**
+   * {@return the entries the finder creates for {@code models}, which have no 
properties}
+   *
+   * @param models The model {@link URI URIs}.
+   */
+  private static Set<ClassPathModelEntry> entries(List<URI> models) {
+    final Set<ClassPathModelEntry> entries = new HashSet<>();
+    for (URI model : models) {
+      entries.add(new ClassPathModelEntry(model, Optional.empty()));
+    }
+    return entries;
+  }
+
+  /**
+   * A finder that counts its scans and holds each scan until a latch is 
released.
+   */
+  private static final class ScanCountingFinder extends 
AbstractClassPathModelFinder {
+
+    private final AtomicInteger scans = new AtomicInteger();
+    private final CountDownLatch release;
+    private volatile List<URI> models;
+
+    /**
+     * @param models The model {@link URI URIs} a scan finds.
+     * @param release The latch every scan waits for.
+     */
+    ScanCountingFinder(List<URI> models, CountDownLatch release) {
+      this.models = models;
+      this.release = release;
+    }
+
+    /**
+     * @param models The model {@link URI URIs} the next scan finds.
+     */
+    void setModels(List<URI> models) {
+      this.models = models;
+    }
+
+    /** {@return the number of scans so far} */
+    int scans() {
+      return scans.get();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Object getContext() {
+      return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<URI> getMatchingURIs(String wildcardPattern, Object 
context) {
+      if (!"*.bin".equals(wildcardPattern)) {
+        return List.of();
+      }
+      scans.incrementAndGet();
+      try {
+        if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+          throw new IllegalStateException("the scan was not released");
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IllegalStateException(e);
+      }
+      return models;
+    }
+  }
+}

Reply via email to