All findings · Lucene finding · LFV-1009 · facetsetsfield-buffer-sized-from-first-set

FacetSetsField.create sizes its buffer from the first facet set, so a later, larger custom set can throw ArrayIndexOutOfBoundsException

FacetSetsField.create sizes its buffer from the first facet set, so a custom compressed FacetSet that packs larger than the first can crash it: sets of 2 and 7 bytes get 8 bytes for 13 and throw ArrayIndexOutOfBoundsException. Swapped, they work; built-in sets are fixed-size and safe.

Summary

Priority
Low, rank 659A loud ArrayIndexOutOfBoundsException that needs a hand-written variable-size FacetSet in the experimental facet-sets module, so it sits with LFV-606, where a custom implementation the javadoc permits causes a crash.
Impact
crash
Since
9.3.0 (#841, 2022)introduced 2022-06-25 · about 4 years
Affected
9.3.0 to 10.6.0 and main
Status
documented
Found by
code reading while modelling, then an exhaustive check on the real jar and one JUnit

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

All the facet sets of one field go into one byte array. create sizes that array from the first set alone, but every set then writes as many bytes as it needs.

Background: facet sets in one minute

  1. A FacetSet is a small tuple of values per document, for example (year, month). FacetSetsField.create(name, sets...) packs the sets it is given into one binary doc-values field; a field of that name can be added to a document only once.
  2. Each set writes its own bytes with packValues(buf, start), which returns how many it wrote, and reports its size with sizePackedBytes(). The built-in Int/Float and Long/DoubleFacetSet always use 4 or 8 bytes per value.
  3. You may write your own FacetSet with your own encoding, for example a compressed one, plus a matching FacetSetDecoder that MatchingFacetSetsCounts uses to count.
Summary card for FacetSetsField.create("f", set(7), set(2^40)) with a compressed one-dimension FacetSet: the sets pack to 2 and 7 bytes, create allocates 4 + 2 x 2 = 8 bytes for the 13 it needs, and the second set's write throws ArrayIndexOutOfBoundsException at index 8; with the larger set first it allocates 18 bytes and keeps 13; the fix sizes the buffer as 4 plus the sum of the sets' sizes.
The minimal repro step by step: the user's compressed sets, create sizing the buffer from the first set (8 bytes for 13) and the write past the end, the swapped order that works, and the fix that adds up every set's own size. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Write a one-dimension FacetSet that compresses its value: one length byte plus the value's significant bytes, with sizePackedBytes() returning that exact size. The value 7 packs to 2 bytes, the value 2^40 to 7 bytes.
  2. Build the field for a document with both: FacetSetsField.create("f", set(7), set(2^40)). You expect a field of 4 + 2 + 7 = 13 bytes: same class, same dimensions, so none of create's documented errors applies.
  3. Instead create allocates 4 + 2 × 2 = 8 bytes, sized from the first set only, while each set writes its own bytes. The second set's write reaches byte 8: ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8.
  4. Swap the order and it works: create("f", set(2^40), set(7)) allocates 4 + 7 × 2 = 18 bytes, keeps 13, and the document indexes and counts 1 and 1. In general it fails whenever the sets' average size exceeds the first set's.

Who is affected

Only applications with their own FacetSet whose packed size differs per instance (a compressed or variable-length encoding, which the docs invite) that pass a smaller set before a larger one. That document cannot be built in that order; nothing wrong is written. Every release since 9.3.0, any codec, assertions on or off. No such public FacetSet was found.

Mechanism, terms and numbers676 words

FacetSetsField.create(name, sets...) packs the facet sets of one field into one binary doc-values value. It allocates Integer.BYTES + facetSets[0].sizePackedBytes() * facetSets.length bytes (FacetSetsField.java:55 on main 645d4fe14a20) and then advances its write offset by what each set's packValues really writes (:74). FacetSet.sizePackedBytes() is per instance ("the size of the packed values in this facet set"; an upper limit is allowed when the size is unknown in advance, FacetSet.java:52-58), and FacetSets.adoc:137-139 invites custom encodings "e.g. by compressing them". So a custom compressed set that packs larger than the first set can overrun the buffer: create("f", set(7), set(2^40)) with sets of 2 and 7 bytes allocates 8 bytes for 13 and throws ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8, while the same two sets with the larger first pack, index and count correctly. Packing fails exactly when the sets' mean packed size exceeds the first set's (726 of 1,554 lists of 1-4 sets of 1-6 bytes, checked exhaustively on the real jar). The built-in Int/Long/Float/DoubleFacetSet are fixed-size and cannot trigger it. The exception is thrown while the field is built, so nothing wrong is indexed. Present since facet sets were added in 9.3.0 (#841); a fix that sums every set's size (a four-line loop) passes the repro and the whole facet module.

Terms

  • FacetSet (FacetSet.java, @lucene.experimental): a set of dims values per document. packValues(byte[] buf, int start) "Packs the dimension values into the given byte[] and returns the number of packed-values bytes ... you can override to implement your own scheme" (:39-43). sizePackedBytes(): "Returns the size of the packed values in this facet set. If the value is unknown in advance (e.g. if the values are compressed), this method can return an upper limit." (:52-58).
  • FacetSetsField (FacetSetsField.java): a BinaryDocValuesField whose value is the number of dimensions (an int, 4 bytes) "followed by each set's values" (:23-27). create requires that all sets "must have the same number of dimensions" and throws IllegalArgumentException "if the field name is null or the given facet sets are invalid" (:32-39); the code also requires the same class (:66-73).
  • FacetSetDecoder / MatchingFacetSetsCounts: at search time a decoder reads one set and "returns the number of bytes read" (FacetSetDecoder.java:54-58); the counter advances by that value (MatchingFacetSetsCounts.java:124-125). So counting already handles sets of different sizes, given a matching decoder; the stock decodeLongs and decodeInts are fixed-width, like the built-in sets (FacetSetDecoder.java:37-52).

The bug

  1. FacetSetsField.java:55: byte[] buf = new byte[Integer.BYTES + facetSets[0].sizePackedBytes() * facetSets.length]; sizes the buffer from the first set only.
  2. :74: offset += facetSet.packValues(buf, offset); advances by what each set really wrote.
  3. The writes need 4 + sum(sizes) bytes; the buffer has 4 + n * sizes[0]. It overflows exactly when sum(sizes) > n * sizes[0], that is when the sets' mean size exceeds the first set's; for two sets, when the second is larger.
  4. The buffer is a fresh Java array, so the JVM's bounds check throws ArrayIndexOutOfBoundsException from inside the set's packValues, out of create, before any document reaches IndexWriter.

The numbers

Bytes printed by an executed probe on main: the dims int is 80 00 00 01, set(7) packs 01 07, set(2^40) packs 06 01 00 00 00 00 00. Smaller first: buffer 8 bytes, holds 80 00 00 01 01 07 06 01, and the next write, at index 8, throws. Larger first: buffer 18 bytes, the field keeps 13 (80 00 00 01 06 01 00 00 00 00 00 01 07), and MatchingFacetSetsCounts counts 1 document for each value. With three sets of 2, 2 and 7 bytes the buffer is 10 bytes and the write fails at index 10. A bounded-exhaustive check (1 to 4 sets, sizes 1 to 6, 3,108 checks) found 726 of 1,554 lists failing, exactly those the formula predicts, and 0 failures when all sizes are equal or every set returns one class-wide bound.

Exposure and likelihood283 words
  • Who is exposed. Applications that write their own FacetSet subclass whose packed size differs from one instance to the next (compressed, varint or length-prefixed) and return each set's exact size from sizePackedBytes(), as the javadoc describes. The four built-in sets (IntFacetSet, FloatFacetSet: dims * 4; LongFacetSet, DoubleFacetSet: the default dims * 8) and the demo's TemperatureReadingFacetSet (a constant size, CustomFacetSetExample.java:353-355) cannot trigger it.
  • Opt-in API. Facet sets are an @lucene.experimental part of the facet module. Within it the failure does not depend on IndexWriterConfig, the codec or assertions: it happens while the field is built.
  • Effect. The field cannot be built with its sets in that order, so the document cannot be indexed with it. The field "can be added to a document only once, so you will need to construct all the facet sets in advance" (FacetSets.adoc:98-99), so all the sets for that field go through this one call (an application may still add other FacetSetsFields under other names). The exception comes from a fresh, bounds-checked Java array while the field is built, before anything reaches IndexWriter: no corrupt data, no wrong counts.
  • Scale. Any create call with two or more sets whose mean packed size exceeds the first set's: in the bounded check, 726 of 1,554 lists of 1 to 4 sets.
  • Workarounds today. Pass the largest-packing set first (then n * sizes[0] >= sum(sizes)), or return one bound valid for every instance from sizePackedBytes().
  • How likely: rare. A public code search for sizePackedBytes "extends FacetSet" found 39 files, all copies of Lucene's own sets; no outside variable-size FacetSet was found, and nobody has reported it since 9.3.0.

Minimal repro

One JUnit test on the public API: two sets of a one-dimension, length-prefixed FacetSet (2 and 7 packed bytes), smaller first. It fails on main, 10.6.0, 10.5.2 and 9.3.0 with assertions on and off, and passes unchanged with the fix, so it is also the regression test.

TestFacetSetsFieldVariableSizeSets.java (lucene/facet, public API, 1 test)64 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.facet.facetset;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFacetSetsFieldVariableSizeSets extends LuceneTestCase {

  private static FacetSet lengthPrefixed(long value) {
    int n = Math.max(1, (Long.SIZE - Long.numberOfLeadingZeros(value) + 7) / 8);
    return new FacetSet(1) {
      @Override
      public long[] getComparableValues() {
        return new long[] {value};
      }

      @Override
      public int packValues(byte[] buf, int start) {
        buf[start] = (byte) n;
        for (int i = 0; i < n; i++) {
          buf[start + 1 + i] = (byte) (value >>> (8 * (n - 1 - i)));
        }
        return 1 + n;
      }

      @Override
      public int sizePackedBytes() {
        return 1 + n;
      }
    };
  }

  public void testSmallerSetFirst() {
    // expected: create() packs 2 + 7 bytes; actual: ArrayIndexOutOfBounds (buffer = 4 + 2 * 2)
    FacetSet small = lengthPrefixed(7);
    FacetSet large = lengthPrefixed(1L << 40);
    try {
      FacetSetsField.create("f", small, large);
    } catch (ArrayIndexOutOfBoundsException e) {
      throw new AssertionError(
          "expected a field for sets of "
              + small.sizePackedBytes()
              + " and "
              + large.sizePackedBytes()
              + " packed bytes, got "
              + e,
          e);
    }
  }
}
Command
cp $REPRO/TestFacetSetsFieldVariableSizeSets.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/facetset/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.facetset.TestFacetSetsFieldVariableSizeSets -Ptests.seed=DEADBEEF -Ptests.asserts=true
# fails the same way with -Ptests.asserts=false
Expected
FacetSetsField.create("f", set(7), set(2^40)) returns a field holding 4 + 2 + 7 = 13 bytes.
Observed
ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8 from FacetSetsField.toPackedValues(FacetSetsField.java:74).

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

More files, environment, full output300 words

TestFacetSetsFieldVariableSizeSets (64 lines with the license header) sits in package org.apache.lucene.facet.facetset next to the upstream facet-set tests but uses only public API. Both sets come from one factory method, so they are instances of one anonymous class and create's same-class check passes. The input is fixed and the test is single-threaded and needs no index; LuceneTestCase still randomizes its environment, and every seed tried fails (DEADBEEF and 5EED1009 for this test, 5EED4413 and C0FFEE01 for the finder's equivalent one). The file passes the facet module's google-java-format, ecjLint and forbiddenApis checks.

