You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[BUG] Date range filter on a date field falls back to a per-document script instead of a native range when the field is wrapped in timestamp() / CAST(... AS TIMESTAMP) #5680
# Filtering a `date`-mapped field where the field is wrapped in timestamp()/CAST(... AS TIMESTAMP).
# This wrapped shape is what clients such as the Grafana OpenSearch data source generate
# automatically for their dashboard time filter.
source=my-logs
| where timestamp(event_time) >= cast('2024-01-15 12:00:00' as timestamp)
and timestamp(event_time) <= cast('2024-01-15 15:00:00' as timestamp)
| stats count() as c
# The logically-identical BARE-field form behaves correctly (pushes down to a native range):
# source=my-logs
# | where event_time >= '2024-01-15 12:00:00' and event_time <= '2024-01-15 15:00:00'
# | stats count() as c
Expected Result:
The wrapped-field range predicate should push down to a native OpenSearch range query (BKD/points accelerated), exactly like the bare-field form. POST _plugins/_ppl/_explain should show a range query on event_time.
Actual Result:
The predicate is emitted as a per-document ScriptQueryBuilder ("lang": "opensearch_compounded_script") whose decoded body is gte(timestamp(event_time), cast_to_timestamp(...)), i.e. it calls TypeCastOperators.castToTimestamp → LocalDateTime.parse for every scanned document. _explain shows a script filter instead of range. Because the script has no index acceleration, the timestamp is string-parsed per document across all shards, saturating the search thread pool on large indices. The serialized script also embeds a per-request timestamp, so each execution is a unique script that must be recompiled, which can trip the script-compilation-rate circuit breaker and surface as all shards failed.
Dataset Information
Dataset/Schema Type
OpenTelemetry (OTEL)
Simple Schema for Observability (SS4O)
Open Cybersecurity Schema Framework (OCSF)
Custom (details below) — time-series log records with a date-typed timestamp field
Issue Summary:
When a PPL/SQL predicate compares a date-mapped field to a timestamp literal and the field is wrapped in a date function (timestamp(<field>) or CAST(<field> AS TIMESTAMP)), the SQL engine does not push the comparison down to a native range query. It falls back to a per-document script filter. The same filter written against the bare field pushes down to range and is orders of magnitude cheaper. Wrapping an already-date/timestamp-typed field in timestamp()/CAST(... AS TIMESTAMP) is a no-op for a range comparison (monotonic, range-preserving), so it should push down identically.
Steps to Reproduce:
Create an index with a plain date field (mapping above) and index a few documents (sample above).
Run _explain on the bare-field form and confirm it pushes down to range:
POST _plugins/_ppl/_explain
{ "query": "source=my-logs | where event_time >= '2024-01-15 12:00:00' and event_time <= '2024-01-15 15:00:00' | stats count() as c" }
→ {"range":{"event_time":{...}}}.
Run _explain on the wrapped-field form and observe the fallback to a script filter:
POST _plugins/_ppl/_explain
{ "query": "source=my-logs | where timestamp(event_time) >= cast('2024-01-15 12:00:00' as timestamp) and timestamp(event_time) <= cast('2024-01-15 15:00:00' as timestamp) | stats count() as c" }
→ two script filters ("lang": "opensearch_compounded_script"). Both queries return the same count; only the execution plan differs.
Impact:
On large time-series indices the per-document LocalDateTime.parse runs as a linear scan across all shards, pinning search threads at high CPU and driving allocation/GC pressure; repeated executions can trip the script-compilation-rate breaker (all shards failed). Because common clients (e.g. the Grafana OpenSearch data source) auto-generate the wrapped timestamp(<timeField>) time filter, users hit this without writing it themselves, so the impact is broad for time-series dashboards and alerts backed by PPL/SQL.
Environment Information
OpenSearch Version: 2.19 (reproduced on the released distribution). Reproducible via both PPL and SQL; independent of security settings.
Additional Details:
Root cause
In opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java, canSupport() only allows push-down when the left operand is a bare ReferenceExpression:
A timestamp(field) / cast(field as timestamp) left operand is a FunctionExpression, so canSupport returns false and FilterQueryBuilder.visitFunction falls back to buildScriptQuery(func).
Proposed fix
Fold a redundant date/time cast on the field side to the underlying field reference so the predicate pushes down to a native range. In LuceneQuery:
/** * True if the operand is a date/time cast (or timestamp()/date()/time() builtin) applied to a * reference whose field type is already an OpenSearchDateType. Such a wrap is redundant for a * range comparison and can be unwrapped so the predicate pushes down instead of scripting. */protectedbooleanreferenceWrappedByRedundantDateCast(Expressionarg) {
if (arginstanceofFunctionExpression) {
FunctionExpressionfn = (FunctionExpression) arg;
FunctionNamename = fn.getFunctionName();
booleanisDateCast =
name.equals(BuiltinFunctionName.CAST_TO_TIMESTAMP.getName())
|| name.equals(BuiltinFunctionName.CAST_TO_DATE.getName())
|| name.equals(BuiltinFunctionName.CAST_TO_TIME.getName())
|| name.equals(BuiltinFunctionName.TIMESTAMP.getName())
|| name.equals(BuiltinFunctionName.DATE.getName())
|| name.equals(BuiltinFunctionName.TIME.getName());
returnisDateCast
&& fn.getArguments().size() == 1
&& fn.getArguments().get(0) instanceofReferenceExpression
&& fn.getArguments().get(0).type() instanceofOpenSearchDateType;
}
returnfalse;
}
privateReferenceExpressionunwrapReference(Expressionarg) {
if (arginstanceofReferenceExpression) {
return (ReferenceExpression) arg;
}
return (ReferenceExpression) ((FunctionExpression) arg).getArguments().get(0);
}
canSupport() accepts the wrapped-but-redundant left operand:
The fold is restricted to OpenSearchDateType references, so it only applies where the cast is genuinely redundant and range-preserving. The equivalent normalization (timestamp($dateRef) → $dateRef) should also be added to the Calcite/v3 predicate path.
Validation
Built from source and run as single-node OpenSearch 2.19 containers on the same host (100k docs, event_time mapped as native date, 8 iterations each). Three builds are compared so the behavior change can be traced end to end:
_explain for the wrapped-field predicate is a script (opensearch_compounded_script, per-document castToTimestamp) on both the pre-#3615 and the #3615 builds, and a native range on the fold build.
Full numbers across query shapes. DATE = timestamp(event_time) >= cast(… as timestamp) and timestamp(event_time) <= cast(… as timestamp); SEL = a selective term filter (e.g. event_type="type_a" or event_type="type_b"). Each cell is plan · median latency:
where DATE | head 10000 | where SEL | stats count()
script · 51 ms
script · 228 ms
range · 177 ms
where DATE | where SEL | head 10000 | stats count()
script · 44 ms
script · 46 ms
range · 36 ms
where DATE | where SEL | stats count() (no head)
script · 36 ms
script · 33 ms
range · 25 ms
where DATE | stats count() (pure date)
script · 248 ms
script · 249 ms
range · 99 ms
Each build holds its own independently randomized data, so absolute totals differ slightly: the DATE window holds ~37k docs and the selective result is ~1.5k.
Reading the table:
The date predicate is a per-document script on both the pre-Support Limit pushdown #3615 and Support Limit pushdown #3615 builds; the fold is what turns it into a native range. The pure-date row — no selective filter to front the script — is the clearest view of the per-document cost: ~248 ms → 99 ms (~2.5×) here, widening with data volume and shard count.
The head-before shape (… | head 10000 | where SEL | …) is where behavior changed across Support Limit pushdown #3615. Pre-Support Limit pushdown #3615, the trailing selective filter was pushed below the head, so it narrowed the script's candidate set and head-before ran fast (51 ms) and returned the same count as the other shapes (~1473). Support Limit pushdown #3615 correctly stops pushing a filter that follows a head/LIMIT (a limit-then-filter correctness fix), so the selective filter now runs after the limit: head-before returns a truncated-then-filtered count (~407) and the date script is left to run unnarrowed over the whole window (228 ms). This is the regression a user sees after upgrading to a 2.19 patch that includes Support Limit pushdown #3615.
The fold fixes that regression without undoing Support Limit pushdown #3615's correctness fix. With the date predicate pushed down to a native range, head-before drops to 177 ms and every shape uses range; results match the Support Limit pushdown #3615 build.
head placed after the filters (head-after / no-head) is unaffected across all three builds — the selective filter fronts the script there, so those shapes are already cheap and stay cheap; they simply move from script to range with the fold.
Screenshots
Not applicable — the issue is fully reproducible from the CLI and is demonstrated by the _explain plan output shown under Actual Result and Steps to Reproduce (native range for the bare field vs opensearch_compounded_script for the wrapped field).
Query Information
PPL Command/Query:
Expected Result:
The wrapped-field range predicate should push down to a native OpenSearch
rangequery (BKD/points accelerated), exactly like the bare-field form.POST _plugins/_ppl/_explainshould show arangequery onevent_time.Actual Result:
The predicate is emitted as a per-document
ScriptQueryBuilder("lang": "opensearch_compounded_script") whose decoded body isgte(timestamp(event_time), cast_to_timestamp(...)), i.e. it callsTypeCastOperators.castToTimestamp→LocalDateTime.parsefor every scanned document._explainshows ascriptfilter instead ofrange. Because the script has no index acceleration, the timestamp is string-parsed per document across all shards, saturating the search thread pool on large indices. The serialized script also embeds a per-request timestamp, so each execution is a unique script that must be recompiled, which can trip the script-compilation-rate circuit breaker and surface asall shards failed.Dataset Information
Dataset/Schema Type
date-typed timestamp fieldIndex Mapping
{ "mappings": { "properties": { "event_time": { "type": "date" }, "event_type": { "type": "keyword" }, "request_id": { "type": "text" }, "group_id": { "type": "keyword" } } } }Sample Data
{ "event_time": "2024-01-15T13:30:00.000Z", "event_type": "type_a", "request_id": "req-000042", "group_id": "group-42" }Bug Description
Issue Summary:
When a PPL/SQL predicate compares a
date-mapped field to a timestamp literal and the field is wrapped in a date function (timestamp(<field>)orCAST(<field> AS TIMESTAMP)), the SQL engine does not push the comparison down to a nativerangequery. It falls back to a per-document script filter. The same filter written against the bare field pushes down torangeand is orders of magnitude cheaper. Wrapping an already-date/timestamp-typed field intimestamp()/CAST(... AS TIMESTAMP)is a no-op for a range comparison (monotonic, range-preserving), so it should push down identically.Steps to Reproduce:
datefield (mapping above) and index a few documents (sample above)._explainon the bare-field form and confirm it pushes down torange:{"range":{"event_time":{...}}}._explainon the wrapped-field form and observe the fallback to a script filter:scriptfilters ("lang": "opensearch_compounded_script"). Both queries return the same count; only the execution plan differs.Impact:
On large time-series indices the per-document
LocalDateTime.parseruns as a linear scan across all shards, pinning search threads at high CPU and driving allocation/GC pressure; repeated executions can trip the script-compilation-rate breaker (all shards failed). Because common clients (e.g. the Grafana OpenSearch data source) auto-generate the wrappedtimestamp(<timeField>)time filter, users hit this without writing it themselves, so the impact is broad for time-series dashboards and alerts backed by PPL/SQL.Environment Information
OpenSearch Version: 2.19 (reproduced on the released distribution). Reproducible via both PPL and SQL; independent of security settings.
Additional Details:
Root cause
In
opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java,canSupport()only allows push-down when the left operand is a bareReferenceExpression:A
timestamp(field)/cast(field as timestamp)left operand is aFunctionExpression, socanSupportreturns false andFilterQueryBuilder.visitFunctionfalls back tobuildScriptQuery(func).Proposed fix
Fold a redundant date/time cast on the field side to the underlying field reference so the predicate pushes down to a native range. In
LuceneQuery:canSupport()accepts the wrapped-but-redundant left operand:build()unwraps before building the range:The fold is restricted to
OpenSearchDateTypereferences, so it only applies where the cast is genuinely redundant and range-preserving. The equivalent normalization (timestamp($dateRef) → $dateRef) should also be added to the Calcite/v3 predicate path.Validation
Built from source and run as single-node OpenSearch 2.19 containers on the same host (100k docs,
event_timemapped as nativedate, 8 iterations each). Three builds are compared so the behavior change can be traced end to end:_explainfor the wrapped-field predicate is ascript(opensearch_compounded_script, per-documentcastToTimestamp) on both the pre-#3615 and the #3615 builds, and a nativerangeon the fold build.Full numbers across query shapes.
DATE=timestamp(event_time) >= cast(… as timestamp) and timestamp(event_time) <= cast(… as timestamp);SEL= a selective term filter (e.g.event_type="type_a" or event_type="type_b"). Each cell isplan · median latency:where DATE | head 10000 | where SEL | stats count()script· 51 msscript· 228 msrange· 177 mswhere DATE | where SEL | head 10000 | stats count()script· 44 msscript· 46 msrange· 36 mswhere DATE | where SEL | stats count()(nohead)script· 36 msscript· 33 msrange· 25 mswhere DATE | stats count()(pure date)script· 248 msscript· 249 msrange· 99 msEach build holds its own independently randomized data, so absolute totals differ slightly: the
DATEwindow holds ~37k docs and the selective result is ~1.5k.Reading the table:
scripton both the pre-Support Limit pushdown #3615 and Support Limit pushdown #3615 builds; the fold is what turns it into a nativerange. The pure-date row — no selective filter to front the script — is the clearest view of the per-document cost: ~248 ms → 99 ms (~2.5×) here, widening with data volume and shard count.… | head 10000 | where SEL | …) is where behavior changed across Support Limit pushdown #3615. Pre-Support Limit pushdown #3615, the trailing selective filter was pushed below thehead, so it narrowed the script's candidate set and head-before ran fast (51 ms) and returned the same count as the other shapes (~1473). Support Limit pushdown #3615 correctly stops pushing a filter that follows ahead/LIMIT(a limit-then-filter correctness fix), so the selective filter now runs after the limit: head-before returns a truncated-then-filtered count (~407) and the datescriptis left to run unnarrowed over the whole window (228 ms). This is the regression a user sees after upgrading to a 2.19 patch that includes Support Limit pushdown #3615.range, head-before drops to 177 ms and every shape usesrange; results match the Support Limit pushdown #3615 build.headplaced after the filters (head-after / no-head) is unaffected across all three builds — the selective filter fronts the script there, so those shapes are already cheap and stay cheap; they simply move fromscripttorangewith the fold.Screenshots
Not applicable — the issue is fully reproducible from the CLI and is demonstrated by the
_explainplan output shown under Actual Result and Steps to Reproduce (nativerangefor the bare field vsopensearch_compounded_scriptfor the wrapped field).