All findings · Lucene finding · LFV-651 · teesink-sink-never-clears-sink-only-attributes

TeeSinkTokenFilter: a keyword marker on a sink stops stemming every word after the first protected one

With a keyword marker and a stemmer on a TeeSinkTokenFilter sink, nothing after the first protected word is stemmed: The quick foxes jumped, quick protected, is indexed as [the, quick, foxes, jumped], not [the, quick, fox, jump]. The sink never resets attributes only its own filters add; silent since 2.9.0.

Summary

Priority
Low, rank 307Only the rarely used, factory-less TeeSinkTokenFilter with attribute-setting filters on the sink side is affected.
Impact
silent wrong results
Since
2.9.0 (LUCENE-1693, 2009)introduced 2009-07-24 · over 17 years
Affected
every release from 2.9.0 to 10.6.0 (10.6.0 by reading), and main
Status
documented
Found by
bounded-exhaustive translation validation against a reference model

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

TeeSinkTokenFilter lets one analysis feed several fields: the tee saves every token's state, and a sink replays those states later, where more filters can be added.

Summary card: Lucene TeeSinkTokenFilter, a sink never clears the attributes that only its own filters add. With SetKeywordMarkerFilter protecting quick before PorterStemFilter on a sink, The quick foxes jumped is expected as [the, quick, fox, jump] (the same filters on the tokenizer) but indexed as [the, quick, foxes, jumped], and TermQuery stemmed:fox finds nothing, with no exception. Why: line 69 gives the sink its own copy of the tee's attributes, and line 121 restores each token without clearAttributes(), so the keyword flag keeps the value of the last token. A reused chain starts the next text with the flag set; the test framework flags the sink. Fix: clearAttributes() before restoreState and super.end() in end(). Every release from 2.9.0 to 10.5.2 and main.
The example sentence through the tee and the sink, token by token: the keyword flag set on quick is never reset, so foxes and jumped skip the stemmer; then the effect, the fix and a summary card. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Tee The quick foxes jumped (whitespace tokenizer, lowercased) into a field plain, and wrap a sink of the tee in SetKeywordMarkerFilter (protect quick) and PorterStemFilter for a field stemmed.
  2. You expect stemmed to get [the, quick, fox, jump], which the same two filters give directly on the tokenizer.
  3. The sink replays each token from the tee's saved state. The keyword flag exists only on the sink side, so no saved state holds it and nothing resets it: once quick switches it on, foxes and jumped still carry it and the stemmer skips them.
  4. You get [the, quick, foxes, jumped], and stemmed:fox finds 0 documents instead of 1. No error: the plain field and CheckIndex are fine.

Who is affected

Applications that build a TeeSinkTokenFilter in their own Java code (it has no factory; no use found in Solr, Elasticsearch or OpenSearch) and wrap a sink in a filter that flags some tokens: a keyword marker or StemmerOverrideFilter before a stemmer, PatternTypingFilter with DropIfFlaggedFilter, or NumericPayloadTokenFilter. Each value is then indexed wrong after its first flagged token.

Mechanism, terms and numbers508 words

TeeSinkTokenFilter lets one analysis chain feed several fields: the tee saves each token's state and a sink replays it later, where more filters can be added. A sink has its own copy of the tee's attributes, and SinkTokenStream.incrementToken() only calls restoreState(), never clearAttributes(). So an attribute that only the sink's own filters add is never reset between tokens. With SetKeywordMarkerFilter protecting quick before PorterStemFilter on a sink, The quick foxes jumped is indexed as [the, quick, foxes, jumped] instead of [the, quick, fox, jump], and stemmed:fox finds nothing. Silent, deterministic, in every release from 2.9.0 to 10.6.0 (10.6.0 by reading) and main; the fix adds clearAttributes() to SinkTokenStream.incrementToken(), plus a separate super.end() hardening in end().

Terms

  • Attributes. A token stream describes the current token through attribute objects: the term text, offsets, position, type, and optional flags such as KeywordAttribute ("do not stem this word"). All filters of one chain share one set of attribute objects.
  • Clearing. The source at the start of a chain resets every attribute before each token ("Tokenizers must call clearAttributes() in incrementToken()", a rule LUCENE-1801 applied to every token source). Filters that only ever switch a flag on rely on that reset.
  • Tee and sink. TeeSinkTokenFilter saves each token's state with captureState(); each sink from newSinkTokenStream() replays the saved states with restoreState(). The class javadoc wraps sinks in further filters (new EntityDetect(sink1)).

