Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
package org.elasticsearch.xpack.esql.core.querydsl;

import org.elasticsearch.common.time.DateFormatter;
import org.elasticsearch.common.time.DateMathParser;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.index.mapper.DateFieldMapper;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.RangeQueryBuilder;

import java.time.Instant;
import java.time.ZoneId;
import java.util.function.LongSupplier;

import static org.elasticsearch.xpack.esql.core.expression.MetadataAttribute.TIMESTAMP_FIELD;

Expand All @@ -23,6 +27,7 @@
* Used by PromQL planning to infer implicit start/end bounds from request filters.
*/
public final class QueryDslTimestampBoundsExtractor {
private static final DateMathParser DEFAULT_PARSER = DateFieldMapper.DEFAULT_DATE_TIME_FORMATTER.toDateMathParser();

private QueryDslTimestampBoundsExtractor() {}

Expand All @@ -48,53 +53,101 @@ public record TimestampBounds(Instant start, Instant end) {}
*/
@Nullable
public static TimestampBounds extractTimestampBounds(@Nullable QueryBuilder filter) {
return extractTimestampBounds(filter, null);
}

/**
* Extracts the {@code @timestamp} range bounds from a query DSL filter using the supplied {@code now} value
* to resolve date math expressions consistently with the current request.
*/
@Nullable
public static TimestampBounds extractTimestampBounds(@Nullable QueryBuilder filter, LongSupplier nowSupplier) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the extractTimestampBounds without nowSupplier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Will follow-up in the next PRs.

if (filter == null) {
return null;
}
RangeQueryBuilder range = findTimestampRange(filter);
if (range == null) {
return null;
}
Instant start = parseInstant(range.format(), range.from());
Instant end = parseInstant(range.format(), range.to());
if (start == null || end == null) {
return null;
}
return new TimestampBounds(start, end);
var bounds = new Builder();
collectTimestampBounds(filter, nowSupplier, bounds);
return bounds.build();
}