The test only builds the field. Indexing and counting are not needed: the exception is thrown inside create. The larger-first control (index, commit, count 1 and 1 with MatchingFacetSetsCounts) was run by the finder on every version and is not part of the minimal repro.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.3.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform run; the code path is plain Java)
Command (environment)
cp $REPRO/TestFacetSetsFieldVariableSizeSets.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/facetset/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.facetset.TestFacetSetsFieldVariableSizeSets -Ptests.seed=DEADBEEF -Ptests.asserts=true

Observed output

# gradle, clean main 645d4fe14a20 + the test, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with =false)
TestFacetSetsFieldVariableSizeSets > testSmallerSetFirst FAILED
    java.lang.AssertionError: expected a field for sets of 2 and 7 packed bytes, got java.lang.ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:86C9314D31A6E643]:0)
        at org.apache.lucene.facet.facetset.TestFacetSetsFieldVariableSizeSets.testSmallerSetFirst(TestFacetSetsFieldVariableSizeSets.java:58)

        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8
            at org.apache.lucene.facet.facetset.TestFacetSetsFieldVariableSizeSets$1.packValues(TestFacetSetsFieldVariableSizeSets.java:35)
            at org.apache.lucene.facet.facetset.FacetSetsField.toPackedValues(FacetSetsField.java:74)
            at org.apache.lucene.facet.facetset.FacetSetsField.create(FacetSetsField.java:45)
            at org.apache.lucene.facet.facetset.TestFacetSetsFieldVariableSizeSets.testSmallerSetFirst(TestFacetSetsFieldVariableSizeSets.java:52)
