A BytesRef is a window on a byte array: the value is bytes[offset .. offset + length). MatchingFacetSetsCounts reads the dimension count from bytes[0] and the sets from bytes[4 .. length), both counted from the start of the array. That is right only when the offset is 0.
MemoryIndex document: the encoding of (1,2), then the document's own value (3,4) at offset 20. count() reads bytes 0-19 and counts (1,2). Beside it, the other executed inputs and controls, and the code lines that cause it. Select the still to open it at full size. Generated with AI, checked against the code.- You add one document to a
MemoryIndex. Its facet-set value (3,4) is a slice of a 40-byte buffer you filled: bytes 0-19 hold the encoding of (1,2), bytes 20-39 the encoding of (3,4). Each encoding is a 4-byte dimension count (2) and two 8-byte numbers. - You count with
MatchingFacetSetsCountsand oneExactFacetSetMatchereach for (3,4) and (1,2). You expect (3,4)=1 and (1,2)=0, which is what a compact copy of the same 20 bytes gives. - Instead you get (3,4)=0 and (1,2)=1, with no error:
count()reads the value from byte 0 of the array and ignores the offset 20, so it decodes the neighbouring (1,2). - Lucene's own backward-codecs
Lucene80DocValuesFormatinBEST_COMPRESSIONmode returns values the same way: two documents (1,2) and (3,4) count as (1,2)=2 and (3,4)=0 instead of 1 and 1. Other leading bytes count nothing or make the constructor throwNegativeArraySizeException. - The default codec returns every value at offset 0, so
FacetSetsFieldvalues indexed withIndexWriterand read the usual way are counted correctly.
Applications that count facet sets over a reader returning sliced values: a MemoryIndex fed a value cut from a shared buffer, a custom or filtering reader serving values from one array, or a field explicitly configured with the legacy Lucene80DocValuesFormat (BEST_COMPRESSION). Each such document is miscounted silently. The default codec is not affected; facet sets are experimental.
Mechanism, terms and numbers736 words
MatchingFacetSetsCounts.count() reads each document's facet-set value from BinaryDocValues#binaryValue(), but it decodes the dimension count from bytesRef.bytes[0] and runs the set decoder from position 4 to bytesRef.length, both counted from the start of the backing array instead of from bytesRef.offset. A BytesRef is documented as "a slice (offset + length) into an existing byte[]" and binaryValue() promises nothing about the offset, so whenever a reader returns the value as a slice, count() takes the dimension count, and with the stock decoders the sets too, from the bytes that start the array: silently wrong counts, a count of nothing, or a NegativeArraySizeException / ArrayIndexOutOfBoundsException from the constructor. Readers that return slices include a MemoryIndex given a sliced value, a custom or filtering reader that serves values from a shared buffer, and the backward-codecs Lucene80DocValuesFormat in BEST_COMPRESSION mode. FacetSetsField.create with IndexWriter and the default codec always gives offset 0 and is not affected. The code dates from the introduction of facet sets (LUCENE-10274, #841, first released in 9.3.0). The fix is two statements in count().
- Facet sets: a document holds one or more tuples of numbers, such as (3,4), in a
FacetSetsField, a binary doc value.MatchingFacetSetsCountscounts, perFacetSetMatcher(an exact tuple or a range per dimension), the matching sets of the hits. The classes are@lucene.experimental. - Encoding:
FacetSetsFieldpacks "a packed byte[] where the first value denotes the number of dimensions in all the sets, followed by each set's values": for the long set (3,4) that is 4 bytes for the count 2, then two 8-byte values, 20 bytes in all. - Slice: a
BytesRefis (bytes, offset, length). A reader may return a value as a window on a larger array (offset > 0) instead of a fresh array (offset 0).
count()takes the value,BytesRef bytesRef = binaryDocValues.binaryValue(); byte[] packedValue = bytesRef.bytes;, and decodes the dimension count withIntPoint.decodeDimension(packedValue, 0): byte 0 of the array, notbytesRef.offset(MatchingFacetSetsCounts.java:105-107).- It then calls the decoder for
startfromInteger.BYTESwhilestart < bytesRef.length(:124-125): positions counted from 0. - Every stock decoder treats
startas an index intobytesRef.bytes:FacetSetDecoder.decodeLongscallsLongPoint.unpack(bytesRef, start, dest), which readsbytesRef.bytesfromstart(FacetSetDecoder.java:37-40,LongPoint.java:126-134);decodeIntsdoes the same (FacetSetDecoder.java:47-52), and so does the demo's custom decoder. The two sides agree only whenbytesRef.offset == 0. - For a slice, the document is therefore decoded from the first
lengthbytes of the backing array: another set's bytes (wrong counts), bytes that match nothing, or a nonsense dimension count. Four zero bytes decode toInteger.MIN_VALUEdimensions, andnew long[numDims](:110) throwsNegativeArraySizeExceptionin the constructor (:84).
MemoryIndexkeeps the caller's value as a shallow clone, offset included (MemoryIndex.java:954), and returns it fromgetBinaryDocValues(:1684-1694). The value is a slice when the application builds aBinaryDocValuesFieldover part of its own buffer, or resets aFacetSetsFieldwith the expertField#setBytesValue, which does not copy (Field.java:329-346).- The backward-codecs
Lucene80DocValuesProducerinBEST_COMPRESSIONmode decompresses 32 documents into one array and returns each value as a window on it (Lucene80DocValuesProducer.java:902-912). - Any custom or filtering reader that serves values from a shared buffer.
- Not the default codec:
FacetSetsField.createbuilds a fresh array (FacetSetsField.java:52-77),IndexWritercopies values, and the default format returns them at offset 0.
Executed on main a9779ef3c193, assertions on and off (identical):
| reader | doc's value | counts |
|---|---|---|
MemoryIndex, compact copy of (3,4) | offset 0 | (3,4)=1 (1,2)=0 (correct) |
MemoryIndex, (3,4) as bytes 20-39 after the encoding of (1,2) | offset 20 | (3,4)=0 (1,2)=1 |
MemoryIndex, (3,4) at offset 5 of a zero-filled array | offset 5 | NegativeArraySizeException: -2147483648 |
| default codec, docs (1,2) and (3,4) | offset 0 | (1,2)=1 (3,4)=1 (correct) |
Lucene80DocValuesFormat(BEST_COMPRESSION), same 2 docs | doc 1 at offset 20 | (1,2)=2 (3,4)=0 |
| a reader serving the field's values from one array, same 2 docs | doc 1 at offset 20 | (1,2)=2 (3,4)=0 |
An independent reproducer swept offsets 1 to 24 of one slice: offsets 1-3 throw ArrayIndexOutOfBoundsException, 4-19 count nothing, 20-24 count the neighbouring set; 24 of 24 differ from the compact copy. These ranges are for that reproducer's layout: other bytes in front of the slice can give other wrong sets, a count that is right by coincidence, or a nonsense dimension count.
Exposure and likelihood379 words
- Who. Applications that count facet sets with
MatchingFacetSetsCounts(with the stock or demo decoders; a custom decoder may behave differently) over a reader that returns the values as slices: aMemoryIndexgiven a sliced value (aBinaryDocValuesFieldover a pooled or shared buffer, or a reusedFacetSetsFieldreset withField#setBytesValue); a custom or filteringLeafReaderthat serves binary doc values from a shared buffer; a field explicitly configured with the backward-codecsLucene80DocValuesFormat(BEST_COMPRESSION). All three were executed. - The Lucene80 format is not a common path. On main no codec uses it; in 10.x and 9.x only the 8.x codecs do, to read 8.x segments, which cannot hold
FacetSetsFieldvalues unless hand-encoded. For new data it takes explicit per-field configuration of a format whose own comment says it "is only used on older versions". It shows that a stock Lucene reader returns non-zero offsets. (Read, not run: on 9.x a facet-set doc-values update on an 8.7+ segment is written with that format, so it might be read back as a slice.) - Not affected.
FacetSetsField.create+IndexWriter+ the default codec: the controls pass on every executed version (9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2, main) and in every run. The test framework's randomized doc-values formats also return offset 0, which is why upstream's randomized tests cannot catch it. - Effect. Silent wrong counts in both directions (the document's own sets go uncounted, sets it does not hold are counted);
FacetResult.value, the number of documents with a match, can be wrong too (0 instead of 1 in an independent int-set repro). With other leading bytes the constructor throws instead. Search hits and the index are not affected. - Scale. Deterministic and per document: with the stock decoders, every document whose value has offset != 0 is decoded from the wrong bytes (the count is right only by coincidence, when those bytes hold the same set); one document is enough. With the Lucene80 format, 31 of the 32 values in each full block have a non-zero offset (k-1 of k in a partial last block); when the values have equal length, each is counted as its block's first value.
- How likely. Rare: facet sets are
@lucene.experimental, no use was found in Elasticsearch, OpenSearch or Solr, and nothing has been reported since 9.3.0 (2022).
The test shows that MatchingFacetSetsCounts ignores BytesRef.offset of a facet-set binary doc value: a MemoryIndex document whose value is the (3,4) encoding as a slice after an encoding of (1,2) is counted as (1,2).
/*
* 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.facet.facetset;
import java.util.List;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
import org.apache.lucene.facet.LabelAndValue;
import org.apache.lucene.index.memory.MemoryIndex;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestMatchingFacetSetsCountsSlicedValue extends LuceneTestCase {
public void testBinaryValueWithOffset() throws Exception {
BytesRef first = FacetSetsField.create("fs", new LongFacetSet(1, 2)).binaryValue();
BytesRef second = FacetSetsField.create("fs", new LongFacetSet(3, 4)).binaryValue();
byte[] buffer = new byte[first.length + second.length];
System.arraycopy(first.bytes, first.offset, buffer, 0, first.length);
System.arraycopy(second.bytes, second.offset, buffer, first.length, second.length);
MemoryIndex index = new MemoryIndex();
index.addField(
new BinaryDocValuesField("fs", new BytesRef(buffer, first.length, second.length)), null);
FacetsCollector hits =
index.createSearcher().search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
MatchingFacetSetsCounts counts =
new MatchingFacetSetsCounts(
"fs",
hits,
FacetSetDecoder::decodeLongs,
new ExactFacetSetMatcher("(3,4)", new LongFacetSet(3, 4)),
new ExactFacetSetMatcher("(1,2)", new LongFacetSet(1, 2)));
// the document's set is (3,4), in a slice after an encoding of (1,2): expected (3,4)=1, (1,2)=0
assertEquals(
List.of(new LabelAndValue("(3,4)", 1), new LabelAndValue("(1,2)", 0)),
List.of(counts.getAllChildren("fs").labelValues));
}
}
# in a copy of this finding's repro folder (repro/LFV-405/, 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/facet/build/libs/lucene-facet-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$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.facet.facetset.TestMatchingFacetSetsCountsSlicedValue
# the module(s) facet also need their third-party runtime jars
- Expected vs actual
- Expected counts
(3,4)=1, (1,2)=0; actual(3,4)=0, (1,2)=1(expected:<[(3,4) (1), (1,2) (0)]> but was:<[(3,4) (0), (1,2) (1)]>), with-eaand-da.
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
=> java.lang.AssertionError: expected:<[(3,4) (1), (1,2) (0)]> but was:<[(3,4) (0), (1,2) (1)]>
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.facet.facetset.TestMatchingFacetSetsCountsSlicedValue.testBinaryValueWithOffset(TestMatchingFacetSetsCountsSlicedValue.java:50)BytesRefis documented as "a slice (offset + length) into an existing byte[]";binaryValue()makes no offset-0 promise, and Lucene's ownMemoryIndexand Lucene80 reader return slices.- The sibling facet counters for taxonomy associations read from
bytesRef.offset. - Lucene's test framework randomizes offsets precisely "to tickle latent bugs that fail to look at BytesRef.offset".
The full argument203 words
- The contract allows slices.
BytesRef: "Represents byte[], as a slice (offset + length) into an existing byte[]" (BytesRef.java:22).BinaryDocValues#binaryValue(): "Returns the binary value for the current document ID" (BinaryDocValues.java:31-37). No javadoc ofMatchingFacetSetsCounts,FacetSetsField,FacetSetDecoderor the facet-sets guide asks for offset 0. - Lucene's own readers return slices.
MemoryIndexkeeps the caller's value, offset included (MemoryIndex.java:954;Field#setBytesValue: "the provided BytesRef is not copied"), and the Lucene80 compressed reader returns windows on a decompressed block. - The same module honours the offset.
TaxonomyFacetIntAssociationsandTaxonomyFacetFloatAssociationsloop frombytesRef.offsettobytesRef.offset + bytesRef.length(TaxonomyFacetIntAssociations.java:76-80,TaxonomyFacetFloatAssociations.java:184-188). - Lucene hunts this bug class.
newBytesRefin the test framework "sometimes uses a non-zero offset ... to tickle latent bugs that fail to look at BytesRef.offset" (LuceneTestCaseParent.java:1165-1213). Earlier fixes of the same kind: LUCENE-10052 ("FSTTermsReader.IntersectEnum was illegally ignoring BytesRef.offset"), LUCENE-5339 (taxonomy facet counts ignoredIntsRef.offset), LUCENE-4423, and even a latent one in 2010, "fix nasty bug (doesnt affect any codecs today): respect bytesref offset". - History. The lines are unchanged since #841 introduced facet sets; its review never discusses offsets.
- The fix keeps offset-0 behaviour. For offset 0 the fixed code computes the same positions.
The devil's-advocate check182 words
The intended-behaviour checker built the strongest case for "works as designed": facet sets are documented to be indexed with FacetSetsField, whose factory always gives offset 0; the default codec, SimpleText and every randomized test format return offset 0; the classes are @lucene.experimental; Field#setBytesValue is "Expert"; the Lucene80 format "is only used on older versions"; and FacetSetDecoder.decode never says whether start is absolute or relative to the offset.
Why it fails: nothing makes offset 0 a precondition, and the triggers use only public API and legal inputs: a stock reader (MemoryIndex) given the documented encoding through a plain BinaryDocValuesField, and a stock, registered, writable doc-values format. The decoder question only decides where the fix goes: every stock and demo decoder treats start as absolute, and with either reading today's count() takes the dimension count from the wrong place. (It does add a compatibility caveat to the recommended fix for custom decoders that read start as relative; see the fix trade-offs.) These points lower reach and severity; they do not make the behaviour intended. Verdict: not intended, a defect.
Recommended Decode at the value's offset in count()
In count(), read the dimension count at bytesRef.offset and run the decoder from offset + 4 to offset + length: two statements, the decoders unchanged. With it both repro files pass with assertions on and off (and 10 iterations), the whole lucene/facet module passes (310 tests), and lint, format and javadoc checks pass.
- int numDims = IntPoint.decodeDimension(packedValue, 0);
+ int numDims = IntPoint.decodeDimension(packedValue, bytesRef.offset);
...
- for (int start = Integer.BYTES; start < bytesRef.length; ) {
+ for (int start = bytesRef.offset + Integer.BYTES, end = bytesRef.offset + bytesRef.length;
+ start < end; ) {
start += facetSetDecoder.decode(bytesRef, start, dimValues);All options and trade-offs5 options ยท 694 words
Option A was validated through gradle in a scratch worktree of main a9779ef3c193; option B with the classpath runner only. The others are untested alternatives.
Two statements in MatchingFacetSetsCounts.count(); FacetSetDecoder, LongPoint.unpack and custom decoders stay as they are. Add one javadoc line on FacetSetDecoder#decode (start is an index into bytesRef.bytes) and a CHANGES.txt note, since a custom decoder that read start as relative breaks (trade-offs).
- int numDims = IntPoint.decodeDimension(packedValue, 0);
+ int numDims = IntPoint.decodeDimension(packedValue, bytesRef.offset);
...
- for (int start = Integer.BYTES; start < bytesRef.length; ) {
+ for (int start = bytesRef.offset + Integer.BYTES, end = bytesRef.offset + bytesRef.length;
+ start < end; ) {
start += facetSetDecoder.decode(bytesRef, start, dimValues);Trade-offs
- Matches every stock and demo decoder (absolute
start); the same positions as today for offset-0 values; no copy and no allocation. FacetSetDecoder#decodedoes not say whetherstartis absolute or relative tobytesRef.offset. An outside decoder that reads it as relative can count a slice correctly today, whenever the backing array starts with the same 4-byte dimension count (for example every value but the first in an equal-dimension Lucene80 block, or theMemoryIndexbuffer of the repro), and breaks with this fix, which makes it add the offset twice. Executed with such a decoder on the repro's offset-20 slice: stock main counts(3,4)=1 (1,2)=0(correct); with the fix it throwsArrayIndexOutOfBoundsException: Index 44 out of bounds for length 33. No stock or demo decoder does this. With this fix, documentstartinFacetSetDecoder#decodeand note the change inCHANGES.txt; option B avoids the issue.- Backportable: the lines are identical at 9.3.0, 9.12.3, 10.5.2 and main. Independent of the open PR #13948.
What the checks validated
- Gradle, scratch worktree of main
a9779ef3c193with both repro files and thelucene/memorytest dependency: both files pass with assertions on and off (4 of 4 tests each time), and the SlicedValues test over 10 iterations (30 of 30). - The whole
lucene/facetmodule (facet sets, taxonomy, sorted-set, range and drill-sideways facets, both repro files included): 41 classes, 310 tests, 0 failed, 8 skipped; stock (SlicedValues test added): 309 tests, 2 failed (its two slice tests). :lucene:facet:ecjLint,checkGoogleJavaFormatandrenderJavadoc: BUILD SUCCESSFUL. The fix plus thebuild.gradleline and both tests applies cleanly to a cleana9779ef3c193tree.- The hunt's tests (finder, independent reproducer, the cross-model stage's test, devil's advocate) and the counts-outside-the-result-set probe all pass with the same fix.
If bytesRef.offset != 0, copy the value into a reused BytesRefBuilder and decode the copy (+9 lines).
Trade-offs
Works whatever a decoder thinks start is relative to; no change for offset-0 values; but one copy per sliced value for slice-returning readers (31 of the 32 values in each full Lucene80 block).
What the checks validated
Classpath runner only: this doc's SlicedValues test, the finder's and the devil's advocate's tests pass with it (assertions off). Not run through gradle or the module suite.
Read the dimension count at bytesRef.offset and have decodeLongs, decodeInts and LongPoint.unpack add bytesRef.offset.
Trade-offs
Changes a public core method (LongPoint.unpack) and the meaning of start for every custom decoder. Not recommended; not run.
The open, stale PR "Allow reading binary doc values as a RandomAccessInput" (2024, approved, never merged) rewrites count() and would read at the right offset as a side effect.
Trade-offs
A large API change across many files; the two-statement fix is independent of it and backportable.
With assertions off, a value whose first 4 bytes decode to 0 dimensions makes count() loop forever (each decode returns 0 bytes). With assertions on, the stock ExactFacetSetMatcher throws AssertionError: Encoded dimensions (dims=0) is incompatible with FacetSet dimensions (dims=2) instead (ExactFacetSetMatcher.java:39-45); a matcher without that assert would still spin.
Trade-offs
Not this bug: the same loop happens for a hand-built value at offset 0. A clear exception would close both.
A downstream that hides documents with filter queries or deletes and counts facet sets through such a reader can show counts taken from hidden documents' sets (executed with the Lucene80 compressed format), though hidden documents are never returned as hits.
A bytecode scan of every non-sandbox module jar for code that reads BytesRef.bytes without ever using BytesRef.offset flagged MatchingFacetSetsCounts.count. A test harness that compares each consumer's result on a slice with its result on a compact copy then saw 30 of 30 mismatches on the stock jars, and a JUnit confirmed it.
The method in full189 words
Method: semi-formal, a static def-use scan plus a metamorphic harness on the real jars, in a hunt unit on BytesRef offset and length handling (2026-10-26, main ea57360912ed).
- Scan. An ASM-based def-use pass over the bytecode of every non-sandbox module flagged array reads of a
BytesRef'sbyteswhose receiver never has itsoffsetread. A mutation probe checked the scanner: all 7 planted defective methods were flagged; of 4 correct ones, 3 got no flag and 1 only a length-bound flag. It flaggedMatchingFacetSetsCounts.countat line 106 (andLongPoint.unpackandFacetSetDecoder.decodeIntson their parameter, whose contract is ambiguous, so they are not reported as separate bugs). - Harness. For each flagged consumer, a bounded harness compared f(slice) with f(compact copy) on the stock jars:
MatchingFacetSetsCountsover aMemoryIndexmismatched in 30 of 30 cases, while planted vacuity mutants reported mismatches as expected. Bounded scope only; no model checker or prover was involved. - Checks. An independent reproducer, a reading-only review by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication followed; a refute-lens review by the same outside model returned no verdict (tooling failure).
No prior report found (2026-10-26, 227 queries over GitHub issues, PRs and commits, JIRA, four Lucene mailing lists, three downstream projects and the web, plus all 411 open PRs). Closest: the open, stale PR #13948, which would fix it as a side effect without naming it.
Searches4 searches
Verdict new: 19 hits read, none the same bug. #13948 ("Allow reading binary doc values as a RandomAccessInput", 2024, approved 2024-12-17, never merged) rewrites count() for an off-heap API and reads at the right offset as a side effect; its description, comments, reviews and commits never mention the bug, so it is fix overlap, not a report. Context only: #12460 / #12459 (predecessor proposal; a 2023 review links these lines only as a call site), #841 / #11310 (introduced facet sets), #11090 / #258 (randomized BytesRef offsets in tests), and 2012-2013 offset bugs in other code (LUCENE-4423, LUCENE-4598, LUCENE-4714). Keyword search cannot prove absence.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; JIRA; the web | 2026-10-26 | the hunt's prior-report tool in four runs (classes, methods, symptoms; the generic offset bug class; the #13948 neighbourhood): 158 queries | no report; #13948 (fix overlap), #841 (introducing PR), older offset bugs in other code |
| GitHub (apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch, OpenSearch, all of GitHub) and the web | 2026-10-26 | 58 manual GitHub searches and 11 web searches | only #13948, #12460, #12459, #841, #11742 / #11764 and #11664 name these classes; none reports the bug |
| All open apache/lucene PRs | 2026-10-26 | changed-file scan of 411 PRs | facet-set files touched only by #13948 (count rewrite) and #12862 (adds an unsupported method) |
| dev@ thread "Off-heap binary doc values" (Dec 2024) | 2026-10-26 | all 6 mails read | design discussion for #13948; no mention of offsets in facet sets |
Present since facet sets were added: LUCENE-10274 (#841, f6bb9d218c0, merged 2022-06-25; 9.x backport #981), first released in 9.3.0. Executed: 9.2.0 has no facet-set API; 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2 and main fail. Not fixed upstream.
Versions tested6 rows
Introduced by f6bb9d218c0 on main, "LUCENE-10274: Add FacetSets faceting capabilities (#841)" (PR by @mdmarshmallow, merged 2022-06-25), and its 9.x backport 0e1502c12ba (#981, the same day). It added the whole facetset package with count()'s decode from offset 0, the decoders that take start as an absolute index, and LongPoint.unpack(BytesRef, int, long[]). git blame puts the buggy lines on it; the four later commits on the file (#1015 fast-match query, #11764, #13328, #14761) leave them alone. Not a regression: nothing earlier to bisect. The MemoryIndex trigger (a shallow clone of the caller's value, LUCENE-10405, #647) is in 9.1.0 and later, so 9.3.0 was exposed from the start. All 30 release tags from 9.3.0 to 10.5.2 contain it; upstream main 20ced71c1e6e, branch_10x and branch_10_6 still decode at offset 0 (read 2026-10-27).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.2.0 | API absent | the repro does not compile: no facet-set classes |
| 9.3.0 (first release with facet sets) | affected | finder's repro fails 2 of 3, assertions on and off: (3,4)=0 (1,2)=1, and NegativeArraySizeException; the IndexWriter control passes |
| 9.4.0, 9.12.3, 10.0.0 | affected | the same 2 of 3 (9.12.3 with assertions on and off); the independent reproducer fails 4 of 5 on 9.4.0 and 9.12.3 |
| 10.5.2 | affected | finder's repro 2 of 3; this doc's tests: SlicedValues 2 of 3, MemoryIndex 1 of 1 (assertions off) |
main a9779ef3c193 | affected | this doc's tests fail through gradle and the classpath runner, assertions on and off; the hunt's repros still fail |
| fix (two statements in count()) | not affected | every repro and the whole facet module pass |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; a reading-only review by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication confirmed it with high confidence; and this doc's gradle runs reproduce it on a9779ef3c193.
Checks and verdicts276 words
Every executing check wrote or re-ran its own JUnit on stock main; every control passed. The refute-lens cross-model review produced no verdict in 4 attempts (tooling failure, not a refutation).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Scan plus harness; its JUnit fails 2 of 3 with assertions on and off (the IndexWriter control passes), 20 of 20 MemoryIndex runs over 10 seeds; fails on 10.5.2, 9.12.3, 9.4.0; the two-statement fix passes 3 of 3. |
| independent reproducer | real | Own test from the claim only, outside the package: 4 of 5 fail on main, 10.5.2, 9.12.3 and 9.4.0 (offset sweep 24 of 24); own fix passes 5 of 5 and the 14 upstream facet-set tests. |
| cross-model review, intended lens | real (medium) | OpenAI GPT-5.6, reading only: intended-behaviour case weak, root cause agreed, inputs in contract. A separate int-set test fails on main, 10.5.2 and 9.4.0 and passes with the fix. |
| cross-model review, refute lens | no verdict | Four attempts ended without a verdict; a tooling failure, not a refutation. |
| intended-behaviour devil's advocate | real | Built the strongest "works as designed" case and showed why it fails; its variants (plain slices with long and int sets, Lucene80 BEST_COMPRESSION) fail 3 of 3 on main, 10.5.2 and 9.12.3. |
| adjudication | confirmed (high) | Re-ran the independent reproduction (4 of 5 fail, 5 of 5 with the fix) and the finder's test; duplicate of none. |
| this doc (gradle, a9779ef3c193) | reproduces; fix validated | Stock: the slice tests fail with assertions on and off; module 309 tests, 2 failed (the repro). Fix: all pass, module 310 tests, 0 failed; lint, format and javadoc pass. |
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 511 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,339 words
- Registry title
- MatchingFacetSetsCounts decodes the facet-set binary doc value from position 0 of BytesRef.bytes instead of BytesRef.offset, so a value that a reader returns as a slice of a larger array is counted from the wrong bytes (silently wrong facet-set counts, or NegativeArraySizeException / ArrayIndexOutOfBoundsException)
- Area
- facet / facet sets (MatchingFacetSetsCounts.count, a BinaryDocValues consumer; reached through MemoryIndex, custom readers and the backward-codecs Lucene80 BEST_COMPRESSION doc-values producer)
- Priority
- Low, rank 372: Needs experimental facet sets read through a reader that returns sliced binary doc values; the default codec returns offset 0.
- Impact class
- silent wrong results
- Impact note
- Only facet-set counting (MatchingFacetSetsCounts, @lucene.experimental, every release from 9.3.0 to 10.6.0 (10.6.0 by reading) and main) over a reader whose BinaryDocValues.binaryValue() returns the value as a slice with offset > 0 is hit, and then on every such document: a MemoryIndex given a sliced value (a plain BinaryDocValuesField over a slice, or a FacetSetsField reset with the expert Field#setBytesValue, which does not copy), a custom or filtering reader that serves values from a shared buffer, or a field explicitly configured with the backward-codecs Lucene80DocValuesFormat in BEST_COMPRESSION mode (each value is a slice of a decompressed 32-document block); FacetSetsField.create builds offset-0 values and IndexWriter with the default codec reads them back at offset 0, so ordinary facet-set use is not affected (executed control); one document suffices, with -ea and -da.
- Summary
- You count facet sets with MatchingFacetSetsCounts over a reader that hands back a document's facet-set binary doc value as a BytesRef slice of a larger array (offset != 0), for example a MemoryIndex holding a value that was packed into a shared buffer, and expect the same counts as for a compact copy of the same bytes. Instead count() reads the dimension count from bytes[0] and the dimension values from bytes[4..length) of the backing array, ignoring the offset, so the document is counted under whatever bytes start that array: a document whose set is (3,4), stored right after an encoding of (1,2), is counted as (1,2)=1 and (3,4)=0, with no error. When the leading bytes do not decode to a sane dimension count, the MatchingFacetSetsCounts constructor throws NegativeArraySizeException or ArrayIndexOutOfBoundsException instead, while the same value indexed through IndexWriter with the default codec is counted correctly.
- Introduced
- f6bb9d218c0e8315d86d2aa2ac1f5c7313925a7a, apache/lucene#841 LUCENE-10274 'Add FacetSets faceting capabilities' (PR by mdmarshmallow, merged to main 2022-06-25; branch_9x backport 0e1502c12ba93ce42e608a93ab71314a6d671a13, #981, the same day) [read: the commit added the whole facetset package, including count()'s offset-0 decode, the decoders that take start as an absolute index into bytesRef.bytes, and LongPoint.unpack(BytesRef, int, long[]); git blame on main attributes the count() lines to it and none of the 4 later commits on the file touches them]. Present since the API was created, not a regression, so there is nothing earlier to bisect; the release pair 9.2.0 (API absent, compile error) / 9.3.0 (fails) brackets it [exec] (2022-06-25)
- First affected version
- 9.3.0 (tagged 2022-07-28; the first release with facet sets) [exec: fails with -ea and -da]; 9.2.0 lacks the API [exec: compile error]; first 10.x release 10.0.0 [exec]. the hunt's record, the independent reproduction and the hunt's record said 9.4.0; the history stage corrected that (the 9.x backport is an ancestor of the 9.3.0 tag)
- Affected versions
- All 30 release tags from 9.3.0 to 10.5.2, 10.6.0 (by reading) and main ea57360912ed; not fixed upstream (main cf398bfb142c, branch_10x and branch_10_6, read 2026-10-26). [exec] The finder repro fails the same 2 of 3 tests (its IndexWriter control passes) on 9.3.0 (-ea and -da), 9.4.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's test fails on 9.4.0, 9.12.3 and 10.5.2 (-ea and -da each) and main; a separate test on 9.4.0, 10.5.2 and main; the intended-behaviour check's variants on 9.12.3, 10.5.2 and main; the security probe on 9.12.3, 10.5.2 and main. [read] The 30 tags come from git tag --contains on both introducing commits, and the decode lines are identical at 9.3.0, 9.12.3, 10.5.2 and main; releases between those executed were checked by tag scan only, and old releases ran on JDK 25
- 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
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new: 227 recorded queries, namely 158 from the scripted prior-report search in four runs over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 58 manual GitHub searches (apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch, OpenSearch, all of GitHub) and 11 web searches; the changed files of all 411 open apache/lucene PRs; the dev@ thread 'Off-heap binary doc values' read in full; 19 hits read and classified, none the same bug; the independent reproduction's and the intended-behaviour check's searches found nothing either); not fixed upstream (main cf398bfb142c, branch_10x and branch_10_6, read 2026-10-26, still decode at offset 0). Closest item, fix overlap only: open, stale #13948 (iverase, 2024, 'Allow reading binary doc values as a RandomAccessInput'; approved 2024-12-17, never merged, last activity 2025-10-16) rewrites count() for an off-heap API and would read at the right offset as a side effect, but its description, comments, reviews and commits never mention the bug. Context, not reports: #12460 / #12459 (predecessor DataInput proposal; a 2023 review links the affected lines only as a call site), #841 / #11310 (introduced facet sets; no offset discussion), #11090 / #258 LUCENE-10052 (randomized BytesRef offsets in tests; same bug class, other code), and 2012-2013 offset bugs in other code (LUCENE-4423, LUCENE-4598, LUCENE-4714)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): this doc's two JUnit files through gradle in a scratch worktree, stock and with the fix (assertions on and off; 10 iterations), plus the wholelucene/facetmodule stock and with the fix (2026-10-27; the stock MemoryIndex test with assertions on and the stock 10-iteration gradle run come from the independent fact check); the classpath runner on main and on the 10.5.2 jars. Hunt runs on mainea57360912edand the 9.2.0 (API absent), 9.3.0, 9.4.0, 9.12.3, 10.0.0 and 10.5.2 jars; the main-refresh re-run ona9779ef3c193still reproduces.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:38 (at ea57360912ed) (@lucene.experimental), :105-107 (bytesRef = binaryDocValues.binaryValue(); packedValue = bytesRef.bytes; numDims = IntPoint.decodeDimension(packedValue, 0)), :110 (new long[numDims]), :124-125 (for start = Integer.BYTES; start < bytesRef.length: start += facetSetDecoder.decode(bytesRef, start, dimValues))lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:37-52 (at ea57360912ed) (decodeLongs -> LongPoint.unpack(bytesRef, start, dest); decodeInts reads IntPoint.decodeDimension(bytesRef.bytes, start + ...): start is an absolute index); lucene/core/src/java/org/apache/lucene/document/LongPoint.java:126-133 (unpack indexes bytesRef.bytes from start without bytesRef.offset)lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22 (at ea57360912ed) ('Represents byte[], as a slice (offset + length) into an existing byte[]'); lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:31-37 (binaryValue(), no offset-0 promise); lucene/core/src/java/org/apache/lucene/document/Field.java:329-346 (setBytesValue: 'the provided BytesRef is not copied'); lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (binary value kept as a shallow clone, offset included); lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesProducer.java:837-912 (values returned as slices of one decompressed block, offset at :909) and Lucene80DocValuesFormat.java:166-175, :205 (writer kept; 32 documents per compressed block); lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetIntAssociations.java:75-79 (a sibling consumer that honours the offset)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:38, 84, 105-110, 124-125 (at a9779ef3c193)lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:37-58; lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetsField.java:22-77lucene/core/src/java/org/apache/lucene/document/LongPoint.java:126-134; lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22, 100-109lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954, 1684-1694, 1728-1731; lucene/core/src/java/org/apache/lucene/document/Field.java:329-346lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesProducer.java:837-912; Lucene80DocValuesFormat.java:204-205lucene/demo/src/java/org/apache/lucene/demo/facet/CustomFacetSetExample.java:361-366
- Who is affected
lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesFormat.java:166-170a Lucene clone releases/lucene/9.12.3: lucene/backward-codecs/.../lucene87/Lucene87Codec.java:103-117; lucene/core/.../index/ReadersAndUpdates.java:575, 619 (read only)lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomCodec.java:261-265
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22; lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:31-37lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetIntAssociations.java:76-80; TaxonomyFacetFloatAssociations.java:184-188lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1165-1213
- TestMatchingFacetSetsCountsSlicedValue.java (JUnit test):
repro/LFV-405/src/org/apache/lucene/facet/facetset/TestMatchingFacetSetsCountsSlicedValue.java(54 lines)
Made in parallel with this doc from a small probe run on main a9779ef3c193 (assertions on and off, and with the fix); every value on screen was printed by the probe or comes from the code. The example is a plain BinaryDocValuesField slice in a MemoryIndex, with the zero-prefix and Lucene80 cases beside it.
assets/LFV-405/bug.png(511 KB)
![Diagram: MatchingFacetSetsCounts decodes each facet-set value from bytes[0], ignoring BytesRef.offset. A buffer holds the encoding of (1,2) in bytes 0-19 and of (3,4) in bytes 20-39; the document's value is the slice at offset 20, (3,4). count() reads bytes 0-19 and counts (1,2): expected (3,4)=1, (1,2)=0; actual (3,4)=0, (1,2)=1, no exception. Other inputs: zeros before the slice at offset 5 throw NegativeArraySizeException -2147483648; Lucene80DocValuesFormat BEST_COMPRESSION, doc 1 at offset 20, gives (3,4)=0 and (1,2)=2 instead of 1 and 1; the compact copy in a MemoryIndex and IndexWriter with the default codec count correctly. Cause: MatchingFacetSetsCounts.count lines 105-107 decode the header at position 0 and line 124 loops from 4 to bytesRef.length; the decoders treat start as an index into bytesRef.bytes. Fix: add bytesRef.offset to both.](../assets/LFV-405/bug.png)