The bug

  1. newSinkTokenStream() gives each sink its own attribute source, a copy of the tee's attributes (new SinkTokenStream(this.cloneAttributes(), cachedStates), TeeSinkTokenFilter.java:69).
  2. SetKeywordMarkerFilter and PorterStemFilter wrapped around the sink add KeywordAttribute to that copy. The tee never has it, so none of the tee's saved states (:85) contains it.
  3. SinkTokenStream.incrementToken() only calls restoreState(state) (:121). restoreState "does not affect attributes of the targetStream that are not contained in this state"; to reset them "the caller should first call TokenStream#clearAttributes() on the targetStream" (AttributeSource.java:312-316). The sink never does.
  4. KeywordMarkerFilter only ever sets the flag to true (KeywordMarkerFilter.java:45-46), and PorterStemFilter skips keyword tokens (PorterStemFilter.java:64).
  5. So: the (flag false) is stemmed; quick is protected and the flag turns on; foxes and jumped get their terms restored but keep the flag, and are not stemmed.

On the tee side the same filters are fine: they share the tokenizer's attributes, which the tokenizer clears for every token. Attributes the tee already has when the sink is created (term, type, offsets, position increment and length) are in every saved state and are restored correctly.

The numbers

The quick foxes jumped: the sink chain gives [the, quick, foxes, jumped], the same chain on the tokenizer [the, quick, fox, jump]. Indexed with the tee as plain and the sink as stemmed (the documented order), stemmed:fox and stemmed:jump find 0 documents and plain:foxes finds 1. Adding KeywordAttribute to the tee before creating the sink gives the right terms. The test framework's own check reports "clearAttributes() was not called correctly in TokenStream chain at token 0" on a plain sink.

