Describe the bug
A cardinality aggregation fails on individual shards with EOFException: read past EOF
when it accompanies a hybrid query that has two or more subqueries. The failure comes
from the dynamic pruning optimization in CardinalityAggregator: it seeks to an offset past
the end of the doc-values slice and the shard returns a status_exception.
status_exception: Failed when performing dynamic pruning in cardinality aggregation.
You can set cluster setting
[search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinality] to 0 to disable.
caused_by: runtime_exception: java.io.EOFException: read past EOF (pos=31):
MemorySegmentIndexInput(path=".../index/_32gc.cfs")
[slice=_32gc_Lucene90_0.dvd] [slice=randomaccess]
The request still returns HTTP 200 with _shards.failed > 0, so results are silently
partial: fewer hits than requested and an understated (or zero) cardinality value. Clients
that only check the HTTP status treat the truncated response as correct.
The pos values are consistently tiny (observed 11, 31, 293), which suggests a wrongly
computed base offset rather than an off-by-a-little read near the true end of the slice.
This is not data corruption
Verified on an affected shard copy:
-
Primary and replica of the shard report an identical document count.
-
_shard_stores reports no store_exception; the shard is STARTED (Lucene checksums
pass at recovery).
-
On the same shard copy, terms, value_count, and a match_all cardinality all read
the same field successfully.
-
Most telling: keeping the query, the shard copy, and the matched document set fixed, and
changing only the aggregation:
| aggregation on the same field |
outcome |
terms |
succeeds |
value_count |
succeeds |
cardinality (pruning engaged) |
fails with read past EOF |
| no aggregation |
succeeds |
So the doc-values file is readable; only the dynamic pruning path misreads it.
Trigger conditions
Isolated by holding everything else constant and varying one ingredient at a time:
| variant |
result |
hybrid with 2 subqueries (query_string + knn) + cardinality |
fails |
hybrid with 2 subqueries (query_string + query_string) + cardinality |
fails |
hybrid with 1 subquery (query_string) + cardinality |
passes |
hybrid with 1 subquery (knn) + cardinality |
passes |
plain bool/knn/query_string (no hybrid) + cardinality |
passes |
hybrid with 2 subqueries, no cardinality aggregation |
passes |
Notes:
- A
search_pipeline (normalization processor) is not required — the failure is
identical with and without one.
- A knn/vector subquery is not required — two text subqueries reproduce it.
- Low result cardinality alone is not sufficient: a single-subquery variant returning a
distinct count of 2 (well under the default threshold of 100) succeeds.
- The failure is per-segment and per-shard-copy. A primary and its replica hold different
segments because they merge independently, so the same query fails on one copy and
succeeds on the other. Running without a preference round-robins between copies and
therefore reproduces only intermittently. Use preference=_replica / preference=_primary
to pin a copy.
- Affected segments are transient: a merge clears them and later ingest produces new ones.
Over three days we observed four distinct segments across three indexes, so this is a
recurring state, not one damaged file.
Suspected cause
This looks like the same class of bug as #17739 / #17775, where dynamic pruning in
CardinalityAggregator broke because BooleanScorer behaves differently from
DefaultBulkScorer. A hybrid query with two or more subqueries is executed by the
neural-search plugin's own scorer rather than the default one, whereas a single-subquery
hybrid degenerates to the standard path — which matches the pass/fail split in the table
above exactly. The pruning path appears to assume scorer/iterator semantics that the hybrid
scorer does not provide, and derives a doc-values offset from that assumption.
Related component
Search:Aggregations
To Reproduce
We have not reduced this to a synthetic dataset, because the failure depends on the segment
layout a shard happens to have, and merges clear the affected segments within a day or two.
The following reproduces it reliably against an index that currently has an affected segment.
- Leave
search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinality at its
default of 100.
- Have an index with a high-cardinality keyword field (hundreds of thousands of distinct
values) and enough ingest activity to keep producing small compound (.cfs) segments.
- Run a
hybrid query with two subqueries plus a cardinality aggregation on that field,
with a filter selective enough that the matched set falls under the pruning threshold.
Pin the shard copy with preference:
POST /my-index-*/_search?preference=_replica
{
"size": 0,
"query": {
"hybrid": {
"queries": [
{ "query_string": { "query": "alpha", "fields": ["content"], "default_operator": "AND" } },
{ "query_string": { "query": "beta", "fields": ["content"], "default_operator": "AND" } }
],
"filter": {
"range": { "timestamp": { "gte": "2026-06-16T00:00:00Z", "lte": "2026-07-16T00:00:00Z" } }
}
}
},
"aggs": {
"distinct": { "cardinality": { "field": "entity_id", "precision_threshold": 40000 } }
}
}
- Repeat with
preference=_primary and with the aggregation swapped for terms — both
succeed on the same data, which isolates the pruning path.
Expected behavior
The cardinality aggregation should either compute correctly under dynamic pruning, or fall
back to the non-pruning path. It should not read past the end of a doc-values slice, and a
failure in an optimization should not silently truncate results behind an HTTP 200.
Additional Details
Plugins: neural-search (required for the hybrid query), k-NN. The managed service
masks plugin versions as x.x.x.x; bundled core plugins report 3.5.0.
Host/Environment:
- OpenSearch 3.5.0 on every node, no mixed-version cluster
(from _cat/nodes?h=version; note that on a managed service GET / reports
"number": "7.10.2" because of the Elasticsearch compatibility-mode override —
the real version is in _cat/nodes, and the lucene_version: 10.3.2 plus
minimum_wire_compatibility_version: 2.19.0 in the same response give it away)
- Lucene 10.3.2
- Managed service deployment, Linux
- Default
search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinality (100)
search.concurrent_segment_search.mode: none
Workaround: disabling the optimization cluster-wide removes the failure:
PUT /_cluster/settings
{ "persistent": { "search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinality": "0" } }
Additional context
Two secondary issues that may be worth separate consideration:
- A failure inside an optional performance optimization degrades a correct result into a
silently partial one. Falling back to the non-pruning path on error would keep the
response correct at the cost of latency.
- The error message names the setting to disable the optimization, which is good, but the
response is still HTTP 200 — callers that check only the status code never see it.
Describe the bug
A
cardinalityaggregation fails on individual shards withEOFException: read past EOFwhen it accompanies a
hybridquery that has two or more subqueries. The failure comesfrom the dynamic pruning optimization in
CardinalityAggregator: it seeks to an offset pastthe end of the doc-values slice and the shard returns a
status_exception.The request still returns HTTP 200 with
_shards.failed > 0, so results are silentlypartial: fewer hits than requested and an understated (or zero) cardinality value. Clients
that only check the HTTP status treat the truncated response as correct.
The
posvalues are consistently tiny (observed 11, 31, 293), which suggests a wronglycomputed base offset rather than an off-by-a-little read near the true end of the slice.
This is not data corruption
Verified on an affected shard copy:
Primary and replica of the shard report an identical document count.
_shard_storesreports nostore_exception; the shard isSTARTED(Lucene checksumspass at recovery).
On the same shard copy,
terms,value_count, and amatch_allcardinalityall readthe same field successfully.
Most telling: keeping the query, the shard copy, and the matched document set fixed, and
changing only the aggregation:
termsvalue_countcardinality(pruning engaged)read past EOFSo the doc-values file is readable; only the dynamic pruning path misreads it.
Trigger conditions
Isolated by holding everything else constant and varying one ingredient at a time:
hybridwith 2 subqueries (query_string+knn) +cardinalityhybridwith 2 subqueries (query_string+query_string) +cardinalityhybridwith 1 subquery (query_string) +cardinalityhybridwith 1 subquery (knn) +cardinalitybool/knn/query_string(nohybrid) +cardinalityhybridwith 2 subqueries, nocardinalityaggregationNotes:
search_pipeline(normalization processor) is not required — the failure isidentical with and without one.
distinct count of 2 (well under the default threshold of 100) succeeds.
segments because they merge independently, so the same query fails on one copy and
succeeds on the other. Running without a
preferenceround-robins between copies andtherefore reproduces only intermittently. Use
preference=_replica/preference=_primaryto pin a copy.
Over three days we observed four distinct segments across three indexes, so this is a
recurring state, not one damaged file.
Suspected cause
This looks like the same class of bug as #17739 / #17775, where dynamic pruning in
CardinalityAggregatorbroke becauseBooleanScorerbehaves differently fromDefaultBulkScorer. Ahybridquery with two or more subqueries is executed by theneural-search plugin's own scorer rather than the default one, whereas a single-subquery
hybrid degenerates to the standard path — which matches the pass/fail split in the table
above exactly. The pruning path appears to assume scorer/iterator semantics that the hybrid
scorer does not provide, and derives a doc-values offset from that assumption.
Related component
Search:Aggregations
To Reproduce
We have not reduced this to a synthetic dataset, because the failure depends on the segment
layout a shard happens to have, and merges clear the affected segments within a day or two.
The following reproduces it reliably against an index that currently has an affected segment.
search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinalityat itsdefault of
100.values) and enough ingest activity to keep producing small compound (
.cfs) segments.hybridquery with two subqueries plus acardinalityaggregation on that field,with a filter selective enough that the matched set falls under the pruning threshold.
Pin the shard copy with
preference:preference=_primaryand with the aggregation swapped forterms— bothsucceed on the same data, which isolates the pruning path.
Expected behavior
The cardinality aggregation should either compute correctly under dynamic pruning, or fall
back to the non-pruning path. It should not read past the end of a doc-values slice, and a
failure in an optimization should not silently truncate results behind an HTTP 200.
Additional Details
Plugins: neural-search (required for the
hybridquery), k-NN. The managed servicemasks plugin versions as
x.x.x.x; bundled core plugins report3.5.0.Host/Environment:
(from
_cat/nodes?h=version; note that on a managed serviceGET /reports"number": "7.10.2"because of the Elasticsearch compatibility-mode override —the real version is in
_cat/nodes, and thelucene_version: 10.3.2plusminimum_wire_compatibility_version: 2.19.0in the same response give it away)search.dynamic_pruning.cardinality_aggregation.max_allowed_cardinality(100)search.concurrent_segment_search.mode: noneWorkaround: disabling the optimization cluster-wide removes the failure:
Additional context
Two secondary issues that may be worth separate consideration:
silently partial one. Falling back to the non-pruning path on error would keep the
response correct at the cost of latency.
response is still HTTP 200 — callers that check only the status code never see it.