The OpenNLP factories cache what they load by file name for the whole JVM, so the first configuration that loads a name decides it for every configuration built after it.
- Analysis factories that read files implement
ResourceLoaderAware: whenCustomAnalyzerbuilds an analyzer, it calls each factory'sinform(loader)once, and the factory reads its files through that loader. CustomAnalyzer.builder(Path)makes a loader for one configuration directory andbuilder(ResourceLoader)takes yours. An application with several collections, tenants or languages typically has one configuration, and so one loader, for each.- The OpenNLP factories name their model and dictionary files by resource name, for example
tokenizerModel=tok.binordictionary=lemmas.txt.
lemmas.txt: A's maps dogs to dogA, B's maps dogs to dogB. The JVM-wide cache is keyed by the name alone, so B's analyzer never reads its own file, gets A's dictionary and outputs dogA. Select the still to open it at full size. Generated with AI, checked against the code.- In one JVM you build two analyzers, A and B, each from its own configuration (
CustomAnalyzer.builder(dir)orbuilder(loader)), both withopenNlpLemmatizeranddictionary=lemmas.txt. A's file mapsdogstodogA, B's mapsdogstodogB. - You expect B to turn
dogsintodogB: the builder "loads all resources using the given ResourceLoader", and under a name nobody loaded before, B's file is indeed used. - You get
dogA.OpenNLPOpsFactorykeeps one static map per resource type, keyed only by the file name. B'sinform()findslemmas.txtalready cached and never opens B's file;create()then fetches A's dictionary by name. - Models behave the same: with two directories that each hold a different
tok.bin, B emits A's 8 tokens[Hello, world, ., This, is, a, test, .]instead of its own 6,[Hello, world., This, is, a, test.]. A configuration with nolemmas.txtat all builds and uses A's. - No error, and it lasts until the JVM restarts (or the application calls
clearModels()). Caching by name is deliberate, to share multi-megabyte models, but no javadoc says that names are shared by every configuration in the JVM.
Users of the opt-in OpenNLP module who build analyzers from two or more configurations in one JVM (collections, tenants, per-language directories) with different files under the same name. One configuration per JVM, or identical files, is unaffected. It is silent: wrong terms are indexed and queried until restart (or a clearModels() call), and a configuration missing its file still builds.
Mechanism, terms and numbers650 words
Lucene's OpenNLP analysis factories (the openNlp tokenizer and the openNlppos, openNlpChunker and openNlpLemmatizer filters) load their models and lemma dictionaries through OpenNLPOpsFactory, which keeps seven static maps, one per resource type, keyed only by the resource name. The ResourceLoader a factory receives in inform() is read only when the name is not yet cached; the factory then fetches the object by name in create(). So within one JVM the first configuration that loads a file name decides what every later configuration gets under that name: an analyzer whose own lemmas.txt maps dogs to dogB outputs dogA; with two directories holding different tok.bin models, the second analyzer tokenizes with the first one's model; and a configuration that lacks the file still builds. Nothing raises an error, and it lasts until the JVM restarts or the application calls the public clearModels(). Caching by name is deliberate (LUCENE-2899, to share multi-megabyte models), so this is a documentation defect: no javadoc says that the names form one namespace for the whole JVM, while CustomAnalyzer.builder(Path) says it "loads all resources from the given file system base directory". Present since 7.3.0; affected through 10.6.0 and main.
- ResourceLoader: what an analysis factory reads its files through;
CustomAnalyzer.builder(Path)uses aFilesystemResourceLoaderfor one directory,builder(ResourceLoader)uses the one you pass. - OpenNLP factories:
OpenNLPTokenizerFactory(openNlp, sentence and tokenizer models),OpenNLPPOSFilterFactory(openNlppos),OpenNLPChunkerFilterFactory(openNlpChunker) andOpenNLPLemmatizerFilterFactory(openNlpLemmatizer, a lemma dictionary and/or a lemmatizer model). All live in the opt-inanalysis-opennlpmodule. OpenNLPOpsFactory: the module's model cache. Its class javadoc says only "Supply OpenNLP Named Entity Recognizer Cache model file objects. Assumes model files are thread-safe." (tools/OpenNLPOpsFactory.java:33-36).
OpenNLPOpsFactorykeeps seven static maps (sentence, tokenizer, POS, chunker, NER and lemmatizer models, lemma dictionaries) keyed only by the resource name (:38-48).- Each loader-taking getter reads from the loader only on a miss.
getLemmatizerDictionary(dictionaryFile, loader)(:156-166) doeslemmaDictionaries.get(dictionaryFile); only if that isnulldoes it callloader.openResource(dictionaryFile)and put the result. On a hit the loader is never used. The model getters are the same (:59-69,:80-90,:97-107,:114-124,:131-141,:168-178). - The factories'
inform(loader)only fills the maps ("register models in cache with file/resource names",OpenNLPLemmatizerFilterFactory.java:97), andcreate()fetches by name with no loader (OpenNLPOpsFactory.getLemmatizer(dictionaryFile, lemmatizerModelFile),:88). The tokenizer, POS and chunker factories do the same. No factory keeps the object its own loader produced. clearModels()(:180-188) is the only way to empty the maps; it is public, but nothing in Lucene calls it. So the first object loaded under a name stays for the life of the JVM, unless the application calls it.- With configurations built one after another, as in the repro, the first one decides. The getters do
get, load, thenput, which is not atomic: if two configurations load the same name at the same moment, both read their own file and the lastputwins (the populate race committer magibney noted on PR #380).
- Lemma dictionary: A's
lemmas.txtisdogs<TAB>word<TAB>dogA, B's isdogs<TAB>word<TAB>dogB(the whitespace tokenizer's typewordserves as the POS tag). B's analyzer emitsdogA; A's emitsdogA, as it should. Main with assertions on and off, 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. - Tokenizer model, two real directories via
CustomAnalyzer.builder(Path), each with a validsent.binandtok.bin: B's model keeps periods attached ([Hello, world., This, is, a, test.], 6 tokens, its output with an empty cache); after A is built, B's analyzer emits A's[Hello, world, ., This, is, a, test, .], 8 tokens. 13 releases from 7.3.0 to 10.5.2 and main. - Missing file: a directory without
lemmas.txtthrowsIOExceptionwhen built first, but builds without error, using A's dictionary, once A has loaded that name.
Exposure and likelihood242 words
- Who is exposed. Only processes that build OpenNLP analyzers from more than one configuration, and only when two configurations ship different contents under the same file name: for example a retrained model kept under its old name in a new configuration, or per-language directories that reuse generic names such as
tok.binorlemmas.txt. The Solr reference guide's examples prefix the model names with the language (en-sent.bin,en-tokenizer.bin), which makes model collisions less likely; its three OpenNLP lemmatizer examples, however, name the dictionary plainlemmas.txt, the name this repro uses (and the name in the LUCENE-10171 report), so configurations copied from the guide share the dictionary name. - Silent and lasting. No error and no log line. The second configuration indexes documents with the first one's tokens, tags or lemmas and analyzes its queries the same way, until the JVM restarts or the application calls
clearModels(); the documents keep the wrong terms until reindexed. - Missing files are masked. A configuration without the file builds if another one loaded that name first, and fails if it is built first, so a broken configuration can pass in one process and fail in another, depending on load order.
- Scale. Ordinary: one shared file name and two configurations. No unusual parameter is needed.
- Same pattern, by reading only (not executed):
Word2VecSynonymProviderFactoryin analysis-common keeps a static map keyed by the model file name and reads from the loader only on a miss.
The test shows that of two CustomAnalyzers, each built from its own ResourceLoader with a different lemmas.txt (dogs -> dogA, dogs -> dogB) for openNlpLemmatizer, the second one uses the first one's dictionary, because OpenNLPOpsFactory caches it JVM-wide by file name (undocumented).
/*
* 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.lucene.analysis.opennlp;
import java.io.IOException;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.custom.CustomAnalyzer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
import org.apache.lucene.tests.util.StringMockResourceLoader;
public class TestOpenNLPLemmaDictionarySecondLoader extends BaseTokenStreamTestCase {
public void testSameDictionaryNameFromSecondLoader() throws Exception {
try (Analyzer a = lemmaAnalyzer("dogs\tword\tdogA\n");
Analyzer b = lemmaAnalyzer("dogs\tword\tdogB\n")) {
// b's own lemmas.txt maps dogs to dogB; actual: dogA from a's same-named lemmas.txt
assertAnalyzesTo(b, "dogs", new String[] {"dogB"});
}
}
private static Analyzer lemmaAnalyzer(String lemmasTxt) throws IOException {
return CustomAnalyzer.builder(new StringMockResourceLoader(lemmasTxt))
.withTokenizer("whitespace")
.addTokenFilter("openNlpLemmatizer", "dictionary", "lemmas.txt")
.build();
}
}
# in a copy of this finding's repro folder (repro/LFV-448/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/opennlp/build/libs/lucene-analysis-opennlp-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.analysis.opennlp.TestOpenNLPLemmaDictionarySecondLoader
# the module(s) analysis-opennlp also need their third-party runtime jars
- Expected vs actual
- Expected: the second analyzer lemmatizes "dogs" to dogB (its own file); actual (-ea and -da): dogA ("term 0 expected:<dog[B]> but was:<dog[A]>").
More files, environment, full output40 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: term 0 expected:<dog[B]> but was:<dog[A]>
org.junit.Assert.assertEquals(Assert.java:117)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:573)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:637)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertAnalyzesTo(BaseTokenStreamTestCase.java:792)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertAnalyzesTo(BaseTokenStreamTestCase.java:777)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertAnalyzesTo(BaseTokenStreamTestCase.java:862)
org.apache.lucene.analysis.opennlp.TestOpenNLPLemmaDictionarySecondLoader.testSameDictionaryNameFromSecondLoader(TestOpenNLPLemmaDictionarySecondLoader.java:31)CustomAnalyzer.builder(Path)"loads all resources from the given file system base directory"; for a cached name it does not.- No javadoc, CHANGES or MIGRATE entry says that names are shared by the whole JVM or that a missing file is masked.
- Caching by name is deliberate (LUCENE-2899, 2012), hence a documentation issue; that design comment covers one configuration, not two.
The full argument160 words
- What users read.
CustomAnalyzer.builder(Path): "loads all resources from the given file system base directory" (CustomAnalyzer.java:109-116);builder(ResourceLoader): "loads all resources using the given ResourceLoader" (:118-124);ResourceLoaderAware.inform: "Initializes this component with the provided ResourceLoader" (ResourceLoaderAware.java:29-33; weak on its own, it promises no isolation). - What the module says. The
OpenNLPOpsFactoryclass javadoc mentions a cache of model objects, but not its key, its JVM-wide scope or that the loader is ignored on a hit. The factory javadocs, the module overview and package docs,CHANGES.txt,MIGRATE.mdand the Solr reference guide's OpenNLP section say nothing about it. - The input is legal. Public API, SPI names and valid files; the cross-model reviewer rated the inputs in contract, and no checker argued misuse or a test artifact.
- What kind of defect. The mechanism is intended, so the defect is the missing documentation (a content-keyed cache is an optional follow-up), not a violation of the
ResourceLoaderAwarecontract.
The devil's-advocate check257 words
The intended-behaviour checker built the strongest case for "by design": (1) in LUCENE-2899 (#3973, 2012) the module's contributor wrote "the statistical models are several megabytes apiece. This class loads them and caches them by file name. It does not reload them across commits." (2) Every factory's inform() has a comment saying it registers its models in the cache under their file/resource names ("register models in cache with file/resource names" in the tokenizer and lemmatizer factories; "load and register the read-only model in cache with file/resource name" and "load and register read-only models in cache with file/resource names" in the POS and chunker factories). (3) The public op getters (getSentenceDetector(String), getTokenizer(String), ...) take only a name; upstream's TestOpenNLPSentenceBreakIterator registers through a loader and fetches by name, and Solr's NER update processor does the same. (4) Users rely on sharing across collections: LUCENE-10171 (#11208) reported out-of-memory errors when lemmatizer copies were not shared. (5) Word2VecSynonymProviderFactory (#12169, 2023) reused the pattern.
Why it fails for this claim: it shows that caching by name is deliberate, which is why the claim is a documentation defect and not a code bug. None of it addresses two unrelated configurations: the 2012 comment is about not reloading a changed file under one configuration, it lives in a JIRA comment, and no user-facing document states the JVM-wide scope or the masking of a missing file. Verdict: real as a documentation defect (high confidence that the mechanism is by design and that nothing documents its scope; moderate that upstream accepts a documentation change).
Recommended Document the cache's key and scope (recommended, validated)
Document the cache in the OpenNLPOpsFactory and factory javadocs: models and dictionaries are cached for the whole JVM by resource name, the loader is read only the first time, so each distinct file needs its own name. Validated: the module's 33 tests and its format, lint and javadoc checks pass. A validated content-keyed cache is the optional code follow-up.
* <p><b>NOTE:</b> models and lemma dictionaries are cached in static maps that are shared by the
* whole JVM and keyed only by their resource name. The {@link ResourceLoader} passed to a {@code
* get...Model} method or to {@link #getLemmatizerDictionary} is used only the first time a name is
* requested: a later request for the same name, through any loader, returns the cached object
* without reading the resource. So all analyzers in a JVM that name the same file, for example
* analyzers built from different configuration directories, share the object that was loaded first,
* and a changed or missing file is not noticed. Give each distinct model or dictionary its own
* resource name.All options and trade-offs4 options · 882 words
Two fixes were built and validated through gradle in a scratch worktree of 645d4fe14a20: the documentation change, which upstream is most likely to accept because the cache is deliberate, and a content-keyed cache that removes the cross-configuration effect while keeping the sharing. Two other options were weighed and not recommended.
Javadoc only, +21 lines in 5 files. The OpenNLPOpsFactory class javadoc gets the note below; each of the four factory javadocs gets one sentence: "Model and dictionary files are cached for the whole JVM by resource name, see {@link OpenNLPOpsFactory}."
* <p><b>NOTE:</b> models and lemma dictionaries are cached in static maps that are shared by the
* whole JVM and keyed only by their resource name. The {@link ResourceLoader} passed to a {@code
* get...Model} method or to {@link #getLemmatizerDictionary} is used only the first time a name is
* requested: a later request for the same name, through any loader, returns the cached object
* without reading the resource. So all analyzers in a JVM that name the same file, for example
* analyzers built from different configuration directories, share the object that was loaded first,
* and a changed or missing file is not noticed. Give each distinct model or dictionary its own
* resource name.Trade-offs
Closes the documentation gap only: the cross-configuration effect and the masking of a missing file remain, now documented. Solr users would also need a sentence in the Solr reference guide's OpenNLP section, which is Solr's documentation.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). :lucene:analysis:opennlp:test: 34 tests, 1 failure, the repro, as expected (the fix changes no behaviour); the module's 33 upstream tests pass.checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestandrenderJavadoc: pass.
Every loader-taking getter in OpenNLPOpsFactory goes through one helper that always reads the resource, keys the shared object by type, name and the SHA-256 of the bytes, and still records the first object per name for the public name-only getters. The four factories store what inform(loader) returns and build their ops from it in create() instead of fetching by name. 5 files, +85/-80.
private static <T> T load(Map<String, T> byName, String name, ResourceLoader loader,
Class<T> type, IOFunction<InputStream, T> parser) throws IOException {
byte[] bytes;
try (InputStream resource = loader.openResource(name)) {
bytes = resource.readAllBytes();
}
String key = type.getName() + '/' + name + '/' + sha256(bytes);
T model = type.cast(byContent.get(key));
if (model == null) {
model = parser.apply(new ByteArrayInputStream(bytes));
byContent.put(key, model);
}
byName.putIfAbsent(name, model); // the name-only getters keep returning the first object
return model;
}Trade-offs
- Keeps the sharing the cache exists for: identical files under the same name still share one object.
- Every
inform()reads and hashes the whole file, even when the object is cached (today a hit reads nothing); the cost per analyzer build was not measured. - A configuration without the file now fails at build time, as it already does when built first: a behaviour change for setups that relied on the masking.
- A changed file under the same name (for example after a configuration reload) is now picked up; the old object stays cached until
clearModels(), like every cached object today. - The public name-only getters (
getTokenizer(String),getNERTagger(String), ...), used by Solr's NER update processor, keep first-loaded-wins; fixing them needs an API change. - Like today's code, the helper does
get, parse, thenput, which is not atomic: two concurrent first loads of the same file can parse it twice and leave two factories with different, equal objects.byContentis never trimmed: it keeps one object per distinct file version for the life of the JVM (untilclearModels()), so repeated reloads with changed files grow it. No concurrency or memory test was run. - Side effect: the lemma dictionary's input stream is now closed, which also fixes a separate stream leak in the same method (a 9.1.0 regression of the LUCENE-8188 fix).
What the checks validated
- Same worktree and settings.
:lucene:analysis:opennlp:test: 34 tests, 0 failures, the repro included; the repro with-Ptests.asserts=false: passes; the same six format, lint and javadoc tasks: pass. - The verifiers' wider tests, copied in temporarily: the two-directory tokenizer-model test and the same-named-dictionary and missing-file tests (the second directory without the file now throws
IOException): 3 tests, 0 failures; on stock the same classes give 4 failures.
The finder's sketch, for the tokenizer factory only: read the models from the factory's own loader in inform() and keep them, bypassing OpenNLPOpsFactory.
Trade-offs
Gives up the cross-field and cross-collection sharing of multi-megabyte models that the cache exists for (LUCENE-10171 reported out-of-memory errors without it); the adjudication and the devil's-advocate verifier advise against it.
What the checks validated
The finder's repro and TestOpenNLPTokenizerFactory (4 of 4) pass with it.
Use (loader, name) as the key.
Trade-offs
ResourceLoader defines no identity or equals, and CustomAnalyzer.builder(Path) creates a new FilesystemResourceLoader on every call, so two analyzers built from the same directory would no longer share models.
What the checks validated
Not built.
Services that host several tenants' analysis configurations in one JVM get no isolation between same-named OpenNLP files: whichever configuration loads a name first decides the model or dictionary the others use, and a configuration without the file analyzes with another's (in a test, an empty configuration emitted another configuration's lemma). An ordinary bug, because Lucene treats configuration as trusted.
An audit of every analysis factory's inform() and create() looked for factories that end up using a different component from the one they were configured with. It found the OpenNLP factories fetching models by name only; a probe on the stock jars and a JUnit confirmed it, and verifiers and an adjudication checked it.
The method in full171 words
Method: non-formal (code reading, then executed tests).
- A hunt unit on the analysis factory chain audited the
inform()andcreate()of every in-scope analysis factory for the signal "the component used differs from the configured one". The OpenNLP factories register their files through the loader ininform()but fetch by name increate(), andOpenNLPOpsFactoryreads the loader only on a cache miss. - A probe on the stock jars confirmed the signal, then a public-API JUnit with two valid, behaviourally different tokenizer models (one trained in the test). A first cross-model review objected to an earlier version that used malformed model files, so the repro was rewritten with valid models.
- An independent reproducer wrote its own two-directory test with a fresh-cache oracle and a lemma-dictionary variant; a cross-model adversary, reading only, agreed with the root cause, and separate runs for that review added the missing-file case; a devil's-advocate verifier built the "by design" case; the adjudication re-ran the independent test. The repro was later minimized to the one-test lemma-dictionary form.
No prior report found (searched 2026-10-26 over GitHub, commits, JIRA, the Lucene, Solr and OpenNLP mailing lists and the web; GitHub rechecked 2026-10-31). Closest: the 2012 design comment in LUCENE-2899 and a 2022 remark in PR #380 that the maps are never cleared.
Searches2 searches
Verdict new: the searches found no issue, PR, JIRA entry, list post or web page that reports that a second configuration silently gets another configuration's same-named OpenNLP model or dictionary, or that the cache's JVM-wide scope is undocumented. Related, not reports: LUCENE-2899 (#3973; in 2012 the module's contributor wrote that the class "caches them by file name. It does not reload them across commits": the design, for one configuration); PR #380 / LUCENE-10171 (#11208; in 2022 committer magibney noted that the objects "are cached in static maps that are never cleared" and that the maps are populated with a race: same maps, other consequences); LUCENE-8188 (#9236, a stream leak in the same class); open PRs #11955 and #14194 (touch the module, not the key); SOLR-13018, SOLR-12046, SOLR-17023 (unrelated). Upstream main 2f424fd428da (2026-10-30) still has the name-only key. Searches can show only that no report was found; GitHub search reads at most 50 results per query, and private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream), the changed files of all 408 open PRs, the dev@, java-user@, issues@, commits@, solr-user@ and solr-dev@ lists, the OpenNLP lists, LUCENE and SOLR JIRA, web | 2026-10-26 | a scripted prior-report search twice (39 and 96 queries) plus 44 manual searches and reads, including all 137 LUCENE-2899 comments and the PR #380 comments; 13 hits read | no report; related #3973 (LUCENE-2899), #380 / #11208 (LUCENE-10171), #9236 (LUCENE-8188), open #11955 and #14194, SOLR-13018, SOLR-12046, SOLR-17023 |
| GitHub (recheck) | 2026-10-31 | OpenNLPOpsFactory, "opennlp cache model name", "opennlp ResourceLoader cache", opennlp "same name" model, "lemmatizer dictionary cache configuration" in apache/lucene, and OpenNLPOpsFactory in apache/solr; the file's commit history on upstream main | only the known items #9236, #11208, #380 and #3973, plus unrelated hits (the opennlp-tools dependency bumps #16037, #15006 and #14580, and #12434, #10175 and #9851); last commit on OpenNLPOpsFactory.java ce50d3a135dc (2025-02-24) |
Present since the module was added: LUCENE-2899 (3e2f9e62d772 and its 7.x twin b720e1ee3a52, 2017-12-15), first released in 7.3.0. No later commit changed the cache key. 7.3.0 to 10.6.0 and main are affected; not fixed.
Versions tested6 rows
Introduced by LUCENE-2899, "Add OpenNLP Analysis capabilities as a module" (#3973), committed by Steve Rowe on 2017-12-15 as 3e2f9e62d772 (master, first released in 8.0.0) and b720e1ee3a52 (branch_7x, released in 7.3.0); the code came as JIRA patches, with no PR. The static name-keyed maps and the get-or-load pattern are in the module's first version, so this is not a regression.
Later commits kept the key: LUCENE-8188 (stream leak, 7.3.0), LUCENE-8778, LUCENE-9281, LUCENE-9317, LUCENE-9570 (reformat), LUCENE-10171 / #380 (9.1.0: the dictionary is cached as a DictionaryLemmatizer instead of its text, still keyed by name), #14228 (maps made final) and #15573 (javadoc tags). Upstream main 2f424fd428da (2026-10-30) is unchanged.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (dogA, expected dogB) |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 | affected | minimal test fails, same message |
| 9.0.0, 8.11.4, 8.9.0, 8.8.2, 8.2.0, 8.0.0, 7.7.3, 7.3.1 | affected | independent two-directory tokenizer-model test fails (the minimal test needs the 9.1.0 test framework) |
| 7.3.0 | first | two-directory tokenizer-model test fails with assertions on and off |
| 7.2.1 | not affected | no OpenNLP module (no artifact, no directory in the tag) |
with the content-keyed cache (645d4fe14a20) | not affected | minimal test and the two-directory and missing-file tests pass; the module's tests pass |
An independent reproducer, a devil's-advocate verifier and the adjudication all found it real, as a documentation defect of a deliberate design. Cross-model reviews that could only read files gave a procedural "not real" or no verdict; both agreed with the root cause.
Checks and verdicts362 words
Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with identical files involved) or on releases.
| Check | Verdict | What it found |
|---|---|---|
| Finder (JUnit) | candidate, documentation defect | Two valid, different tokenizer models under one name in two directories: the second analyzer gets the first one's tokens; fails on main with assertions on and off and on 10.5.2 (its 9.12.3 run failed on a model-format error and is not counted). |
| Cross-model review, refute lens, 2 rounds (OpenAI GPT-5.6, reading only) | not real (low), procedural; then no verdict | Agreed with the root cause; objected that the first repro's malformed model files were not a legal input, so the repro was rewritten with valid models. |
| Independent reproducer | real, documentation defect | Own two-directory test, all models trained in the test, fresh-cache oracle and vacuity guard: fails on main, 10.5.2, 9.12.3 and 9.0.0; a lemma-dictionary variant fails the same way. |
| Cross-model adversary, refute lens (OpenAI GPT-5.6, reading only) | not real (low), "not a substantive refutation" | The reviewer could not run Java; it agreed with the root cause, found the inputs legal and the intended-behaviour case moderate. Separate runs for this review then showed that a same-named dictionary is ignored and a directory without the file builds, on main, 10.5.2 and 9.12.3. |
| Devil's-advocate verifier | real only as a documentation defect | Built the strongest "by design" case and found that it defeats a code-bug framing but not the documentation claim; re-ran the finder's repro on main and 10.5.2: fails. |
| Adjudication | confirmed, high; documentation defect; impact minor | Re-ran the independent test on main with assertions on and off: fails, same token lists. |
| History, prior art and security screen | first affected 7.3.0; new; not security-sensitive | The independent test fails on 13 releases from 7.3.0 to 10.5.2 and on main; no prior report; a three-test probe of the cross-configuration effects fails on main, 10.5.2 and 9.12.3. |
| This doc | reproduced, fixes validated | The minimal test on main (assertions on and off) and five releases, through gradle on 645d4fe14a20; the documentation fix and the content-keyed cache validated with the module's tests and checks. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 552 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,209 words
- Registry title
- OpenNLP analysis factories cache models and lemma dictionaries JVM-wide by bare file name, so an analyzer built from a second configuration directory silently uses another directory's same-named model (and builds even when its own directory lacks the file); the name namespace and its scope are undocumented (documentation defect of a deliberate design)
- Area
- analysis-opennlp / model cache (OpenNLPOpsFactory and the OpenNLP factories' inform/create)
- Priority
- Low, rank 862: The OpenNLP factories' JVM-wide model cache keyed by bare file name silently shares a model across configurations only in multi-config JVMs with same-named files, like the per-tenant sharing of LFV-216.
- Impact class
- minor
- Impact note
- Documentation defect of a deliberate design, but silent for the user: in one JVM, an OpenNLP tokenizer, POS, chunker or lemmatizer factory built from a second configuration directory (or any second ResourceLoader) that names a model or lemma dictionary already loaded under the same file name gets the first one's object, never reads its own file and raises no error (a directory without the file at all also builds), until the JVM restarts; only users of the opt-in OpenNLP module who run two or more configurations per process (several collections or tenants, per-language directories) with same-named files of different contents are affected; ordinary scale; released 7.3.0 through 10.6.0 and on main.
- Summary
- You build two analyzers with CustomAnalyzer.builder(configDir) from two configuration directories, each with its own OpenNLP model under the same file name (for example sent.bin and tok.bin, or a lemma dictionary), and expect each analyzer to use its own directory's files, as builder(Path) says it 'loads all resources from the given file system base directory'. Instead the OpenNLP factories keep one static, JVM-wide cache per model type keyed only by the file name, so the second analyzer silently gets the model the first directory loaded: with two valid, different tokenizer models, configB's analyzer produces configA's tokens [Hello, world, ., This, is, a, test, .] instead of its own [Hello, world., This, is, a, test.], and a directory with no such file at all still builds. The name-keyed cache is intended (LUCENE-2899), but nothing documents that file names are shared by every configuration in the JVM.
- Introduced
- 3e2f9e62d772 (LUCENE-2899 'Add OpenNLP Analysis capabilities as a module', GitHub issue #3973, committed by sarowe on master, first released in 8.0.0) and its branch_7x twin b720e1ee3a52 (same message, timestamp and code; released in 7.3.0); no PR, the code came as JIRA patch attachments [read: git log --follow and git blame -w -M -C on OpenNLPOpsFactory.java and OpenNLPTokenizerFactory.java; exec: 7.2.1 has no OpenNLP module, 7.3.0 fails]; not a regression: the static name-keyed maps and the get-or-load-from-loader pattern are in the module's first version, and no later commit changed the key (LUCENE-8188, LUCENE-8778, LUCENE-9281, LUCENE-9317, LUCENE-9570, LUCENE-10171 #380, #14228, #15573) (2017-12-15 (both commits; LUCENE-2899 closed 2017-12-19))
- First affected version
- 7.3.0 [exec] (released 2018-04-04; 7.2.1, the last release before, has no OpenNLP module [exec])
- Affected versions
- 7.3.0 through 10.6.0 and main ea57360912ed [exec: the minimal test fails on the 10.6.0 jars with -ea (2026-10-31); the independent reproduction's self-contained repro (an import-adapted copy before 9.1.0) fails 1 of 1 on 7.3.0, 7.3.1, 7.7.3, 8.0.0, 8.2.0, 8.8.2, 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, also with -da on 7.3.0, 10.5.2 and main; the releases in between by reading]; not applicable to 7.2.1 and earlier (module absent) [exec]; branch_10x and branch_10_6 not checked (code identical on main) [read]; not fixed anywhere: the last commit to OpenNLPOpsFactory.java on upstream main (ce50d3a135dc, #14228, 2025-02-24) kept the name-only key, and no open PR changes it [read]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26: the finder's scripted prior-report search, 39 queries; follow-up prior-report record, verdict new: a second scripted prior-report search with 96 queries over GitHub issues and PRs, commits, downstream repositories, the Lucene, Solr and OpenNLP mailing lists, the LUCENE and SOLR JIRA archives and the web, plus 44 manual searches and reads, including a grep of all 137 LUCENE-2899 comments, the PR #380 comments and the file lists of all 408 open PRs; 13 hits read, none the same bug): no issue, PR, JIRA entry, list post or web page reports that a second configuration silently gets another configuration's same-named OpenNLP model or dictionary, or that the cache's JVM-wide name scope is undocumented; not fixed upstream; related, not reports of this bug: LUCENE-2899 (#3973; the module's contributor wrote in 2012 that the class 'caches them by file name. It does not reload them across commits', the design intent for one configuration), PR #380 / LUCENE-10171 (#11208; committer magibney noted in 2022 that the objects 'are cached in static maps that are never cleared' and populated with a race: same maps, different consequences), LUCENE-8188 (#9236, stream leak), open PRs #11955 and #14194 (touch the module, not the key; would not fix this), SOLR-13018, SOLR-12046, SOLR-17023, three list threads and a web Q&A page (unrelated)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off; the runner also fails it on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. An independent two-directory tokenizer-model test fails on 13 releases from 7.3.0 to 10.5.2 and on main. Fix A (javadoc) keeps the module's 33 tests and its format, lint and javadoc checks green; fix B (content-keyed cache) makes the repro pass with all 34 tests. The hunt, the verifiers and the adjudication ran mainea57360912ed; the files involved are identical in both commits.
- Finding record
lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/OpenNLPOpsFactory.java:33-36 (class javadoc 'Supply OpenNLP Named Entity Recognizer Cache model file objects. Assumes model files are thread-safe.'), :38-48 (seven static maps keyed by name), :59-90 (getSentenceModel/getTokenizerModel read from the loader only on a miss), :92-178 (the POS, chunker, NER, lemmatizer and dictionary getters, same pattern), :181-188 (clearModels, skips lemmatizerModels)lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizerFactory.java:68-77 (create() resolves models by name only), :79-88 (inform(): 'register models in cache with file/resource names'); OpenNLPPOSFilterFactory.java:67,76; OpenNLPChunkerFilterFactory.java:72,85; OpenNLPLemmatizerFilterFactory.java:88,99,102 (same register-then-fetch-by-name pattern)lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:109-116 (builder(Path): 'loads all resources from the given file system base directory'); lucene/core/src/java/org/apache/lucene/util/ResourceLoaderAware.java:30 (inform: 'Initializes this component with the provided ResourceLoader')
- The finding in 30 seconds
lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/OpenNLPOpsFactory.java:33-36, :38-48, :59-178, :180-188lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilterFactory.java:84-104; OpenNLPTokenizerFactory.java:68-88; OpenNLPPOSFilterFactory.java:64-80; OpenNLPChunkerFilterFactory.java:66-90lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilter.java:75, :89
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProviderFactory.java:37-53 (read only)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:109-124lucene/core/src/java/org/apache/lucene/util/ResourceLoaderAware.java:29-33lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/OpenNLPOpsFactory.java:33-36lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:50-57lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizerFactory.java:81; OpenNLPLemmatizerFilterFactory.java:97; OpenNLPPOSFilterFactory.java:75; OpenNLPChunkerFilterFactory.java:83 (the inform() comments)
- Suggested fix
lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:109-117 (builder(Path) creates a new FilesystemResourceLoader)
- Prior reports
https://github.com/apache/lucene/issues/3973https://github.com/apache/lucene/pull/380
- History
a Lucene clone release tags releases/lucene-solr/7.3.0 and releases/lucene/9.0.0 (OpenNLPOpsFactory.java line 48: lemmaDictionaries keyed by name)
- TestOpenNLPLemmaDictionarySecondLoader.java (JUnit test):
repro/LFV-448/src/org/apache/lucene/analysis/opennlp/TestOpenNLPLemmaDictionarySecondLoader.java(41 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's output.
assets/LFV-448/bug.png(552 KB)