/**
* Recursively finds a {@link RangeQueryBuilder} on {@code @timestamp} in a {@link QueryBuilder} tree.
*/
@Nullable
private static RangeQueryBuilder findTimestampRange(QueryBuilder filter) {
return switch (filter) {
case RangeQueryBuilder range when TIMESTAMP_FIELD.equals(range.fieldName()) -> range;
private static void collectTimestampBounds(QueryBuilder filter, LongSupplier nowSupplier, Builder bounds) {
switch (filter) {
case RangeQueryBuilder range when TIMESTAMP_FIELD.equals(range.fieldName()) -> bounds.add(range, nowSupplier);
case BoolQueryBuilder bool -> {
for (QueryBuilder clause : bool.filter()) {
RangeQueryBuilder found = findTimestampRange(clause);
if (found != null) yield found;
collectTimestampBounds(clause, nowSupplier, bounds);
}
for (QueryBuilder clause : bool.must()) {
RangeQueryBuilder found = findTimestampRange(clause);
if (found != null) yield found;
collectTimestampBounds(clause, nowSupplier, bounds);
}
yield null;
}
default -> null;
};
default -> {
}
}
}

@Nullable
private static Instant parseInstant(@Nullable String format, @Nullable Object value) {
if (format == null || value == null) {
private static Instant parseInstant(
@Nullable Object value,
@Nullable String format,
@Nullable String timeZone,
@Nullable LongSupplier nowSupplier
) {
if (value == null) {
return null;
}
String stringValue = value.toString();
if (nowSupplier == null && stringValue.contains("now")) {
return null;
}
ZoneId zone = timeZone == null ? null : ZoneId.of(timeZone);
DateMathParser parser = format != null ? DateFormatter.forPattern(format).toDateMathParser() : DEFAULT_PARSER;
try {
DateFormatter df = DateFormatter.forPattern(format);
return Instant.from(df.parse(value.toString()));
return parseInstant(parser, stringValue, zone, nowSupplier);
} catch (RuntimeException e) {
return null;
}
}

private static Instant parseInstant(DateMathParser parser, String value, @Nullable ZoneId zone, @Nullable LongSupplier nowSupplier) {
return parser.parse(value, nowSupplier, false, zone);
}

private static final class Builder {
private Instant start;
private Instant end;
private boolean foundTimestampRange;
private boolean invalid;

private void add(RangeQueryBuilder range, LongSupplier nowSupplier) {
foundTimestampRange = true;
Instant lowerBound = parseInstant(range.from(), range.format(), range.timeZone(), nowSupplier);
if (range.from() != null && lowerBound == null) {
invalid = true;
return;
}
Instant upperBound = parseInstant(range.to(), range.format(), range.timeZone(), nowSupplier);
if (range.to() != null && upperBound == null) {
invalid = true;
return;
}
if (lowerBound != null && (start == null || lowerBound.isAfter(start))) {
start = lowerBound;
}
if (upperBound != null && (end == null || upperBound.isBefore(end))) {
end = upperBound;
}
}

@Nullable
TimestampBounds build() {
if (invalid || foundTimestampRange == false || start == null || end == null || start.isAfter(end)) {
return null;
}
return new TimestampBounds(start, end);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1386,7 +1386,14 @@ private LogicalPlan analyzedPlan(
QueryBuilder requestFilter
) throws Exception {
handleFieldCapsFailures(configuration.allowPartialResults(), executionInfo, r.indexResolution());
var timestampBounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(requestFilter);
var timestampBounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(
requestFilter,
// Resolve date math against the query start time. If this uses the wall clock during planning instead,
// filters like [@timestamp >= now-5m AND @timestamp <= now] drift relative to Configuration.now(), so inferred
// timestamp bounds can shift within one request and misanchor TBUCKET/TSTEP bucketing.
// TODO: support nanos resolution for date_nanos fields
configuration::absoluteStartedTimeInMillis
);
AnalyzerContext analyzerContext = new AnalyzerContext(
configuration,
functionRegistry,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.esql.core.querydsl.QueryDslTimestampBoundsExtractor.TimestampBounds;

import java.time.Duration;
import java.time.Instant;

import static org.hamcrest.Matchers.equalTo;
Expand Down Expand Up @@ -138,4 +139,91 @@ public void testIgnoresRangeNestedInsideShouldSubtree() {
assertThat(QueryDslTimestampBoundsExtractor.extractTimestampBounds(outerBool), nullValue());
}

public void testExtractTimestampBoundsFromSplitRangeClauses() {
Instant start = Instant.parse("2025-01-01T00:00:00Z");
Instant end = Instant.parse("2025-01-02T00:00:00Z");
var lowerBound = new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").gte(start.toString());
var upperBound = new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").lt(end.toString());
var filter = new BoolQueryBuilder().filter(lowerBound).filter(upperBound);

TimestampBounds bounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter);
assertThat(bounds, notNullValue());
assertThat(bounds.start(), equalTo(start));
assertThat(bounds.end(), equalTo(end));
}

public void testExtractTimestampBoundsUsesMostRestrictiveMatchingBounds() {
Instant start = Instant.parse("2025-01-01T00:00:00Z");
Instant narrowedStart = Instant.parse("2025-01-01T12:00:00Z");
Instant end = Instant.parse("2025-01-02T00:00:00Z");
Instant narrowedEnd = Instant.parse("2025-01-01T18:00:00Z");
var narrowedFilter = new BoolQueryBuilder().filter(
new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").gte(narrowedStart.toString())
).filter(new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").lt(narrowedEnd.toString()));
var filter = new BoolQueryBuilder().filter(
new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").gte(start.toString()).lte(end.toString())
).must(narrowedFilter);

TimestampBounds bounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter);
assertThat(bounds, notNullValue());
assertThat(bounds.start(), equalTo(narrowedStart));
assertThat(bounds.end(), equalTo(narrowedEnd));
}

public void testExtractTimestampBoundsUsesTimeZone() {
var filter = new RangeQueryBuilder("@timestamp").timeZone("+02:00").gte("2025-01-01T00:00:00").lt("2025-01-02T00:00:00");

TimestampBounds bounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter);
assertThat(bounds, notNullValue());
assertThat(bounds.start(), equalTo(Instant.parse("2024-12-31T22:00:00Z")));
assertThat(bounds.end(), equalTo(Instant.parse("2025-01-01T22:00:00Z")));
}

public void testExtractTimestampBoundsDateMathUsesSuppliedNow() {
Instant now = Instant.parse("2025-01-02T12:00:00Z");
var filter = new RangeQueryBuilder("@timestamp").gte("now-15m").lte("now");

TimestampBounds bounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter, now::toEpochMilli);
assertThat(bounds, notNullValue());
assertThat(bounds.start(), equalTo(now.minus(Duration.ofMinutes(15))));
assertThat(bounds.end(), equalTo(now));
}

public void testExtractTimestampBoundsWithoutExplicitFormat() {
Instant start = Instant.parse("2025-01-01T00:00:00Z");
Instant end = Instant.parse("2025-01-02T00:00:00Z");
var filter = new RangeQueryBuilder("@timestamp").gte(start.toString()).lte(end.toString());

TimestampBounds bounds = QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter);
assertThat(bounds, notNullValue());
assertThat(bounds.start(), equalTo(start));
assertThat(bounds.end(), equalTo(end));
}

public void testExtractTimestampBoundsReturnsNullForInvertedRange() {
// Intersecting two non-overlapping ranges produces start > end
var wide = new RangeQueryBuilder("@timestamp").format("strict_date_optional_time")
.gte("2025-01-01T00:00:00Z")
.lte("2025-01-01T12:00:00Z");
var narrow = new RangeQueryBuilder("@timestamp").format("strict_date_optional_time")
.gte("2025-01-02T00:00:00Z")
.lte("2025-01-02T12:00:00Z");
var filter = new BoolQueryBuilder().filter(wide).filter(narrow);

assertThat(QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter), nullValue());
}

public void testExtractTimestampBoundsReturnsNullForUnparseableValue() {
var filter = new RangeQueryBuilder("@timestamp").format("strict_date_optional_time").gte("not-a-date").lte("also-not-a-date");

assertThat(QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter), nullValue());
}

public void testExtractTimestampBoundsDateMathWithoutNowSupplierReturnsNull() {
var filter = new RangeQueryBuilder("@timestamp").gte("now-15m").lte("now");

// Without a nowSupplier, date math with "now" returns null
assertThat(QueryDslTimestampBoundsExtractor.extractTimestampBounds(filter), nullValue());
}

}
Loading