All findings · Lucene finding · LFV-448 · opennlp-model-cache-ignores-resource-loader

OpenNLP analysis factories cache models and dictionaries JVM-wide by file name, so a second configuration silently uses another configuration's same-named file

Lucene's OpenNLP factories cache models and lemma dictionaries JVM-wide by file name alone (deliberately, but undocumented), ignoring a configuration's own ResourceLoader once a name is cached. So an analyzer whose own lemmas.txt maps dogs to dogB outputs dogA, from another configuration's file.

Summary

Priority
Low, rank 862The 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
minor
Since
7.3.0 (LUCENE-2899, 2017)introduced 2017-12-15 · about 8 years
Affected
7.3.0 to 10.6.0, and main
Status
documented
Found by
non-formal: an audit of every analysis factory's inform()/create(), then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background

  1. Analysis factories that read files implement ResourceLoaderAware: when CustomAnalyzer builds an analyzer, it calls each factory's inform(loader) once, and the factory reads its files through that loader.
  2. CustomAnalyzer.builder(Path) makes a loader for one configuration directory and builder(ResourceLoader) takes yours. An application with several collections, tenants or languages typically has one configuration, and so one loader, for each.
  3. The OpenNLP factories name their model and dictionary files by resource name, for example tokenizerModel=tok.bin or dictionary=lemmas.txt.
Diagram of the bug: configuration A and configuration B each have a file named lemmas.txt; A's maps dogs to dogA, B's maps dogs to dogB. Building A loads its file into OpenNLPOpsFactory's static cache under the key lemmas.txt. Building B finds that key already cached, so B's file is never opened, and B's analyzer gets A's dictionary: it turns dogs into dogA instead of dogB.
Two configurations each ship a 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.

The finding, step by step

  1. In one JVM you build two analyzers, A and B, each from its own configuration (CustomAnalyzer.builder(dir) or builder(loader)), both with openNlpLemmatizer and dictionary=lemmas.txt. A's file maps dogs to dogA, B's maps dogs to dogB.
  2. You expect B to turn dogs into dogB: the builder "loads all resources using the given ResourceLoader", and under a name nobody loaded before, B's file is indeed used.
  3. You get dogA. OpenNLPOpsFactory keeps one static map per resource type, keyed only by the file name. B's inform() finds lemmas.txt already cached and never opens B's file; create() then fetches A's dictionary by name.
  4. 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 no lemmas.txt at all builds and uses A's.
  5. 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.

Who is affected

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.

Terms

  • ResourceLoader: what an analysis factory reads its files through; CustomAnalyzer.builder(Path) uses a FilesystemResourceLoader for one directory, builder(ResourceLoader) uses the one you pass.
  • OpenNLP factories: OpenNLPTokenizerFactory (openNlp, sentence and tokenizer models), OpenNLPPOSFilterFactory (openNlppos), OpenNLPChunkerFilterFactory (openNlpChunker) and OpenNLPLemmatizerFilterFactory (openNlpLemmatizer, a lemma dictionary and/or a lemmatizer model). All live in the opt-in analysis-opennlp module.
  • 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).

The bug

  1. OpenNLPOpsFactory keeps seven static maps (sentence, tokenizer, POS, chunker, NER and lemmatizer models, lemma dictionaries) keyed only by the resource name (:38-48).
  2. Each loader-taking getter reads from the loader only on a miss. getLemmatizerDictionary(dictionaryFile, loader) (:156-166) does lemmaDictionaries.get(dictionaryFile); only if that is null does it call loader.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).
  3. The factories' inform(loader) only fills the maps ("register models in cache with file/resource names", OpenNLPLemmatizerFilterFactory.java:97), and create() 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.
  4. 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.
  5. With configurations built one after another, as in the repro, the first one decides. The getters do get, load, then put, which is not atomic: if two configurations load the same name at the same moment, both read their own file and the last put wins (the populate race committer magibney noted on PR #380).

The numbers

  • Lemma dictionary: A's lemmas.txt is dogs<TAB>word<TAB>dogA, B's is dogs<TAB>word<TAB>dogB (the whitespace tokenizer's type word serves as the POS tag). B's analyzer emits dogA; A's emits dogA, 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 valid sent.bin and tok.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.txt throws IOException when 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.bin or lemmas.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 plain lemmas.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): Word2VecSynonymProviderFactory in analysis-common keeps a static map keyed by the model file name and reads from the loader only on a miss.

Minimal repro

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).

TestOpenNLPLemmaDictionarySecondLoader.java (JUnit test)41 lines
/*
 * 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();
  }
}
Command
# 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]>").

All files of this repro: repro/LFV-448/

More files, environment, full output40 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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 OpenNLPOpsFactory class 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.md and 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 ResourceLoaderAware contract.
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).

Suggested fix

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.

Document the cache's key and scope (recommended, validated)

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, forbiddenApisTest and renderJavadoc: pass.

Key the cache by content and let each factory keep its own objects (validated, optional follow-up)

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, then put, which is not atomic: two concurrent first loads of the same file can parse it twice and leave two factories with different, equal objects. byContent is never trimmed: it keeps one object per distinct file version for the life of the JVM (until clearModels()), 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.

Each factory reads its own copy, no shared cache (not recommended) (validated by the checks)

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.

Key the cache by loader (not built, not recommended) (not run)

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.

Downstream note

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.

How it was found

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() and create() 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 in inform() but fetch by name in create(), and OpenNLPOpsFactory reads 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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, web2026-10-26a 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 readno 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-31OpenNLPOpsFactory, "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 mainonly 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)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal 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 affectedminimal 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 affectedindependent two-directory tokenizer-model test fails (the minimal test needs the 9.1.0 test framework)
7.3.0 firsttwo-directory tokenizer-model test fails with assertions on and off
7.2.1 not affectedno OpenNLP module (no artifact, no directory in the tag)
with the content-keyed cache (645d4fe14a20) not affectedminimal test and the two-directory and missing-file tests pass; the module's tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidate, documentation defectTwo 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 verdictAgreed 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 defectOwn 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 defectBuilt 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 minorRe-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-sensitiveThe 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 validatedThe 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,209 words

Finding record

LFV-448, snapshot of 2026-11-06

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 main ea57360912ed; the files involved are identical in both commits.

Lucene and upstream sources

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-188
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilterFactory.java:84-104; OpenNLPTokenizerFactory.java:68-88; OpenNLPPOSFilterFactory.java:64-80; OpenNLPChunkerFilterFactory.java:66-90
  • lucene/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-124
  • lucene/core/src/java/org/apache/lucene/util/ResourceLoaderAware.java:29-33
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/OpenNLPOpsFactory.java:33-36
  • lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:50-57
  • lucene/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/3973
  • https://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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.