:lucene:facet:test (FAILURE): 1 test, 1  failure

# standalone JUnit runner on the built jars, seed DEADBEEF
main 645d4fe14a20  -ea   tests_run=1 failures=1
main 645d4fe14a20  -da   tests_run=1 failures=1   (also seed 5EED1009)
10.6.0             -ea   tests_run=1 failures=1
10.5.2             -da   tests_run=1 failures=1
9.3.0              -ea   tests_run=1 failures=1
  => the same AssertionError and cause frames (FacetSetsField.java:74 <- :45)

Why it looks unintended

  • sizePackedBytes() is per instance, "the size of the packed values in this facet set", and may be an upper limit "if the value is unknown in advance"; nothing requires a class-wide bound.
  • The docs invite compressed encodings; counting advances by each set's decoded length.
  • create documents only a same-dimensions rule, yet the same two sets work or crash by order.
The full argument400 words
  • Contract. FacetSet.java:52-58: "Returns the size of the packed values in this facet set. If the value is unknown in advance (e.g. if the values are compressed), this method can return an upper limit." An instance method about "this facet set". It permits an upper limit when the size is unknown in advance; it does not say that a set whose exact size is known must return a bound shared by its whole class. Its own example of an unknown size is compressed values, which a maintainer may read as pointing to such a bound (see the strongest case below). packValues "returns the number of packed-values bytes ... you can override to implement your own scheme" (:39-43).
  • The docs invite variable sizes. FacetSets.adoc:137-139: "If you implement your own FacetSet and choose to encode the values differently (e.g. by compressing them), you should provide a corresponding implementation of a FacetSetDecoder." The class javadoc of FacetSetsField describes the value as the dimensions "followed by each set's values" (:23-27), with no uniform per-set size.
  • The generic path handles variable sizes. The field keeps only the bytes written (new BytesRef(buf, 0, offset), :76); FacetSetDecoder.decode returns "the number of bytes read" and MatchingFacetSetsCounts advances by it (:124-125). The stock decoders are fixed-width, like the built-in sets, so a variable-size set brings its own decoder, as FacetSets.adoc:137-139 says. Inside create, only :55 assumes that every set has the first set's size.
  • No documented precondition. create documents the same-dimensions rule and an IllegalArgumentException for invalid sets (:32-39). A size rule would be checked there, not left to an order-dependent array overrun inside the user's packValues.
  • No test pins it. The facet-set tests use only the fixed-size built-in sets; no CHANGES.txt entry or MIGRATE.md note mentions sizes.
  • History. The line and the javadoc arrived together in f6bb9d218c0e (#841, 2022-06-25). Inside #841, PR commit 40e2992e25 (shaie, 2022-06-15) summed every set's size, new byte[4 + Arrays.stream(facetSets).mapToInt(FacetSet::sizePackedBytes).sum()]; PR commit ab0d4e1624 ("Finish FacetSets.adoc", shaie, 2022-06-19) replaced it with today's line, in the same hunk that added the same-class check. So :55 has been in tension with a per-instance reading of the javadoc from the start. The only review remark on the line (gsmiller, 2022-06-15: "Maybe use Integer.BYTES instead of 4 here?") predates the switch; nobody discussed it (25 commits, 169 review comments, 62 issue comments read).
The devil's-advocate check188 words

Strongest case for "works as designed". The switch from the per-set sum to first-set-times-count was a deliberate edit, made together with the same-class check, so the authors pictured "one class, one size". Read that way, the "upper limit" clause is the hook for compressed encodings: a compressed set should return a class-wide worst case. Every shipped set is fixed-size, and the API is experimental.

Why we still read it as a bug. None of that is written down. The javadoc speaks of "this facet set" and offers an upper limit "if the value is unknown in advance"; it does not require a class-wide bound, so a set that knows its exact size and returns it is a natural reading. create documents no size rule and checks none, the same two sets work in one order and crash in the other with an undocumented ArrayIndexOutOfBoundsException, the generic counting loop advances per set, and the PR's own first version summed per-set sizes.

How strong the other reading is: moderate. The compressed-values example in the same javadoc sentence does support a class-wide-bound reading, and the switch in #841 was deliberate.

Suggested fix

Recommended Size the buffer by the sum of every set's sizePackedBytes()

Size the buffer as 4 plus the sum of every set's sizePackedBytes() (a four-line loop with Math.addExact), the per-set-sum strategy of an earlier #841 commit by Shai Erera. Validated: the repro passes, and so do the whole facet module (307 tests) and its static checks.

int sizePackedBytes = 0;
for (FacetSet facetSet : facetSets) {
  sizePackedBytes = Math.addExact(sizePackedBytes, facetSet.sizePackedBytes());
}
byte[] buf = new byte[Integer.BYTES + sizePackedBytes];
All options and trade-offs3 options · 690 words

One fix was built and validated with gradle; two alternatives were not built. A maintainer may prefer option C, which documents a class-wide bound instead.

Size the buffer by the sum of every set's sizePackedBytes() (validated by the checks) (recommended)

Replace line 55 of FacetSetsField.java with a loop that adds up every set's sizePackedBytes(). This restores the per-set-sum strategy of #841 PR commit 40e2992e25 by Shai Erera (shaie; it used Arrays.stream(...).sum()), with overflow-checked accumulation: Math.addExact turns an int overflow of the sum into an ArithmeticException. A cross-model review suggested checked accumulation; Math.addExact is the finder's way of doing it.

int sizePackedBytes = 0;
for (FacetSet facetSet : facetSets) {
  sizePackedBytes = Math.addExact(sizePackedBytes, facetSet.sizePackedBytes());
}
byte[] buf = new byte[Integer.BYTES + sizePackedBytes];

Trade-offs

  • One extra pass of n sizePackedBytes() calls per document, negligible next to packing; not measured.
  • For the fixed-size built-in sets the allocation is byte-for-byte the same as today (n equal sizes sum to n * size).
  • Sets that return an upper limit still work: the field keeps only the written bytes.
  • The final Integer.BYTES + sizePackedBytes addition is not checked, so a sum above Integer.MAX_VALUE - 4 still wraps to a negative array size. Today's unchecked sizes[0] * n can wrap too: at the same point when all sizes are equal, and never later for a list that works today (those have n * sizes[0] >= sum).
  • The new loop calls every set's sizePackedBytes() before the existing same-dimensions and same-class checks (:59-73). Valid lists behave as described; for an invalid mixed list, an unusual sizePackedBytes() of a set of another class (negative, or large enough to overflow) could now throw before the documented IllegalArgumentException. Running the checks in the summing loop would keep today's order (not built).
  • It still trusts each set: a negative sizePackedBytes(), or a packValues that writes more bytes than its sizePackedBytes() reports, still fails, as it does today.
  • A "Bug Fixes" CHANGES.txt line would be usual; none is drafted.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle: the repro fails on the clean tree (assertions on and off) and passes with the fix.
  • :lucene:facet:test --tests "org.apache.lucene.facet.facetset.*" -Ptests.seed=DEADBEEF (assertions off by the seed): 4 suites, 15 tests, 0 failed, 1 skipped (it needs assertions).
  • :lucene:facet:test -Ptests.seed=DEADBEEF -Ptests.asserts=true: the whole facet module, 307 tests, 0 failed, 7 skipped (the gradle summary line). The suite count, 40 (39 upstream plus the new one), and the skip reasons (5 nightly-only, 1 monster, 1 without a message) were transcribed from the JUnit XML, which was not kept; the independent fact check re-ran the module in its own worktree and its JUnit XML gave the same 40 suites, 307 tests and 7 skips.
  • :lucene:facet:check -x test (google-java-format, ecjLint, forbiddenApis): passes. The separate EditorConfig linter (eclint) is not configured on this machine and did not run.
  • Standalone runner with the fix shadowed in: 1 of 1 passes (-ea, -da); an executed probe then packs the smaller-first case as the 13 bytes 80 00 00 01 01 07 06 01 00 00 00 00 00 and counts 1 and 1.
  • Earlier, by the hunt: the finder's 2-test JUnit and the upstream facet-set suites (3 + 6 + 5 tests) pass with the same fix; an independent reproducer's own sum fix passes its own test.
  • Not run: nightly tests, a benchmark.

Grow the buffer before each set (not built)

Start from 4 + sizes[0] and call buf = ArrayUtil.grow(buf, offset + facetSet.sizePackedBytes()) before :74.

Trade-offs

One pass and also correct, but more code and possibly several array copies for many sets; no advantage over the sum for the small set counts facet sets are meant for.

Document a class-wide bound and validate it (not built)

Change the sizePackedBytes() javadoc to require a bound that holds for every instance of the class, and in toPackedValues throw IllegalArgumentException when a set's sizePackedBytes() exceeds the first set's.

Trade-offs

Keeps today's allocation and turns the order-dependent crash into the documented exception, but narrows a documented extension point and still rejects lists the sum accepts; compressed encodings would have to return their worst case.

Downstream note

Downstream products that feed end-user values into their own variable-length FacetSet could see a user's document fail to index depending on the order of its values; Lucene's built-in sets cannot trigger it.

How it was found

Code reading while checking multi-dimension doc-values fields that trust their first element (siblings of #16919): create sizes everything from facetSets[0]. A closed-form model (it fails iff the sizes sum to more than n times the first) was checked exhaustively on the real jar, 3,108 cases with 0 disagreements, then reduced to one JUnit.

The method in full161 words

Method: semi-formal (the hunt's hunt unit for binary doc-values multi-dimension fields that trust the first element's shape, 2026-10-29, main 645d4fe14a20).

  • Lead. While modelling the unit's "trusts facetSets[0]" pattern, the finder read that line 55 sizes the buffer from the first set while line 74 advances by each set's real size.
  • Model. Writes are cumulative and every size is positive, so packing overflows iff sum(sizes) > n * sizes[0].
  • Bounded-exhaustive translation validation on the stock main jar: n = 1 to 4 sets, packed sizes 1 to 6, two size policies (exact per-instance size; one class-wide upper limit), 3,108 checks. With exact sizes, 726 of 1,554 javadoc-legal lists throw, and the observed failure set equals the model's (0 disagreements). Vacuity: 0 failures with the class-wide limit and 0 when all sizes are equal; assertions on and off identical.
  • Reduction. A public-API JUnit with a failing test and a larger-first control (index, commit, count), then minimized to the single failing test.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-29, rechecked twice on 2026-10-30): GitHub issues, PRs and commits, four mailing lists, the JIRA archive, downstream projects and the web. Only the introducing PR #841 mentions FacetSetsField or sizePackedBytes, and no open PR touches the file.

Searches4 searches

Related, not reports of this bug: #841 / #981 (introduced the code; its review never discussed per-instance sizes), #11742 (a different facet-set bug in getTopChildren, fixed by #11764), #13948 (open, read-side decoding change). Hunt neighbours with other fix sites: LFV-405 (MatchingFacetSetsCounts ignores BytesRef.offset) and LFV-351 (FacetSets.adoc says documents where the code counts sets). Tracking issue #16937 does not mention facet sets.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), mailing lists, JIRA archive, downstream, web2026-10-29three runs of a scripted prior-report search: 153 queries over issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the webno report; the only high-scoring hit is #841, the introducing PR
GitHub, manual2026-10-2932 manual searches (27 on apache/lucene), for example FacetSetsField, sizePackedBytes, toPackedValues; Elasticsearch, OpenSearch and SolrFacetSetsField and sizePackedBytes return only #841; toPackedValues nothing; 0 downstream hits
Open PRs and #8412026-10-29the changed files of all 415 open apache/lucene PRs; all 25 commits, 169 review comments and 62 issue comments of #841; LUCENE-10274's commentsonly #13948 and #12862 touch the facetset package (read side); nobody discussed per-instance packed sizes
GitHub (rechecks)2026-10-30 (twice)FacetSetsField, sizePackedBytes, toPackedValues, "FacetSet ArrayIndexOutOfBoundsException"; items mentioning facet updated, or FacetSet created, since 2026-10-29; open PRs mentioning FacetSetsField; the file's blob on upstream mainnothing new; upstream main 2f424fd428da still has the same FacetSetsField.java

History

Present since facet sets were added in 9.3.0 (#841, f6bb9d218c0e, 2022-06-25); the line and the javadoc it is in tension with came in the same commit, so it is not a regression. The file is unchanged in every release since. Executed: 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main fail; 9.2.0 has no facet sets.

Versions tested8 rows

Introduced by f6bb9d218c0e "LUCENE-10274: Add FacetSets faceting capabilities (#841)" (PR by mdmarshmallow, merged 2022-06-25; 9.3.0 CHANGES.txt credits Shai Erera, Marc D'Mello and Greg Miller), branch_9x backport 0e1502c12ba9 (#981, by shaie). git blame of FacetSetsField.java:40-78 and git log --all -S on line 55 find only these two commits. FacetSetsField.java is byte-identical (blob b2f2db7bb1fd) in all 31 release tags from 9.3.0 to 10.6.0 (30 up to 10.5.2, plus 10.6.0), on main 645d4fe14a20, on branch_10_6 and on upstream main of 2026-10-30.

Not fixed: the newest commit on the facetset package is a7704f5242d6 (2025-06-18, unrelated).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off, two seeds)
10.6.0 affectedfails (release jars, assertions on)
10.5.2 affectedfails (assertions on and off)
10.0.0 affectedfails (assertions off; the 9.x variant of the finder's test)
9.12.3, 9.4.0 affectedfail (9.12.3 assertions on and off, 9.4.0 off)
9.3.0 first releasefails (assertions on and off); the larger-first control passes
9.2.0 API absentno facetset package: the repro does not compile
main + fix not affectedthe minimal test passes; the facet module, 307 tests, 0 failed

Independent checks

An independent reproducer with its own varint FacetSet, an intended-behaviour devil's advocate, an adjudication and a credibility audit re-ran the repros and agree; reviews by another model family (OpenAI GPT-5.6) could only read code and agree on the root cause. The brief re-ran it with gradle and validated the fix.

Checks and verdicts489 words

The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, medium" (it votes not real on what it could not reproduce) while agreeing on the root cause; a separate re-run failed as stated.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateClosed-form model checked on the stock jar (3,108 checks, 0 disagreements, vacuity clean); JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and 9.4.0 with the larger-first control passing; the fix passes with the upstream facet-set suites.
cross-model review, refute lens not real, medium (procedural, reading-only)OpenAI GPT-5.6; root cause agrees, inputs in contract, intended argument weak; suggested checked (overflow-safe) accumulation, which the fix does with Math.addExact. A separate run re-ran the repro: 1 of 2 fail (-da).
Independent reproducer realOwn varint FacetSet test outside Lucene's packages fails on main (-ea, -da), 10.5.2 and 9.12.3 with "Index 6 out of bounds for length 6" (buffer 4 + 1 × 2); control passes; own sum fix passes.
Cross-model review, intended lens real, medium (reading-only)OpenAI GPT-5.6; rated the works-as-designed case weak because nothing documents it. A separate run re-ran the repro: 1 of 2 fail (-da).
Intended-behaviour devil's advocate real, medium-highBuilt the strongest intended case from the #841 PR commits and answered it; re-ran on main (-ea, -da) and 10.5.2.
adjudication confirmed, highRe-ran the independent reproduction's test (-ea) and the finder's (-da) with a fresh seed: both fail as stated, controls pass; not noise.
Registration check and credibility audit keepThe failing test alone fails 1 of 1 (-da, fresh seed) and passes with the fix; a fourth re-run on main (-ea) fails the same way.
minimal-repro audit verifiedReduced to the single failing test; fails on main (-ea, -da), passes with the fix.
brief reproduced, fix validatedGradle on the clean tree (fails, assertions on and off) and with the fix (passes; facet module 307 tests; check -x test); standalone runner on main, 10.6.0, 10.5.2 and 9.3.0.
independent fact check ok, 0 errorsRe-ran the gradle command in its own worktree (fails 1 of 1, assertions on and off, byte-identical output), the fix (facet module 307 tests, 0 failed), the release jars and its own probe; four wording nits, fixed.
cross-model fact check 4 errors: 3 fixed, 1 rejected (reading-only)OpenAI GPT-5.6. Confirmed code, bytes, counts, releases and history; two overbroad phrases and an overflow comparison fixed, overclaims softened and caveats added; one error mistook the unconfigured EditorConfig linter for ecjLint. Rated the works-as-designed case moderate.
diagram check ok, 0 errorsEvery value, line number and step on screen checked against the code and the probe; the minor wording items were fixed in a re-render.

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

Finding record

LFV-1009, snapshot of 2026-11-06

Registry title
FacetSetsField.create sizes its buffer from the first FacetSet's sizePackedBytes(), so a list of custom variable-size facet sets (e.g. compressed, which the FacetSet javadoc and FacetSets.adoc allow) throws ArrayIndexOutOfBoundsException when a later set packs to more bytes than the first, and whether a document can be built depends only on the order of its sets
Area
facet / facet sets (FacetSetsField packing of custom FacetSet encodings)
Priority
Low, rank 659: A loud ArrayIndexOutOfBoundsException that needs a hand-written variable-size FacetSet in the experimental facet-sets module, so it sits with LFV-606, where a custom implementation the javadoc permits causes a crash.
Impact class
crash
Impact note
Only applications that write their own FacetSet subclass whose packed size differs from one instance to the next (a compressed or variable-length encoding, invited by FacetSets.adoc, with sizePackedBytes() returning each set's exact size) and pass a smaller-packing set before a larger-packing one to FacetSetsField.create hit it, on every release from 9.3.0 to 10.5.2, on 10.6.0 (by reading) and on main, with any IndexWriterConfig or codec and with -ea or -da; the exception is thrown while the Field is built, so that document cannot be indexed in that order, but nothing wrong is written and the built-in Int/Long/Float/DoubleFacetSet are not affected.
Summary
You write a FacetSet subclass that compresses its value (one length byte plus the value's significant bytes, so sizePackedBytes() is 2 for the value 7 and 7 for 2^40) with a matching FacetSetDecoder, and you expect FacetSetsField.create("f", set(7), set(2^40)) to work just like the same call with the two sets swapped. Instead create allocates 4 + the first set's sizePackedBytes() times the number of sets (8 bytes) and then writes each set's own packed bytes (13 needed), so the second set's packValues throws ArrayIndexOutOfBoundsException 'Index 8 out of bounds for length 8'. With the larger set first the same two sets pack, index and count correctly.
Introduced
f6bb9d218c0e8315d86d2aa2ac1f5c7313925a7a (apache/lucene#841, LUCENE-10274 'Add FacetSets faceting capabilities', Marc D'Mello and Shai Erera; branch_9x backport 0e1502c12ba9, #981) [read: git blame of FacetSetsField.java:40-78 and git log --all -S on the line find only these two commits; the parent has no facetset package]. Inside #841, PR commit 40e2992e25 (2022-06-15) sized the buffer as 4 + the sum of every set's sizePackedBytes(); PR commit ab0d4e1624 (2022-06-19, 'Finish FacetSets.adoc') replaced that with the first set's size times the count, with no review comment on the change. Not a regression: the line and the javadoc it breaks were added together. (2022-06-25)
First affected version
9.3.0 [exec: the finder's JUnit fails with -ea and -da] (the first release with facet sets, LUCENE-10274 in its CHANGES.txt); 9.2.0 has no facetset package [exec: compile error, API absent]
Affected versions
every release from 9.3.0 to 10.5.2 and 10.6.0 (by reading) [read: FacetSetsField.java is byte-identical, git blob b2f2db7bb1fd, in all 30 release tags 9.3.0..10.5.2, main 645d4fe14a20, branch_10_6 and branch_10x]; [exec] the finder's JUnit fails on 9.3.0 (-ea, -da), 9.4.0 (-da), 9.12.3 (-ea, -da), 10.0.0 (-da), 10.5.2 (-ea, -da) and main 645d4fe14a20 (-ea, -da), always ArrayIndexOutOfBoundsException from FacetSetsField.toPackedValues:74 with the larger-first control passing; the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; branch_10_6 / 10.6.0 RC1 4ae946e5a9b and branch_10x 657014b3c57 [read: same blob; the RC jar set has no facet jar, so not executed]; not fixed upstream (upstream main head 645d4fe14a20, author date 2026-10-28; newest commit on the facetset package a7704f5242d6, 2025-06-18, unrelated; no open PR touches FacetSetsField.java or FacetSet.java). JDK 25.0.3 for all versions.
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 “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record, verdict new, plus the finder's run and the independent reproduction's, the intended-behaviour check's and the adjudication's searches): three runs of the scripted prior-report search (153 queries over GitHub issues/PRs, commits, downstream repos, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; the only high-scoring hit is #841, the introducing PR, which is not a report), 32 manual GitHub searches (27 on apache/lucene) ('FacetSetsField' and 'sizePackedBytes' return only #841, 'toPackedValues' nothing), Elasticsearch, OpenSearch and Solr (0 each), the changed files of all 415 open PRs (only #13948 and #12862 touch the facetset package, read side; none touches FacetSetsField.java or FacetSet.java), all 25 commits, 169 review comments and 62 issue comments of #841 and LUCENE-10274's comments and worklog (nobody discusses per-instance packed sizes). Recheck (2026-10-29): 'FacetSetsField' and 'sizePackedBytes' still return only #841; no apache/lucene item mentioning FacetSet updated since 2026-10-27 and none mentioning facet updated since 2026-10-28; none of the 13 issues and PRs created since 2026-10-28 (up to #16938) concerns facet sets; upstream main head is still 645d4fe14a20 and the newest facetset commit a7704f5242d6 (2025-06-18). Closest items, not reports: #841 / #981 (introduced the code), #11742 (a different facet-set bug, getTopChildren, fixed by #11764), #13948 (open, read-side decode change).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; tested 2026-10-29 and 2026-10-30) through gradle in a scratch worktree (assertions on and off) and through the standalone JUnit runner (-ea, -da, seeds DEADBEEF and 5EED1009); the Maven Central release jars 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (SHA-1 checked); 9.2.0 has no facet sets. The fix was validated in the scratch worktree: the repro, the whole lucene/facet module (307 tests, assertions on) and :lucene:facet:check -x test.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetsField.java:23-29 (at 645d4fe14a20) (class javadoc: the dims int 'followed by each set's values'; @lucene.experimental), :32-46 (create javadoc: all sets 'must have the same number of dimensions', IllegalArgumentException for a null name or invalid sets; toPackedValues called at :45), :55 ('byte[] buf = new byte[Integer.BYTES + facetSets[0].sizePackedBytes() * facetSets.length];'), :59-73 (same-dims and same-class checks), :74 ('offset += facetSet.packValues(buf, offset);'), :76 (new BytesRef(buf, 0, offset))
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSet.java:38-50 (at 645d4fe14a20) (packValues 'returns the number of packed-values bytes', 'you can override to implement your own scheme'), :52-61 (sizePackedBytes: 'Returns the size of the packed values in this facet set. If the value is unknown in advance (e.g. if the values are compressed), this method can return an upper limit.')
  • lucene/facet/docs/FacetSets.adoc:131-139 (at 645d4fe14a20) ('If you implement your own FacetSet and choose to encode the values differently (e.g. by compressing them), you should provide a corresponding implementation of a FacetSetDecoder.'); lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:54-58 (decode returns the number of bytes read); lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:125 (the counter advances by decode()'s return)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetsField.java:23-29, (at 645d4fe14a20) :32-46, :52-77
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSet.java:39-61 (at 645d4fe14a20)
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:54-58; (at 645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:124-125
  • lucene/facet/docs/FacetSets.adoc:96-99, (at 645d4fe14a20) :137-139
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/IntFacetSet.java:53-54, (at 645d4fe14a20) FloatFacetSet.java:56-57, FacetSet.java:59-61; lucene/demo/src/java/org/apache/lucene/demo/facet/CustomFacetSetExample.java:353-355
  • lucene/facet/docs/FacetSets.adoc:98-99 (at 645d4fe14a20)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSet.java:39-61; (at 645d4fe14a20) FacetSetsField.java:23-39, :55, :66-76; FacetSetDecoder.java:54-58; MatchingFacetSetsCounts.java:124-125
  • lucene/facet/docs/FacetSets.adoc:137-139 (at 645d4fe14a20)
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetsField.java:55-76 (at 645d4fe14a20) (order of the sizing and the checks)

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

Repro files

About the diagrams

It is made in parallel with this doc; every byte and result on screen comes from the code or the diagram's executed probe.

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.