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

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


The following commit(s) were added to refs/heads/main by this push:
     new 4f22baa93 GH-1464: Release the per-thread stemmer state of a 
TermAnalyzer and reuse the matching analyzer (#1465)
4f22baa93 is described below

commit 4f22baa93eef99fc34ba7e001e6731e4cb57847b
Author: Richard Zowalla <[email protected]>
AuthorDate: Sun Oct 11 18:30:22 2026 +0200

    GH-1464: Release the per-thread stemmer state of a TermAnalyzer and reuse 
the matching analyzer (#1465)
---
 .../util/normalizer/NormalizationProfile.java      | 112 +++++++++++++++---
 .../tools/util/normalizer/TermAnalyzer.java        |  19 ++++
 .../util/normalizer/NormalizationProfilesTest.java |  52 +++++++++
 .../TermAnalyzerThreadLocalStateTest.java          | 126 +++++++++++++++++++++
 4 files changed, 292 insertions(+), 17 deletions(-)

diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
index 5f0d1c855..0de2c038f 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
@@ -16,6 +16,8 @@
  */
 package opennlp.tools.util.normalizer;
 
+import java.util.Objects;
+
 import opennlp.tools.stemmer.Stemmer;
 import opennlp.tools.stemmer.StemmerFactory;
 import opennlp.tools.stemmer.snowball.SnowballStemmer;
@@ -37,26 +39,59 @@ import opennlp.tools.util.ParamChecks;
  * is a search-recall choice, not a statement of linguistic correctness; 
callers can build a
  * {@link TermAnalyzer} directly to override it.</p>
  *
- * @param language         The language, as an ISO 639-3 code (for example 
{@code "eng"}). Must
- *                         not be {@code null} or blank.
- * @param stemmerAlgorithm The Snowball algorithm for the language. Must not 
be {@code null}.
- * @param accentFold       The diacritic fold for the language, or {@code 
null} for none.
+ * <p>A profile is immutable and thread-safe. Two profiles are equal when 
their language, stemmer
+ * algorithm and diacritic fold are equal.</p>
  */
-public record NormalizationProfile(String language, SnowballStemmer.ALGORITHM 
stemmerAlgorithm,
-    CharSequenceNormalizer accentFold) {
+public final class NormalizationProfile {
+
+  private final String language;
+  private final SnowballStemmer.ALGORITHM stemmerAlgorithm;
+  private final CharSequenceNormalizer accentFold;
+
+  /** The matching analyzer, built on the first call to {@link 
#matchingAnalyzer()}. */
+  private volatile TermAnalyzer matchingAnalyzer;
 
   /**
-   * Validates the components.
+   * Creates a profile.
    *
+   * @param language         The language, as an ISO 639-3 code (for example 
{@code "eng"}). Must
+   *                         not be {@code null} or blank.
+   * @param stemmerAlgorithm The Snowball algorithm for the language. Must not 
be {@code null}.
+   * @param accentFold       The diacritic fold for the language, or {@code 
null} for none.
    * @throws IllegalArgumentException if {@code language} or {@code 
stemmerAlgorithm} is
    *     {@code null}, or if {@code language} is blank.
    */
-  public NormalizationProfile {
+  public NormalizationProfile(String language, SnowballStemmer.ALGORITHM 
stemmerAlgorithm,
+      CharSequenceNormalizer accentFold) {
     ParamChecks.requireNonNullArg(language, "language");
     ParamChecks.requireNonNullArg(stemmerAlgorithm, "stemmerAlgorithm");
     if (language.isBlank()) {
       throw new IllegalArgumentException("language must not be blank");
     }
+    this.language = language;
+    this.stemmerAlgorithm = stemmerAlgorithm;
+    this.accentFold = accentFold;
+  }
+
+  /**
+   * {@return the language, as an ISO 639-3 code}
+   */
+  public String language() {
+    return language;
+  }
+
+  /**
+   * {@return the Snowball algorithm for the language}
+   */
+  public SnowballStemmer.ALGORITHM stemmerAlgorithm() {
+    return stemmerAlgorithm;
+  }
+
+  /**
+   * {@return the diacritic fold for the language, or {@code null} for none}
+   */
+  public CharSequenceNormalizer accentFold() {
+    return accentFold;
   }
 
   /**
@@ -77,19 +112,62 @@ public record NormalizationProfile(String language, 
SnowballStemmer.ALGORITHM st
   }
 
   /**
-   * Returns a matching analyzer for this language: NFC, case folding, the 
language's
-   * {@linkplain #accentFold() diacritic fold} when it has one, then stemming. 
The returned
-   * analyzer is thread-safe, and repeated words resolve from a bounded 
per-thread stem cache
-   * instead of being re-stemmed. The cache is keyed to the thread, so build 
the analyzer once
-   * and reuse it from threads that live across many calls, such as a fixed 
platform-thread pool.
+   * Returns the matching analyzer for this language: NFC, case folding, the 
language's
+   * {@linkplain #accentFold() diacritic fold} when it has one, then stemming. 
The analyzer is
+   * built on the first call, and every call on this profile returns the same 
instance. It is
+   * thread-safe, and repeated words resolve from a bounded per-thread stem 
cache instead of being
+   * re-stemmed; the cache pays off on threads that live across many calls, 
such as a fixed
+   * platform-thread pool. Call {@link TermAnalyzer#clearThreadLocalState()} 
when a pooled thread
+   * no longer uses the analyzer.
    *
    * @return the analyzer.
    */
   public TermAnalyzer matchingAnalyzer() {
-    final TermAnalyzer.Builder builder = 
TermAnalyzer.builder().nfc().caseFold();
-    if (accentFold != null) {
-      builder.transform(Dimension.ACCENT_FOLD, accentFold);
+    TermAnalyzer analyzer = matchingAnalyzer;
+    if (analyzer == null) {
+      synchronized (this) {
+        analyzer = matchingAnalyzer;
+        if (analyzer == null) {
+          final TermAnalyzer.Builder builder = 
TermAnalyzer.builder().nfc().caseFold();
+          if (accentFold != null) {
+            builder.transform(Dimension.ACCENT_FOLD, accentFold);
+          }
+          analyzer = builder.stem(stemmerFactory()).build();
+          matchingAnalyzer = analyzer;
+        }
+      }
     }
-    return builder.stem(stemmerFactory()).build();
+    return analyzer;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    return obj instanceof NormalizationProfile other
+        && language.equals(other.language)
+        && stemmerAlgorithm == other.stemmerAlgorithm
+        && Objects.equals(accentFold, other.accentFold);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public int hashCode() {
+    return Objects.hash(language, stemmerAlgorithm, accentFold);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public String toString() {
+    return "NormalizationProfile[language=" + language + ", stemmerAlgorithm=" 
+ stemmerAlgorithm
+        + ", accentFold=" + accentFold + "]";
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
index 02fb1a0ae..2c6faa895 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
@@ -50,6 +50,10 @@ import opennlp.tools.util.Span;
  * the factory mints stateful stemmers, and repeated words resolve from a 
bounded per-thread cache
  * instead of being re-stemmed. A raw {@link Stemmer} passed to {@link 
Builder#stem(Stemmer)} is
  * only safe when that stemmer is itself thread-safe or the analyzer is 
confined to one thread.</p>
+ *
+ * <p>Long-running environments such as application containers should call
+ * {@link #clearThreadLocalState()} when a pooled thread no longer uses an 
analyzer whose stemmer is
+ * a {@link CachingStemmer}.</p>
  */
 public final class TermAnalyzer {
 
@@ -145,6 +149,21 @@ public final class TermAnalyzer {
     return chain;
   }
 
+  /**
+   * Removes the calling thread's state of the analyzer's stemmer to prevent 
classloader leaks in
+   * container environments. Call when the thread is returned to a pool or the 
analyzer is no
+   * longer needed. The state is held when the stemmer is a {@link 
CachingStemmer}, whether created
+   * by {@link Builder#stem(StemmerFactory)} or passed to {@link 
Builder#stem(Stemmer)}; for any
+   * other stemmer, or without one, this method has no effect.
+   *
+   * @see CachingStemmer#clearThreadLocalState()
+   */
+  public void clearThreadLocalState() {
+    if (stemmer instanceof CachingStemmer caching) {
+      caching.clearThreadLocalState();
+    }
+  }
+
   /**
    * {@return the last configured dimension in pipeline order, or {@link 
Dimension#ORIGINAL} when
    * none are configured} This is the layer {@link Term#normalized()} reports.
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
index f344af918..0eead3d61 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
@@ -16,6 +16,7 @@
  */
 package opennlp.tools.util.normalizer;
 
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
@@ -26,6 +27,7 @@ import java.util.stream.Collectors;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import opennlp.tools.langdetect.Language;
@@ -33,6 +35,7 @@ import opennlp.tools.langdetect.LanguageDetector;
 import opennlp.tools.stemmer.snowball.SnowballStemmer;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -235,4 +238,53 @@ public class NormalizationProfilesTest {
       }
     }
   }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"eng", "deu", "swe", "tur"})
+  void testMatchingAnalyzerIsBuiltOncePerProfile(String language) {
+    final NormalizationProfile profile = 
NormalizationProfiles.forLanguage(language).orElseThrow();
+    assertSame(profile.matchingAnalyzer(), profile.matchingAnalyzer());
+  }
+
+  @Test
+  void testMatchingAnalyzerIsSharedAcrossThreads() throws Exception {
+    final NormalizationProfile profile =
+        new NormalizationProfile("eng", SnowballStemmer.ALGORITHM.ENGLISH, 
null);
+    try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+      final List<Future<TermAnalyzer>> tasks = new ArrayList<>();
+      for (int i = 0; i < 32; i++) {
+        tasks.add(pool.submit(profile::matchingAnalyzer));
+      }
+      final TermAnalyzer first = tasks.get(0).get(30, 
java.util.concurrent.TimeUnit.SECONDS);
+      for (final Future<TermAnalyzer> task : tasks) {
+        assertSame(first, task.get(30, java.util.concurrent.TimeUnit.SECONDS));
+      }
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"eng, Cats, cat", "eng, Running, run", "deu, Häuser, haus", 
"fra, Écoles, ecol"})
+  void testReusedMatchingAnalyzerStillMatches(String language, String word, 
String expected) {
+    final NormalizationProfile profile = 
NormalizationProfiles.forLanguage(language).orElseThrow();
+    final TermAnalyzer analyzer = profile.matchingAnalyzer();
+    final String first = analyzer.analyze(word).get(0).normalized();
+    analyzer.clearThreadLocalState();
+    assertEquals(first, 
profile.matchingAnalyzer().analyze(word).get(0).normalized());
+    assertEquals(expected, first);
+  }
+
+  @Test
+  void testProfilesAreEqualByComponents() {
+    final NormalizationProfile a = new NormalizationProfile("eng", 
SnowballStemmer.ALGORITHM.ENGLISH,
+        AccentFoldCharSequenceNormalizer.getInstance());
+    final NormalizationProfile b = new NormalizationProfile("eng", 
SnowballStemmer.ALGORITHM.ENGLISH,
+        AccentFoldCharSequenceNormalizer.getInstance());
+    a.matchingAnalyzer();
+    assertEquals(a, b);
+    assertEquals(a.hashCode(), b.hashCode());
+    assertEquals(a, NormalizationProfiles.forLanguage("eng").orElseThrow());
+    assertNotEquals(a, new NormalizationProfile("eng", 
SnowballStemmer.ALGORITHM.PORTER,
+        AccentFoldCharSequenceNormalizer.getInstance()));
+    assertNotEquals(a, new NormalizationProfile("eng", 
SnowballStemmer.ALGORITHM.ENGLISH, null));
+  }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerThreadLocalStateTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerThreadLocalStateTest.java
new file mode 100644
index 000000000..3671ff768
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerThreadLocalStateTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.util.normalizer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.stemmer.CachingStemmer;
+import opennlp.tools.stemmer.PorterStemmer;
+import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.StemmerFactory;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TermAnalyzerThreadLocalStateTest {
+
+  /** A factory that counts the delegates it mints and the words they stem. */
+  private static final class CountingFactory implements StemmerFactory {
+
+    private final AtomicInteger minted = new AtomicInteger();
+    private final AtomicInteger stemmed = new AtomicInteger();
+
+    @Override
+    public Stemmer newStemmer() {
+      minted.incrementAndGet();
+      return word -> {
+        stemmed.incrementAndGet();
+        return word.toString() + "-s";
+      };
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"dog", "cat", "running"})
+  void testClearReleasesAWorkerThreadsDelegate(String word) throws Exception {
+    final CountingFactory factory = new CountingFactory();
+    final TermAnalyzer analyzer = TermAnalyzer.builder().stem(factory).build();
+    // The first stemming thread owns a retained state; a pool thread holds 
per-thread state.
+    analyzer.analyze(word);
+    final ExecutorService pool = Executors.newSingleThreadExecutor();
+    try {
+      pool.submit(() -> analyzer.analyze(word)).get();
+      final int beforeClear = factory.minted.get();
+      pool.submit(() -> analyzer.analyze(word)).get();
+      assertEquals(beforeClear, factory.minted.get(), "the worker reuses its 
delegate");
+
+      pool.submit(analyzer::clearThreadLocalState).get();
+      final List<Term> terms = pool.submit(() -> analyzer.analyze(word)).get();
+      assertEquals(word + "-s", terms.get(0).normalized());
+      assertEquals(beforeClear + 1, factory.minted.get(),
+          "a fresh delegate is minted after the clear");
+    } finally {
+      pool.shutdownNow();
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"dog", "cat", "running"})
+  void testClearEmptiesTheOwnerThreadsCache(String word) {
+    final CountingFactory factory = new CountingFactory();
+    final TermAnalyzer analyzer = TermAnalyzer.builder().stem(factory, 
16).build();
+    analyzer.analyze(word);
+    analyzer.analyze(word);
+    assertEquals(1, factory.stemmed.get(), "the second lookup is served from 
the cache");
+
+    analyzer.clearThreadLocalState();
+    assertEquals(word + "-s", analyzer.analyze(word).get(0).normalized());
+    assertEquals(2, factory.stemmed.get(), "the clear emptied the cache");
+  }
+
+  @Test
+  void testClearReleasesAPassedCachingStemmer() throws Exception {
+    final CountingFactory factory = new CountingFactory();
+    final TermAnalyzer analyzer =
+        TermAnalyzer.builder().stem(new CachingStemmer(factory)).build();
+    analyzer.analyze("dog");
+    final ExecutorService pool = Executors.newSingleThreadExecutor();
+    try {
+      pool.submit(() -> analyzer.analyze("dog")).get();
+      final int beforeClear = factory.minted.get();
+      pool.submit(analyzer::clearThreadLocalState).get();
+      pool.submit(() -> analyzer.analyze("dog")).get();
+      assertEquals(beforeClear + 1, factory.minted.get());
+    } finally {
+      pool.shutdownNow();
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"Running", "Cats", "jumped"})
+  void testClearIsANoOpWithoutACachingStemmer(String word) {
+    final List<TermAnalyzer> analyzers = new ArrayList<>();
+    analyzers.add(TermAnalyzer.builder().caseFold().build());
+    analyzers.add(TermAnalyzer.builder().caseFold().stem(new 
PorterStemmer()).build());
+    analyzers.add(TermAnalyzer.builder().caseFold()
+        .stem(new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH)).build());
+    for (final TermAnalyzer analyzer : analyzers) {
+      final String before = analyzer.analyze(word).get(0).normalized();
+      assertDoesNotThrow(analyzer::clearThreadLocalState);
+      assertEquals(before, analyzer.analyze(word).get(0).normalized());
+    }
+  }
+}

Reply via email to