Skip to content

Commit bfeb0bd

Browse files
committed
OPENNLP-1883: Address review: concise contracts, shared thread routing, cache controls
The Stemmer and StemmerFactory interfaces state their contracts without implementation or threading claims, and null words throw IllegalArgumentException across the API (Snowball, factory products, caching and sharing wrappers, Porter), pinned by tests. SnowballStemmer now composes SharingStemmer so the per-thread routing pattern lives in one class. CachingStemmer gains clearCache() for the calling thread, interns cached stems through StringInterners, and both stateful classes document the container release path of the *ME components.
1 parent b76eef4 commit bfeb0bd

7 files changed

Lines changed: 89 additions & 58 deletions

File tree

opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* this work for additional information regarding copyright ownership.
55
* The ASF licenses this file to You under the Apache License, Version 2.0
66
* (the "License"); you may not use this file except in compliance with
7-
* the License. You may obtain a copy of the License at
7+
* the License. You may obtain a copy of the License at
88
*
99
* http://www.apache.org/licenses/LICENSE-2.0
1010
*
@@ -22,12 +22,7 @@
2222
/**
2323
* Reduces a word to its root form.
2424
*
25-
* <p>Thread safety is implementation-specific: check the implementation's documentation before
26-
* sharing an instance across threads. Stateful engines mutate internal buffers on each
27-
* {@link #stem(CharSequence)} call; when an implementation is not documented as thread-safe,
28-
* share a {@link StemmerFactory} across threads and confine each {@code Stemmer} to one thread,
29-
* or wrap the factory in a thread-local adapter when a single {@code Stemmer} reference must be
30-
* shared.</p>
25+
* <p>Thread safety is implementation specific.</p>
3126
*/
3227
public interface Stemmer {
3328

@@ -36,16 +31,17 @@ public interface Stemmer {
3631
*
3732
* @param word The input word. Must not be {@code null}.
3833
* @return The stemmed form.
34+
* @throws IllegalArgumentException Thrown if {@code word} is {@code null}.
3935
*/
4036
CharSequence stem(CharSequence word);
4137

4238
/**
43-
* {@return every stem form for {@code word}} The default returns a single-element list with
44-
* {@link #stem(CharSequence)}. Dictionary-based engines that can produce several root forms for
45-
* one word override this; wrappers that delegate to another {@code Stemmer} must forward this
46-
* method so a multi-output delegate keeps its full result list.
39+
* {@return all stem forms for {@code word}} Defaults to a single-element list from
40+
* {@link #stem(CharSequence)}. Dictionary-based engines may override this to return
41+
* multiple roots; delegating wrappers must forward it to preserve the full list.
4742
*
4843
* @param word The input word. Must not be {@code null}.
44+
* @throws IllegalArgumentException Thrown if {@code word} is {@code null}.
4945
*/
5046
default List<CharSequence> stemAll(CharSequence word) {
5147
return List.of(stem(word));

opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,18 @@
1818
package opennlp.tools.stemmer;
1919

2020
/**
21-
* An immutable, thread-safe factory for {@link Stemmer} instances.
22-
*
23-
* <p>Stateful stemming engines hold per-call mutable buffers and must not be shared across
24-
* threads. A {@code StemmerFactory} captures the configuration (algorithm, repeat count,
25-
* dictionary path, ...) and mints a fresh {@link Stemmer} on each {@link #newStemmer()} call. The
26-
* factory itself is safe to share; confine each returned {@link Stemmer} to a single thread, or
27-
* route through a thread-local adapter when a component must be shared.</p>
21+
* A factory for {@link Stemmer} instances: it captures a stemmer configuration (algorithm,
22+
* repeat count, dictionary path, ...) once and mints configured stemmers on demand.
2823
*
2924
* <p>Despite the name, this is not one of the {@code BaseToolFactory}-based tool factories (such
3025
* as {@code LemmatizerFactory}) that are instantiated by name from a model manifest. It is a
31-
* plain functional supplier of configured {@link Stemmer} instances and takes no part in the
32-
* model-loading mechanism.</p>
33-
*
34-
* <p>Implementations must be immutable and thread-safe after construction.</p>
26+
* plain supplier of configured {@link Stemmer} instances and takes no part in the model-loading
27+
* mechanism.</p>
3528
*/
3629
public interface StemmerFactory {
3730

3831
/**
39-
* {@return a new {@link Stemmer} confined to the calling thread} Implementations must return a
40-
* distinct instance on each call when the underlying engine is stateful.
32+
* {@return a new {@link Stemmer}}
4133
*/
4234
Stemmer newStemmer();
4335
}

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import opennlp.tools.commons.ThreadSafe;
2525
import opennlp.tools.util.OwnerOrPerThreadState;
26+
import opennlp.tools.util.jvm.StringInterners;
2627

2728
/**
2829
* A {@link Stemmer} that memoizes word-to-stem mappings in a bounded per-thread LRU cache.
@@ -40,8 +41,10 @@
4041
* <p>Because the cache is keyed to the thread, the memoization pays off on threads that are
4142
* reused across many words, such as a fixed platform-thread pool. On a virtual-thread-per-task
4243
* executor every task starts with an empty cache, so repeats are only served within one task.
43-
* Call {@link #clearThreadLocalState()} before returning a pooled thread that should not retain
44-
* its delegate and cache.</p>
44+
* As with the thread-safe {@code *ME} components, long-running environments such as application
45+
* containers should call {@link #clearThreadLocalState()} when a pooled thread no longer uses
46+
* this stemmer; otherwise the thread retains its delegate and cache until the instance is
47+
* unreachable, which can pin the defining classloader on redeploys.</p>
4548
*
4649
* <p>{@link #stemAll(CharSequence)} is forwarded to the delegate uncached, so multi-output
4750
* engines keep their full result list.</p>
@@ -89,14 +92,18 @@ public CachingStemmer(StemmerFactory factory, int capacity) {
8992

9093
@Override
9194
public CharSequence stem(CharSequence word) {
95+
if (word == null) {
96+
throw new IllegalArgumentException("word must not be null");
97+
}
9298
final ThreadState ts = state.get();
9399
final String key = word.toString();
94100
final String cached = ts.cache.get(key);
95101
if (cached != null) {
96102
return cached;
97103
}
98-
// toString() detaches the result from any buffer the delegate may reuse internally.
99-
final String stemmed = ts.delegate.stem(key).toString();
104+
// toString() detaches the result from any buffer the delegate may reuse internally, and
105+
// interning stores one instance per distinct stem across all threads' caches.
106+
final String stemmed = StringInterners.intern(ts.delegate.stem(key).toString());
100107
ts.cache.put(key, stemmed);
101108
return stemmed;
102109
}
@@ -115,6 +122,14 @@ public void clearThreadLocalState() {
115122
state.clearForCurrentThread();
116123
}
117124

125+
/**
126+
* Empties the calling thread's cache while keeping its delegate, forcing every subsequent
127+
* word through a fresh stemming pass. Only the calling thread's cache is affected.
128+
*/
129+
public void clearCache() {
130+
state.get().cache.clear();
131+
}
132+
118133
private static final class ThreadState {
119134

120135
private final Stemmer delegate;

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,9 @@ public String stem(String s) {
599599
* Returns the result as a CharSequence.
600600
*/
601601
public CharSequence stem(CharSequence word) {
602+
if (word == null) {
603+
throw new IllegalArgumentException("word must not be null");
604+
}
602605
return stem(word.toString());
603606
}
604607

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,26 @@
2020
import java.util.function.Supplier;
2121

2222
import opennlp.tools.commons.ThreadSafe;
23+
import opennlp.tools.stemmer.SharingStemmer;
2324
import opennlp.tools.stemmer.Stemmer;
24-
import opennlp.tools.util.OwnerOrPerThreadState;
2525

2626
/**
2727
* A {@link Stemmer} backed by the generated Snowball engines.
2828
*
2929
* <p>The generated engines hold mutable per-call buffers, so this class routes each call to a
30-
* per-thread engine via {@link OwnerOrPerThreadState}, the same pattern the thread-safe {@code *ME}
31-
* components use. A single {@code SnowballStemmer} instance is therefore safe to share across
32-
* threads.</p>
30+
* per-thread engine, the same pattern the thread-safe {@code *ME} components use. A single
31+
* {@code SnowballStemmer} instance is therefore safe to share across threads.</p>
32+
*
33+
* <p>As with the {@code *ME} components, long-running environments such as application
34+
* containers should call {@link #clearThreadLocalState()} when a pooled thread no longer uses
35+
* this stemmer; otherwise the thread retains its engine until the instance is unreachable,
36+
* which can pin the defining classloader on redeploys.</p>
3337
*/
3438
@ThreadSafe
3539
public class SnowballStemmer implements Stemmer {
3640

37-
private final OwnerOrPerThreadState<AbstractSnowballStemmer> delegates;
38-
private final int repeat;
41+
// All per-thread routing is delegated, so the pattern lives in exactly one class.
42+
private final SharingStemmer sharing;
3943

4044
/**
4145
* Creates a stemmer for the given algorithm and repeat count.
@@ -46,15 +50,7 @@ public class SnowballStemmer implements Stemmer {
4650
* {@code repeat} is not positive.
4751
*/
4852
public SnowballStemmer(ALGORITHM algorithm, int repeat) {
49-
if (algorithm == null) {
50-
throw new IllegalArgumentException("algorithm must not be null");
51-
}
52-
if (repeat <= 0) {
53-
throw new IllegalArgumentException("repeat must be positive, got " + repeat);
54-
}
55-
this.repeat = repeat;
56-
final Supplier<AbstractSnowballStemmer> engine = engineFor(algorithm);
57-
this.delegates = new OwnerOrPerThreadState<>(engine, stemmer -> { });
53+
this.sharing = new SharingStemmer(new SnowballStemmerFactory(algorithm, repeat));
5854
}
5955

6056
/**
@@ -95,15 +91,7 @@ static Supplier<AbstractSnowballStemmer> engineFor(ALGORITHM algorithm) {
9591

9692
@Override
9793
public CharSequence stem(CharSequence word) {
98-
final AbstractSnowballStemmer stemmer = delegates.get();
99-
100-
stemmer.setCurrent(word.toString());
101-
102-
for (int i = 0; i < repeat; i++) {
103-
stemmer.stem();
104-
}
105-
106-
return stemmer.getCurrent();
94+
return sharing.stem(word);
10795
}
10896

10997
/**
@@ -112,7 +100,7 @@ public CharSequence stem(CharSequence word) {
112100
* {@code clearThreadLocalState()} on the thread-safe {@code *ME} components.
113101
*/
114102
public void clearThreadLocalState() {
115-
delegates.clearForCurrentThread();
103+
sharing.clearThreadLocalState();
116104
}
117105

118106
public enum ALGORITHM {

opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@
2525
* A thread-safe factory that captures a Snowball stemmer configuration for APIs that accept a
2626
* {@link StemmerFactory}.
2727
*
28-
* <p>{@link #newStemmer()} returns a thread-confined stemmer that drives its generated engine
29-
* through a plain field, with none of the per-call thread routing of the shareable
30-
* {@link SnowballStemmer}. This keeps the one-stemmer-per-thread pattern (and per-thread
31-
* delegates inside caching or sharing wrappers) free of that indirection; use
32-
* {@link SnowballStemmer} directly when a single shared instance is wanted instead.</p>
28+
* <p>Use this factory for the classic one-stemmer-per-thread pattern: {@link #newStemmer()}
29+
* returns a plain, thread-confined stemmer without the per-call thread routing of the
30+
* shareable {@link SnowballStemmer}.</p>
3331
*
3432
* @param algorithm The Snowball algorithm. Must not be {@code null}.
3533
* @param repeat How many times to apply the stemmer per word; must be positive.
@@ -81,6 +79,9 @@ private ConfinedStemmer(AbstractSnowballStemmer engine, int repeat) {
8179

8280
@Override
8381
public CharSequence stem(CharSequence word) {
82+
if (word == null) {
83+
throw new IllegalArgumentException("word must not be null");
84+
}
8485
engine.setCurrent(word.toString());
8586
for (int i = 0; i < repeat; i++) {
8687
engine.stem();

opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.concurrent.Executors;
2525
import java.util.concurrent.Future;
2626
import java.util.concurrent.TimeUnit;
27+
import java.util.concurrent.atomic.AtomicInteger;
2728

2829
import org.junit.jupiter.api.Assertions;
2930
import org.junit.jupiter.api.Test;
@@ -328,4 +329,39 @@ void clearThreadLocalStateReleasesAWorkerThreadsDelegate() throws Exception {
328329
snowball.clearThreadLocalState();
329330
Assertions.assertEquals("run", snowball.stem("running").toString());
330331
}
332+
333+
@Test
334+
void nullWordThrowsIllegalArgumentAcrossTheStemmerApi() {
335+
final SnowballStemmerFactory factory =
336+
new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
337+
Assertions.assertThrows(IllegalArgumentException.class,
338+
() -> new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH).stem(null));
339+
Assertions.assertThrows(IllegalArgumentException.class,
340+
() -> factory.newStemmer().stem(null));
341+
Assertions.assertThrows(IllegalArgumentException.class,
342+
() -> new CachingStemmer(factory).stem(null));
343+
Assertions.assertThrows(IllegalArgumentException.class,
344+
() -> new SharingStemmer(factory).stem(null));
345+
Assertions.assertThrows(IllegalArgumentException.class,
346+
() -> new PorterStemmer().stem((CharSequence) null));
347+
}
348+
349+
@Test
350+
void clearCacheForcesRestemmingOnTheCallingThread() {
351+
final AtomicInteger delegateCalls = new AtomicInteger();
352+
final StemmerFactory counting = () -> word -> {
353+
delegateCalls.incrementAndGet();
354+
return word.toString();
355+
};
356+
final CachingStemmer cached = new CachingStemmer(counting);
357+
358+
cached.stem("running");
359+
cached.stem("running");
360+
Assertions.assertEquals(1, delegateCalls.get(), "the repeat is served from the cache");
361+
362+
cached.clearCache();
363+
cached.stem("running");
364+
Assertions.assertEquals(2, delegateCalls.get(),
365+
"after clearCache() the word goes through the delegate again");
366+
}
331367
}

0 commit comments

Comments
 (0)