Skip to content

Commit 8d3b485

Browse files
fix(esql): check per-shard DateFieldType for DocValuesSkipper (elastic#142752) (elastic#142800)
* fix(esql): check per-shard DateFieldType for DocValuesSkipper in SearchContextStats min/max hasDocValuesSkipper was derived once from the first shard's DateFieldType and applied globally via doWithContexts. In mixed environments (TSDB shards with DocValuesSkipper + standard shards with PointValues), calling the wrong API on some shards caused sentinel values (Long.MIN_VALUE/Long.MAX_VALUE) to leak into min/max. Replace doWithContexts with direct per-context iteration so each shard's own DateFieldType determines whether to use DocValuesSkipper or PointValues. This also simplifies the early-return guard by removing the incorrect indexed check that could bail out prematurely in mixed modes.
1 parent 1327bfa commit 8d3b485

File tree

2 files changed

+154
-49
lines changed

2 files changed

+154
-49
lines changed

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java

Lines changed: 73 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
3737
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
3838
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute.FieldName;
39-
import org.elasticsearch.xpack.esql.core.util.Holder;
4039

4140
import java.io.IOException;
4241
import java.util.LinkedHashMap;
@@ -232,74 +231,99 @@ public long count(FieldName field, BytesRef value) {
232231

233232
@Override
234233
public Object min(FieldName field) {
235-
var stat = cache.computeIfAbsent(field.string(), this::makeFieldStats);
236-
// Consolidate min for indexed date fields only, skip the others and mixed-typed fields.
237-
MappedFieldType fieldType = stat.config.fieldType;
238-
boolean hasDocValueSkipper = fieldType instanceof DateFieldType dft && dft.hasDocValuesSkipper();
239-
if (fieldType == null
240-
|| (hasDocValueSkipper == false && stat.config.indexed == false)
241-
|| fieldType instanceof DateFieldType == false) {
234+
final var stat = cache.computeIfAbsent(field.string(), this::makeFieldStats);
235+
final MappedFieldType fieldType = stat.config.fieldType;
236+
if (fieldType instanceof DateFieldType == false) {
242237
return null;
243238
}
244239
if (stat.min == null) {
245-
var min = new long[] { Long.MAX_VALUE };
246-
Holder<Boolean> foundMinValue = new Holder<>(false);
247-
doWithContexts(r -> {
248-
long minValue = Long.MAX_VALUE;
249-
if (hasDocValueSkipper) {
250-
minValue = DocValuesSkipper.globalMinValue(new IndexSearcher(r), field.string());
251-
} else {
252-
byte[] minPackedValue = PointValues.getMinPackedValue(r, field.string());
253-
if (minPackedValue != null && minPackedValue.length == 8) {
254-
minValue = NumericUtils.sortableBytesToLong(minPackedValue, 0);
240+
Long result = null;
241+
try {
242+
for (final SearchExecutionContext context : contexts) {
243+
if (context.isFieldMapped(field.string()) == false) {
244+
continue;
245+
}
246+
final MappedFieldType ctxFieldType = context.getFieldType(field.string());
247+
boolean ctxHasSkipper = ctxFieldType.indexType().hasDocValuesSkipper();
248+
for (final LeafReaderContext leafContext : context.searcher().getLeafContexts()) {
249+
final Long minValue = ctxHasSkipper
250+
? docValuesSkipperMinValue(leafContext, field.string())
251+
: pointMinValue(leafContext, field.string());
252+
result = nullableMin(result, minValue);
255253
}
256254
}
257-
if (minValue <= min[0]) {
258-
min[0] = minValue;
259-
foundMinValue.set(true);
260-
}
261-
return true;
262-
}, true);
263-
stat.min = foundMinValue.get() ? min[0] : null;
255+
} catch (IOException ex) {
256+
throw new EsqlIllegalArgumentException("Cannot access data storage", ex);
257+
}
258+
stat.min = result;
264259
}
265260
return stat.min;
266261
}
267262

268263
@Override
269264
public Object max(FieldName field) {
270-
var stat = cache.computeIfAbsent(field.string(), this::makeFieldStats);
271-
// Consolidate max for indexed date fields only, skip the others and mixed-typed fields.
272-
MappedFieldType fieldType = stat.config.fieldType;
273-
boolean hasDocValueSkipper = fieldType instanceof DateFieldType dft && dft.hasDocValuesSkipper();
274-
if (fieldType == null
275-
|| (hasDocValueSkipper == false && stat.config.indexed == false)
276-
|| fieldType instanceof DateFieldType == false) {
265+
final var stat = cache.computeIfAbsent(field.string(), this::makeFieldStats);
266+
final MappedFieldType fieldType = stat.config.fieldType;
267+
if (fieldType instanceof DateFieldType == false) {
277268
return null;
278269
}
279270
if (stat.max == null) {
280-
var max = new long[] { Long.MIN_VALUE };
281-
Holder<Boolean> foundMaxValue = new Holder<>(false);
282-
doWithContexts(r -> {
283-
long maxValue = Long.MIN_VALUE;
284-
if (hasDocValueSkipper) {
285-
maxValue = DocValuesSkipper.globalMaxValue(new IndexSearcher(r), field.string());
286-
} else {
287-
byte[] maxPackedValue = PointValues.getMaxPackedValue(r, field.string());
288-
if (maxPackedValue != null && maxPackedValue.length == 8) {
289-
maxValue = NumericUtils.sortableBytesToLong(maxPackedValue, 0);
271+
Long result = null;
272+
try {
273+
for (final SearchExecutionContext context : contexts) {
274+
if (context.isFieldMapped(field.string()) == false) {
275+
continue;
276+
}
277+
final MappedFieldType ctxFieldType = context.getFieldType(field.string());
278+
boolean ctxHasSkipper = ctxFieldType.indexType().hasDocValuesSkipper();
279+
for (final LeafReaderContext leafContext : context.searcher().getLeafContexts()) {
280+
final Long maxValue = ctxHasSkipper
281+
? docValuesSkipperMaxValue(leafContext, field.string())
282+
: pointMaxValue(leafContext, field.string());
283+
result = nullableMax(result, maxValue);
290284
}
291285
}
292-
if (maxValue >= max[0]) {
293-
max[0] = maxValue;
294-
foundMaxValue.set(true);
295-
}
296-
return true;
297-
}, true);
298-
stat.max = foundMaxValue.get() ? max[0] : null;
286+
} catch (IOException ex) {
287+
throw new EsqlIllegalArgumentException("Cannot access data storage", ex);
288+
}
289+
stat.max = result;
299290
}
300291
return stat.max;
301292
}
302293

294+
private static Long nullableMin(final Long a, final Long b) {
295+
if (a == null) return b;
296+
if (b == null) return a;
297+
return Math.min(a, b);
298+
}
299+
300+
private static Long nullableMax(final Long a, final Long b) {
301+
if (a == null) return b;
302+
if (b == null) return a;
303+
return Math.max(a, b);
304+
}
305+
306+
// TODO: replace these helpers with a unified Lucene min/max API once https://github.com/apache/lucene/issues/15740 is resolved
307+
private static Long docValuesSkipperMinValue(final LeafReaderContext leafContext, final String field) throws IOException {
308+
long value = DocValuesSkipper.globalMinValue(new IndexSearcher(leafContext.reader()), field);
309+
return (value == Long.MAX_VALUE || value == Long.MIN_VALUE) ? null : value;
310+
}
311+
312+
private static Long docValuesSkipperMaxValue(final LeafReaderContext leafContext, final String field) throws IOException {
313+
long value = DocValuesSkipper.globalMaxValue(new IndexSearcher(leafContext.reader()), field);
314+
return (value == Long.MAX_VALUE || value == Long.MIN_VALUE) ? null : value;
315+
}
316+
317+
private static Long pointMinValue(final LeafReaderContext leafContext, final String field) throws IOException {
318+
final byte[] minPackedValue = PointValues.getMinPackedValue(leafContext.reader(), field);
319+
return (minPackedValue != null && minPackedValue.length == 8) ? NumericUtils.sortableBytesToLong(minPackedValue, 0) : null;
320+
}
321+
322+
private static Long pointMaxValue(final LeafReaderContext leafContext, final String field) throws IOException {
323+
final byte[] maxPackedValue = PointValues.getMaxPackedValue(leafContext.reader(), field);
324+
return (maxPackedValue != null && maxPackedValue.length == 8) ? NumericUtils.sortableBytesToLong(maxPackedValue, 0) : null;
325+
}
326+
303327
@Override
304328
public boolean isSingleValue(FieldName field) {
305329
String fieldName = field.string();

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/stats/SearchContextStatsTests.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import org.apache.lucene.index.IndexReader;
1717
import org.apache.lucene.store.Directory;
1818
import org.apache.lucene.tests.index.RandomIndexWriter;
19+
import org.elasticsearch.common.Rounding;
20+
import org.elasticsearch.common.settings.Settings;
1921
import org.elasticsearch.core.IOUtils;
22+
import org.elasticsearch.core.TimeValue;
2023
import org.elasticsearch.index.mapper.MapperService;
2124
import org.elasticsearch.index.mapper.MapperServiceTestCase;
2225
import org.elasticsearch.index.query.SearchExecutionContext;
@@ -25,10 +28,13 @@
2528
import org.junit.After;
2629
import org.junit.Before;
2730

31+
import java.io.Closeable;
2832
import java.io.IOException;
33+
import java.time.ZoneId;
2934
import java.util.ArrayList;
3035
import java.util.List;
3136

37+
import static org.elasticsearch.index.mapper.DateFieldMapper.DateFieldType;
3238
import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.dateNanosToLong;
3339
import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.dateTimeToLong;
3440

@@ -160,6 +166,81 @@ public void testMinMax() {
160166
}
161167
}
162168

169+
public void testPointValuesMinMaxDoesNotReturnSentinelValues() throws IOException {
170+
final MapperServiceTestCase mapperHelper = new MapperServiceTestCase() {};
171+
final List<SearchExecutionContext> contexts = new ArrayList<>();
172+
final List<Closeable> toClose = new ArrayList<>();
173+
174+
try {
175+
for (int i = 0; i < randomIntBetween(5, 10); i++) {
176+
final MapperService mapperService = mapperHelper.createMapperService("""
177+
{ "doc": { "properties": { "date": { "type": "date" }, "keyword": { "type": "keyword" }}}}""");
178+
assertFalse(((DateFieldType) mapperService.fieldType("date")).hasDocValuesSkipper());
179+
final Directory dir = newDirectory();
180+
final IndexReader reader;
181+
try (RandomIndexWriter writer = new RandomIndexWriter(random(), dir)) {
182+
writer.addDocument(List.of(new StringField("keyword", "value" + i, Field.Store.NO)));
183+
reader = writer.getReader();
184+
}
185+
toClose.add(reader);
186+
toClose.add(mapperService);
187+
toClose.add(dir);
188+
contexts.add(mapperHelper.createSearchExecutionContext(mapperService, newSearcher(reader)));
189+
}
190+
191+
final SearchStats stats = SearchContextStats.from(contexts);
192+
final FieldAttribute.FieldName dateFieldName = new FieldAttribute.FieldName("date");
193+
assertNull(stats.min(dateFieldName));
194+
assertNull(stats.max(dateFieldName));
195+
final Rounding.Prepared prepared = new Rounding.Builder(TimeValue.timeValueMinutes(30)).timeZone(ZoneId.of("Europe/Rome"))
196+
.build()
197+
.prepare(0L, 0L);
198+
assertNotNull(prepared);
199+
} finally {
200+
IOUtils.close(toClose);
201+
}
202+
}
203+
204+
public void testDocValuesSkipperMinMaxDoesNotReturnSentinelValues() throws IOException {
205+
final List<SearchExecutionContext> contexts = new ArrayList<>();
206+
final List<Closeable> toClose = new ArrayList<>();
207+
208+
try {
209+
for (int i = 0; i < randomIntBetween(5, 10); i++) {
210+
final MapperService mapperService = createMapperService(
211+
Settings.builder().put("index.mode", "time_series").put("index.routing_path", "uid").build(),
212+
"""
213+
{ "doc": { "properties": {
214+
"@timestamp": { "type": "date" },
215+
"uid": { "type": "keyword", "time_series_dimension": true }
216+
}}}"""
217+
);
218+
assertTrue(((DateFieldType) mapperService.fieldType("@timestamp")).hasDocValuesSkipper());
219+
final Directory dir = newDirectory();
220+
final IndexReader reader;
221+
try (RandomIndexWriter writer = new RandomIndexWriter(random(), dir)) {
222+
writer.addDocument(List.of(new StringField("uid", "id" + i, Field.Store.NO)));
223+
reader = writer.getReader();
224+
}
225+
toClose.add(reader);
226+
toClose.add(mapperService);
227+
toClose.add(dir);
228+
contexts.add(createSearchExecutionContext(mapperService, newSearcher(reader)));
229+
}
230+
231+
final SearchStats stats = SearchContextStats.from(contexts);
232+
final FieldAttribute.FieldName timestampFieldName = new FieldAttribute.FieldName("@timestamp");
233+
assertNull(stats.min(timestampFieldName));
234+
assertNull(stats.max(timestampFieldName));
235+
final Rounding.Prepared prepared = new Rounding.Builder(TimeValue.timeValueMinutes(30)).timeZone(ZoneId.of("Europe/Rome"))
236+
.build()
237+
.prepare(0L, 0L);
238+
assertNotNull(prepared);
239+
} finally {
240+
IOUtils.close(toClose);
241+
}
242+
}
243+
163244
@After
164245
public void cleanup() throws IOException {
165246
IOUtils.close(readers);

0 commit comments

Comments
 (0)