Exposure and likelihood285 words
  • Who is exposed. Only chains built in Java: TeeSinkTokenFilter has no analysis factory (Lucene's factory test lists it as a "crazy component" without one), so CustomAnalyzer and factory-based schemas cannot reach it. A read-only code search found no use in Elasticsearch, OpenSearch or Solr (default branches only; private applications are unknown).
  • What triggers it. A sink-side filter that sets an attribute on some tokens only and relies on the chain's source to reset it. Executed: SetKeywordMarkerFilter, PatternKeywordMarkerFilter or StemmerOverrideFilter before PorterStemFilter, EnglishMinimalStemFilter ([cat, exempt, dogs] instead of [cat, exempt, dog]) or KStemFilter; StemmerOverrideFilter {dogs->canine, mice->mouse} also blocks its own second override ([canine, running, mice, walking] instead of [canine, run, mouse, walk]); PatternTypingFilter (digit tokens) + DropIfFlaggedFilter keeps only [order] of order 42 shipped to alice instead of [order, shipped, to, alice]; NumericPayloadTokenFilter gives later tokens the number's payload.
  • Scale. In every value with a flagged token, the flagged token itself is right, but the attribute stays stale on all later tokens, and the output is wrong wherever a later filter depends on its default (here every later word the stemmer would change); deterministic, any size, assertions on or off. If one tee/sink chain is reused for several documents, the flag carries into the next document (foxes jumped indexed as [foxes, jumped]).
  • Effect. Wrong terms or payloads in the sink field only; queries on it miss documents (or over-match on payloads). The tee's field and stored values are right, CheckIndex is clean, and documents stay wrong until reindexed.
  • Not affected. The same filters on the tee side; attributes added to the tee before the sink is created; sink-side filters that set their attribute on every token.

Minimal repro

The test shows that a TeeSinkTokenFilter sink never clears attributes that only its own chain adds, so SetKeywordMarkerFilter's keyword flag for "quick" sticks to every later token and PorterStemFilter stops stemming.

TestTeeSinkSinkOnlyAttribute.java (JUnit test)57 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.sinks;

import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.CharArraySet;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.WhitespaceTokenizer;
import org.apache.lucene.analysis.en.PorterStemFilter;
import org.apache.lucene.analysis.miscellaneous.SetKeywordMarkerFilter;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTeeSinkSinkOnlyAttribute extends LuceneTestCase {

  public void testKeywordMarkerOnSink() throws Exception {
    Tokenizer tokenizer = new WhitespaceTokenizer();
    tokenizer.setReader(new StringReader("quick foxes"));
    TeeSinkTokenFilter tee = new TeeSinkTokenFilter(tokenizer);
    TokenStream sink =
        new PorterStemFilter(
            new SetKeywordMarkerFilter(
                tee.newSinkTokenStream(), new CharArraySet(List.of("quick"), false)));
    tee.reset();
    tee.consumeAllTokens();
    tee.end();
    tee.close();

    CharTermAttribute term = sink.addAttribute(CharTermAttribute.class);
    List<String> terms = new ArrayList<>();
    sink.reset();
    while (sink.incrementToken()) {
      terms.add(term.toString());
    }
    sink.end();
    sink.close();
    // expected: only "quick" is protected, "foxes" is stemmed; actual: [quick, foxes]
    assertEquals(List.of("quick", "fox"), terms);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-651/, 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: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.sinks.TestTeeSinkSinkOnlyAttribute
Expected vs actual
Expected: the sink chain on "quick foxes" gives [quick, fox], as the same chain on the tokenizer does. Actual (-ea and -da): [quick, foxes].

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

More files, environment, full output36 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
    => java.lang.AssertionError: expected:<[quick, fox]> but was:<[quick, foxes]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.sinks.TestTeeSinkSinkOnlyAttribute.testKeywordMarkerOnSink(TestTeeSinkSinkOnlyAttribute.java:55)

Why it looks unintended

  • restoreState leaves attributes the state lacks unchanged and tells the caller, here the sink, to call clearAttributes() first.
  • The javadoc and the 5.5.0 CHANGES entry (LUCENE-6973) say to wrap sinks in "any other TokenFilter"; nothing limits their attributes.
  • Lucene's test-framework check fails on a plain sink; the upstream test avoids it by also adding its check attribute to the tee.
The full argument245 words
  • Contract. AttributeSource.restoreState (AttributeSource.java:312-316): attributes not in the state "remain unchanged"; to reset them "the caller should first call TokenStream#clearAttributes() on the targetStream". The package docs: "Tokenizers must call AttributeSource#clearAttributes() in incrementToken()" (package-info.java:404-405). LUCENE-1801 (#2876, 2009) applied that rule to "All Tokenizers/TokenStreams that are source of tokens"; a reviewer there noted that restoreState() alone is not enough. SinkTokenStream already existed and was not changed.
  • Documented use. Class javadoc: sinks "will further do additional token filtering" (TeeSinkTokenFilter.java:49-51, example new EntityDetect(sink1) at :41). CHANGES 5.5.0 (API Changes), LUCENE-6973: "If you wish to filter the sinks, you can wrap them with any other TokenFilter (e.g. a FilteringTokenFilter)." PorterStemFilter alone adds KeywordAttribute to the sink.
  • Tests. TestTeeSinkTokenFilter.testGeneral adds CheckClearAttributesAttribute to the tee and to the sink (:123-124), so the check value rides in the saved states. No upstream test puts an attribute on the sink only. With it on the sink only the check fails; that is supporting evidence, and the stock filters' wrong terms are the stronger evidence.
  • end(). TokenStream.end(): "If you override this method, always call super.end()" (TokenStream.java:156); SinkTokenStream.end() does not, so a sink-only attribute keeps its last-token value after end(). This is a separate, smaller contract gap: the wrong terms come from incrementToken() alone.
  • History. The restore-only incrementToken() came with the class (LUCENE-1693, 2009) and survived the LUCENE-6973 rewrite (2016, first released in 5.5.0); no commit ever added clearAttributes() to the sink.
The devil's-advocate check235 words

Strongest case (rated weak by the intended-lens reviewer from another model family, the independent reproducer and the devil's advocate; moderate by the refute-lens reviewer and by the cross-model fact check of this doc): the package rule names only Tokenizers; restoreState says a reset "might be desirable"; CachingTokenFilter also restores without clearing and its end() also skips super.end(); the 2009 design wanted a sink with "exactly the same attributes as the TeeSinkTokenFilter"; a committer wrote on dev@ in 2014 that "clearAttributes() is not needed before restoreState()"; the Lucene.NET port suppressed its warning about the missing base.End() as intended.

Why it fails: CachingTokenFilter shares its chain's attributes, so filters after it are in its states (the same keyword chain over a CachingTokenFilter replay is correct), while a sink has a separate copy by construction. The javadoc puts the reset on the caller; the package rule's wording names Tokenizers, and LUCENE-1801 applied it to every TokenStream that is a source of tokens, which a sink is. The "same attributes" remark was about creating the sink; the same javadoc wraps sinks in filters. The 2014 remark was about a filter whose state covers the whole shared chain. The Lucene.NET rationale ("calling base.End() would clear the attributes that RestoreState then sets") does not hold when super.end() runs before restoreState. Verdict: a defect. Expect the reply "add the attribute to the tee first": it works, but nothing documents it.

Suggested fix

Recommended Clear before restoring, and call super.end() (validated)

In SinkTokenStream, call clearAttributes() before restoreState(state) (this alone fixes the wrong terms) and, as separate end-of-stream hardening, super.end() first in end() (+3 lines). The new test then passes 5 of 5; the lucene/analysis/common module (1,914 tests, 18 skipped) has 0 failures, assertions on and off. Tee attributes are unchanged: the restored state overwrites them.

       AttributeSource.State state = it.next();
+      // the sink is the source of its own chain: reset the attributes the state does not hold
+      clearAttributes();
       restoreState(state);
       return true;
     }
 
     @Override
     public void end() throws IOException {
+      super.end(); // endAttributes(), also for attributes that only this sink's chain added
       State finalState = cachedStates.getFinalState();
All options and trade-offs3 options ยท 562 words

Option A was built and validated with gradle in a scratch worktree; option B was run through the standalone runner; option C was not run.

Clear before restoring, and call super.end() (validated) (recommended)

Two calls in SinkTokenStream (TeeSinkTokenFilter.java:121, :126): each sink token starts from cleared attributes, as on any tokenizer-rooted chain, and end() runs endAttributes() before restoring the tee's final state. The first call fixes the bug. The second is a separate end() contract fix: without it a sink-only attribute keeps its last-token value after end() (executed: a reviewer's testSinkEndResetsSinkOnlyAttribute fails on stock and passes with the fix); no in-tree consumer impact of that part was shown.

       AttributeSource.State state = it.next();
+      // the sink is the source of its own chain: reset the attributes the state does not hold
+      clearAttributes();
       restoreState(state);
       return true;
     }
 
     @Override
     public void end() throws IOException {
+      super.end(); // endAttributes(), also for attributes that only this sink's chain added
       State finalState = cachedStates.getFinalState();

Trade-offs

  • Tee attributes do not change: every one is in every saved state and is restored right after the clear (assuming, as TokenStream requires, that the tee's attributes are all added before tokens are captured). Only sink-only attributes change, to their per-token default.
  • Cost: one clearAttributes() per sink token; not benchmarked.
  • A sink-side filter that relied on its attribute persisting across tokens would change; none is known.
  • Existing indexes keep their wrong terms until reindexed.
  • In a path the model treats as a caller error (consumeAllTokens() without the tee's end(), so no final state exists), the sink's end-of-stream values are wrong with or without the fix (3,579 of 4,092 model inputs on stock, 4,089 with it).
  • The same lines are in every 9.x and 10.x release, and the +3-line diff applies cleanly (git apply --check) to the 10.5.2 and 9.12.3 sources; only main was built with gradle.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: the new test fails 3 of 5 on stock and passes 5 of 5 with the fix; TestTeeSinkTokenFilter (3 upstream tests) and the new test pass (8 of 8).
  • The whole lucene/analysis/common module: 340 suites, 1914 tests, 0 failures (18 skipped), with assertions on and off; :lucene:analysis:common:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Standalone runner with the fix shadowed: the new test 5/5 (on and off), the independent reproducer's 5 tests 5/5, the follow-up's 4-test effect probe 4/4. The finder's exhaustive model (same logic) drops from 4,032 of 4,092 mismatching inputs to 0, and the documented-use scenarios stay at 0.

Only the clearAttributes() line (validated by the checks)

Fixes every wrong term and payload seen: the independent reproducer's 5 tests pass, 4 of 5 of the new test, and the follow-up's 4-test effect probe (payloads, dropped tokens, reused chain, deny rule) 4 of 4.

Trade-offs

Sink-only attributes keep their last-token value after end(), and the framework's end() check still fails on such a sink.

What the checks validated

Standalone runner on main: the new test's only failure is the framework's end() check, "super.end()/clearAttributes() was not called correctly in end()".

Document the limitation

Say in the class javadoc that sink-side filters' attributes are never reset and must be added to the tee before newSinkTokenStream().

Trade-offs

No code change, so every existing chain stays wrong; stock filters such as PorterStemFilter add KeywordAttribute without the user asking.

Downstream note

Downstream, a content rule on the sink field can miss documents: with quick protected, the quick secrets kept secrets unstemmed, so a MUST_NOT stemmed:secret clause left it visible.

How it was found

Bounded-exhaustive translation validation of TeeSinkTokenFilter against a reference model. While modelling a sibling bug, the finder read the sink code and added a scenario with a sink-side filter that flags one token: the real class mismatched on 4,032 of 4,092 inputs, every input with two or more tokens.

The method in full173 words

Method: bounded-exhaustive translation validation (semi-formal tier). A reference model of the tee and its sinks was compared with the stock jar on every input up to a small scope: 0 to 4 tokens, position increments 1 or 2, offset gaps 0 or 1, final position increment 0 or 1, final offset slack 0 or 1, and 1 to 3 sinks.

  • The hypothesis came from reading SinkTokenStream while modelling the sibling bug (an attribute added to the tee after a sink exists makes restoreState throw).
  • New scenario F wraps each sink in a filter that adds KeywordAttribute and sets it on one token only, relying on the source's clear, and compares the result with the same filter run on the input itself. Stock main: 4,032 of 4,092 inputs mismatch; the documented-use scenarios A, C and D: 0.
  • The finding was reduced to a public-API JUnit on the stock main jars, then checked by a reviewer from another model family (OpenAI GPT-5.6) in lenses, an independent reproducer, a devil's advocate, an adjudication and an audit.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27): GitHub issues, PRs and commits, the changed files of all 409 open PRs, Solr, Elasticsearch, OpenSearch and Lucene.NET, the mailing lists, JIRA and the web. A Lucene.NET PR flagged only the separate end() detail and suppressed it as intended behaviour.

Searches4 searches

Verdict new. The nearest items are contract and history, not reports of this bug. The one outside touch, Lucene.NET PR #1288, is about the separate end() detail only, not the incrementToken() defect or its wrong terms.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene GitHub issues, PRs and commits; Solr, Elasticsearch, OpenSearch, Lucene.NET2026-10-27161 queries of a scripted prior-report search in three runs (class, symptom, mechanism) plus the finder's run; 44 logged manual GitHub search calls (36 distinct queries); all 11 issues that mention TeeSinkTokenFilter read or triagedno report; nearest: LUCENE-1801 #2876 (sources must clear), LUCENE-2211 #3287 (the check; its test mirrors the attribute on the tee), LUCENE-6973 #8030 (rewrite), LUCENE-1693 #2767 and LUCENE-5687 #6749 (design), LUCENE-2901 #3975, LUCENE-6667 #7725 (opposite problem)
Open apache/lucene PRs2026-10-27changed-file lists of all 409 open PRsonly #16911 (branch_10x decommission) touches the file; none touches KeywordMarkerFilter, StemmerOverrideFilter or AttributeSource
Lucene.NET2026-10-27PRs and codePR #1288 (merged 2026-05) suppressed an analyzer warning that the sink's End() skips base.End(), as intended parity with Java; nothing on incrementToken() or wrong terms
Mailing lists, JIRA, web2026-10-27dev@, java-user@, issues@, commits@; LUCENE, SOLR, LUCENENET JIRA archives; web and Stack Overflow; 8 threads read in fullno report; a 2014 dev@ remark that clearing is not needed before restoreState() concerns a filter whose state covers the whole chain

History

Present since TeeSinkTokenFilter was added (LUCENE-1693, 31a5f0edcc4, 2009-07-24), first released in 2.9.0; every release up to 10.6.0 (10.6.0 by reading) and main is affected, and nothing was ever fixed. The stock keyword-marker trigger exists from 3.1.0.

Versions tested6 rows

Introduced by 31a5f0edcc4 (LUCENE-1693, #2767, svn r797665, 2009-07-24, no PR), which added the class with the per-sink cloneAttributes() copy and the restore-only incrementToken(); git blame on main puts restoreState(state) on it. LUCENE-6973 (073dbdc17e3, backported as 1ff4e84f89f, first released in 5.5.0) rewrote the class and kept both. The missing super.end() dates from LUCENE-1448 (c311d5730cb, 2009) and matters only from 4.5.0, when the base end() began to reset attributes. Upstream main a95c754ee003 has a byte-identical file.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 3 of 5 (gradle and standalone, assertions on and off); passes 5 of 5 with the fix
10.5.2, 9.12.3 affectedthis doc's test fails 3 of 5; the finder's JUnit 3 of 3
3.1.0 to 10.0.0 (3.1.0, 3.6.2, 4.0.0, 4.2.1, 4.3.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 10.0.0) affectedkeyword marker + PorterStemFilter on a sink gives [the, quick, foxes, jumped]
2.9.0, 2.9.4, 3.0.0, 3.0.3 custom attributea sink-side filter's own attribute stays set on later tokens (no stock keyword marker before 3.1.0); control passes
31a5f0edcc4 (introducing commit) over the 2.9.0 core introducingthe same failure; 2.9.0 with only clearAttributes() added passes
2.4.1 not affectedno TeeSinkTokenFilter; the predecessor TeeTokenFilter/SinkTokenizer replays correctly

Independent checks

Confirmed by a reviewer from another model family (OpenAI GPT-5.6) in lenses, an independent reproducer, a devil's advocate, an adjudication and a credibility audit; each wrote its own test with passing controls, except the adjudication, which re-ran the independent reproducer's test with a new seed; this doc's test and fix were re-run with gradle on main.

Checks and verdicts280 words

Each check except the adjudication wrote and ran its own test on the stock jars (the adjudication re-ran the independent reproduction's with a new seed); the controls (the chain on the tokenizer, a CachingTokenFilter replay, the attribute added to the tee first) pass everywhere.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model review, intended lens real, high; intended argument weakOwn test: sink keyword flags [false, true, true, true] where [false, true, false, false] is expected; also with assertions off and on 10.5.2.
cross-model review, refute lens real, high; intended argument moderateOwn tests (PatternKeywordMarkerFilter + EnglishMinimalStemFilter, a custom attribute, an end() check, six controls), run by the reviewer on ea57360912ed (identical TeeSinkTokenFilter.java); re-run on main a9779ef3c193 and 10.5.2: 3 of 9 fail, 0 with the fix.
Independent reproducer realOwn 5-test JUnit: 4 of 5 fail on main (assertions on and off), 10.5.2 and 9.12.3; the tee-first control passes; a clear-only fix passes 5 of 5.
Devil's advocate real (high for incrementToken(), medium for end())Own test with StemmerOverrideFilter mice->mouse + PorterStemFilter fails on main and 10.5.2; four controls pass.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed: 4 of 5 fail with assertions on and off, 5 of 5 pass with the fix.
credibility audit keepRe-ran the finder's JUnit (3 of 3 fail, on and off); own test with PatternKeywordMarkerFilter + KStemFilter fails on main and 10.5.2, passes with the fix.
this doc confirmed; fix validatedNew 5-test JUnit fails 3 of 5 on main, 10.5.2 and 9.12.3; the fix passes it, and the module's 1,914 tests have 0 failures (18 skipped).

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,511 words

Finding record

LFV-651, snapshot of 2026-11-06

Registry title
TeeSinkTokenFilter: a sink never clears attributes that only its own filter chain adds (SinkTokenStream.incrementToken() calls restoreState() without clearAttributes()), so a keyword marker or StemmerOverrideFilter before a stemmer on a sink silently leaves every token after the first protected word unstemmed
Area
analysis / TeeSinkTokenFilter (SinkTokenStream replay through AttributeSource.restoreState without clearAttributes(): attributes that only the sink's chain adds)
Priority
Low, rank 307: Only the rarely used, factory-less TeeSinkTokenFilter with attribute-setting filters on the sink side is affected.
Impact class
silent wrong results
Impact note
Hits applications whose own Java code builds a TeeSinkTokenFilter (the class has no factory, so analysis configuration cannot reach it, and a code search finds no use in Solr, Elasticsearch or OpenSearch) and wraps a sink in a filter that sets an attribute on some tokens only and relies on the chain's source to clear it: a keyword marker (SetKeywordMarkerFilter, PatternKeywordMarkerFilter) or StemmerOverrideFilter before a stemmer, PatternTypingFilter with DropIfFlaggedFilter, or NumericPayloadTokenFilter; then every value that contains one marked token is indexed wrong in the sink field after that token (later words unstemmed, dropped or given a payload), silently and deterministically with -ea and -da at any size, so queries on that field miss or over-match; the tee's own field, the stored value and CheckIndex are fine, a reused tee/sink chain carries the stale value into the next document, and the same filters on the tee side, or adding the attribute to the tee before newSinkTokenStream() (undocumented), avoid it.
Summary
You index one field from a TeeSinkTokenFilter and a second field from one of its sinks, wrapped in a keyword marker and a stemmer (for example SetKeywordMarkerFilter protecting 'quick', then PorterStemFilter), and expect the sink field to get what the same chain gives on the tokenizer: [the, quick, fox, jump] for 'The quick foxes jumped'. Instead it gets [the, quick, foxes, jumped]: the sink replays each token with restoreState() but never calls clearAttributes(), and the KeywordAttribute that only the sink's chain added is not in the tee's captured states, so once 'quick' is marked as a keyword every later token stays marked and is not stemmed. Nothing is thrown; a query for stemmed:fox finds nothing.
Introduced
31a5f0edcc474f739e8392eac7261b9483fc60bf (LUCENE-1693 'AttributeSource/TokenStream API improvements', GitHub #2767, svn r797665, by Michael Busch; SVN/JIRA era, no PR): it added TeeSinkTokenFilter, whose SinkTokenStream gets its own cloneAttributes() copy of the tee's attributes and an incrementToken() that only calls restoreState() [read: git blame -w -M -C in the full-history clone; exec: the introducing commit's own TeeSinkTokenFilter.java compiled over the 2.9.0 core fails, and 2.9.0 with only clearAttributes() added passes]. LUCENE-6973 (073dbdc17e3, backported as 1ff4e84f89f; first released in 5.5.0) rewrote the class and kept the restore-only incrementToken() and end(); the missing super.end() in SinkTokenStream.end() dates from LUCENE-1448 (c311d5730cb, 2009-07-25) [read] (2009-07-24 (31a5f0edcc4))
First affected version
2.9.0 [exec], the first release with TeeSinkTokenFilter (reachable there with a sink-side filter that adds its own attribute; the stock trigger, KeywordMarkerFilter or StemmerOverrideFilter before PorterStemFilter, exists from 3.1.0 [exec]); 2.4.1 has no TeeSinkTokenFilter and its predecessor TeeTokenFilter/SinkTokenizer replays correctly [exec]
Affected versions
Every release from 2.9.0 to 10.6.0 (10.6.0 by reading) and main a9779ef3c193; never fixed. [exec] 40 follow-up history runs (seed DEADBEEF): a sink-side filter adding its own attribute set on one token fails on 2.9.0 (-ea and -da), 2.9.4, 3.0.0, 3.0.3, 3.1.0 and 3.6.2, each with a passing control that adds the attribute to the tee first; the stock keyword-marker + PorterStemFilter repro fails on 3.1.0, 3.6.2, 4.0.0, 4.2.1, 4.3.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), and passes on main with the fix; the finder's JUnit fails 3 of 3 on main, 10.5.2 and 9.12.3 (-ea and -da); the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's on main (-ea and -da) and 10.5.2; the cross-model reviewers' own tests on ea57360912ed, a9779ef3c193 and 10.5.2. [read] versions between executed points from blame (the restore-only line is unchanged from 31a5f0edcc4 to main); upstream main a95c754ee003 has a byte-identical TeeSinkTokenFilter.java and branch_10x and branch_10_6 match 10.5.2, so 10.6.0 (released 2026-10-29 from branch_10_6) and the unreleased 10.x and 11.0 lines are affected (not executed). The end() half (no super.end()) has an effect only from 4.5.0, when LUCENE-3849 made the base TokenStream.end() reset attributes [read], and was not executed separately. JDK 25.0.3, aarch64; pure Java logic.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (searched 2026-10-27, plus the finder's run, read-only; the prior-report record, verdict new): three hunt prior-report tool runs (161 queries: the class side, the symptom side such as a KeywordAttribute that stays set and 'not stemmed', and the mechanism side such as replaying states without clearAttributes() and the test-framework message) over apache/lucene GitHub issues, PRs and commits, Solr, Elasticsearch, OpenSearch and Lucene.NET, the dev@, java-user@, issues@ and commits@ lists, the LUCENE, SOLR and LUCENENET JIRA archive and the web; 44 logged manual GitHub search calls (36 distinct queries) (all 11 apache/lucene issues that mention TeeSinkTokenFilter read or triaged; no PR mentions it), the changed files of all 409 open apache/lucene PRs (only #16911, the branch_10x decommission, touches TeeSinkTokenFilter.java; none touches KeywordMarkerFilter, StemmerOverrideFilter or AttributeSource.java), JIRA queries, 8 dev@/java-user@ threads read in full and 2 Stack Overflow questions. Not fixed upstream: TeeSinkTokenFilter.java on main a95c754ee003 is byte-identical to a9779ef3c193 and no commit ever added clearAttributes() or super.end() to SinkTokenStream. Nearest items, none a report of this bug: LUCENE-1801 / #2876 (2009: every Tokenizer or TokenStream that is a source of tokens must call clearAttributes(); a reviewer noted that restoreState() alone is not enough; SinkTokenStream was not changed), LUCENE-2211 / #3287 (2010: added the test-framework clearAttributes() check; TestTeeSinkTokenFilter puts the check attribute on the tee and the sinks, so the sink-only case was never exercised), LUCENE-6973 / #8030 (2016 rewrite; told users to wrap sinks in any TokenFilter), LUCENE-5687 / #6749 and LUCENE-1693 / #2767 (design discussion recommending filters on sinks), LUCENE-2901 / #3975 (why KeywordMarkerFilter only ever sets the flag), Lucene.NET PR #1288 (merged 2026-05: its analyzer flagged that SinkTokenStream.End() does not call base.End() and the PR suppressed that warning as intended parity with Java; it covers only the secondary end() part of the proposed fix and says nothing about incrementToken() or stale attributes), a 2014 dev@ thread 'BaseTokenStreamTestCase' (a committer: 'clearAttributes() is not needed before restoreState()', said of a TokenFilter inserting tokens, whose state covers the whole shared chain) and LUCENE-6667 / #7725 (the opposite problem in SynonymFilter). Gaps: keyword search only, some web queries throttled, private security reports not visible; 'not found' is the best available evidence, not proof.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the release jars 10.5.2 and 9.12.3 (this doc's test) and, in the follow-up's history probes, 2.4.1 (not affected) and 2.9.0 to 10.0.0 (affected). The fix was validated in the scratch worktree: the new test, the 3 upstream TestTeeSinkTokenFilter tests, the whole lucene/analysis/common module (1,914 tests, 0 failures, 18 skipped) with assertions on and off, and :lucene:analysis:common:check -x test.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/sinks/TeeSinkTokenFilter.java :28-58 (class javadoc: sinks 'will further do additional token filtering', example new EntityDetect(sink1); NOTE on consumption order only), :67-70 (newSinkTokenStream: new SinkTokenStream(this.cloneAttributes(), cachedStates)), :83-96 (tee captureState), :114-123 (SinkTokenStream.incrementToken: restoreState(state) only), :125-131 (end: restoreState(finalState), no super.end())
  • lucene/core/src/java/org/apache/lucene/util/AttributeSource.java :305-317 (restoreState javadoc: attributes of the target not contained in the state remain unchanged; to reset them 'the caller should first call TokenStream#clearAttributes() on the targetStream')
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java :404-405 ('Tokenizers must call AttributeSource#clearAttributes() in incrementToken()'); lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/KeywordMarkerFilter.java :43-48 and StemmerOverrideFilter.java :72-83 (set KeywordAttribute only to true; StemmerOverrideFilter skips already-keyworded tokens)
  • SECURITY.md lines 28, 46 and 50 (threat model; out-of-scope issues are public bug reports; in-scope reports)
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/sinks/TeeSinkTokenFilter.java :35-51 (class javadoc), :68-70, :83-90, :114-131
  • lucene/core/src/java/org/apache/lucene/util/AttributeSource.java :306-318 (restoreState javadoc and method)
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java :404-405
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/KeywordMarkerFilter.java :43-48
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/en/PorterStemFilter.java :54, :64
Who is affected
  • lucene/analysis.tests/src/test/org/apache/lucene/analysis/tests/TestAllAnalyzersHaveFactories.java :55-58
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/AttributeSource.java :312-316
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java :404-405
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/sinks/TeeSinkTokenFilter.java :41, :49-51, :126-131
  • lucene/CHANGES.txt :9693-9695 (LUCENE-6973, in the 5.5.0 section, header :9652)
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java :156, :160-162
  • lucene/core/src/java/org/apache/lucene/analysis/CachingTokenFilter.java :79, :84-88
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/sinks/TestTeeSinkTokenFilter.java :118-128

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

Repro files

About the diagrams

A light, step-by-step picture made in parallel with this doc from an executed probe on main a9779ef3c193 (assertions on and off), 10.5.2 and main with the fix; every code row and value drawn is checked against the source and the probe logs. The PNG is the final summary card.

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.