Skip to content

fix: push down date filter wrapped by a redundant date cast (v2 + Calcite) - #5681

Open
burnthm wants to merge 6 commits into
opensearch-project:mainfrom
burnthm:fix-timestamp-cast-pushdown
Open

fix: push down date filter wrapped by a redundant date cast (v2 + Calcite)#5681
burnthm wants to merge 6 commits into
opensearch-project:mainfrom
burnthm:fix-timestamp-cast-pushdown

Conversation

@burnthm

@burnthm burnthm commented Aug 5, 2026

Copy link
Copy Markdown

Description

A PPL/SQL range comparison on a date-mapped field does not push down to a native OpenSearch range query when the field is wrapped in a date function — timestamp(<field>) or CAST(<field> AS TIMESTAMP). Instead the engine emits a per-document script that calls castToTimestampLocalDateTime.parse for every scanned document. With no BKD/points acceleration this string-parses the timestamp per document across all shards and saturates the search thread pool on large indices; the serialized script also embeds a per-request timestamp, so each execution recompiles and can trip the script-compilation-rate breaker. This is the shape the Grafana OpenSearch data source generates for its dashboard time filter, so users hit it without writing it themselves.

Wrapping a field that is already of that same date/time type is a no-op for a comparison, so this PR folds the wrap back to the underlying field reference and lets the predicate push down to a native range.

Both engines are fixed, because the push-down decision lives in a different place on each and the v2 fix alone has no effect on 3.x (which uses Calcite by default):

Engine Class Change
v2 LuceneQuery canSupport() accepts a redundant date cast on the left operand; build() unwraps it
v3 (Calcite) PredicateAnalyzer visitCall() folds the wrap to the underlying RexInputRef

The fold only applies when the cast is genuinely a no-op. Two guards, both exercised by negative tests:

  1. The cast target must equal the field's own type. A cast that changes the date/time type is a real conversion: date(<timestamp field>) truncates the time component (so date(ts) <= '2024-01-15' is not ts <= '2024-01-15') and time(<timestamp field>) extracts the time of day, which isn't even monotonic in the timestamp.
  2. Only CAST and the timestamp()/date()/time() conversion operators qualify. Result type alone is insufficient — other single-argument functions also return a date/time type while changing the value, LAST_DAY being the clearest example.

Behaviour is unchanged in every case: only the execution plan changes, from script to range.

Validation

v2 and v3 return identical doc counts. Both engines run against an identical 100k-doc dataset (fixed RNG seed and insertion order; event_action histogram verified equal on both nodes). Nodes: 2.19.0 + v2 fold, 3.7.0 + Calcite fold.

query shape v2 (2.19) v3 (3.7) counts
pure date, timestamp() wrap RANGE / 37507 RANGE / 37507 match
pure date, CAST(..) wrap RANGE / 37507 RANGE / 37507 match
pure date, bare field (control) RANGE / 37507 RANGE / 37507 match
+ selective filter, no head RANGE / 1488 RANGE / 1488 match
+ selective filter, head after where RANGE / 1488 RANGE / 1488 match
+ selective filter, head before where RANGE / 385 RANGE / 385 match

Every shape plans a native range on both engines and returns the same count, including the bare-field control — so the fold reproduces bare-field semantics rather than merely agreeing with itself.

Performance (single node, 100k docs, identical dataset, median of 5). On the unpatched node the bare-field form of the same filter runs in 25 ms while the wrapped form takes 628 ms — a ~25× penalty purely from the wrap. After the fix the wrapped form is back to bare-field parity:

Query shape Unpatched Patched Speedup
pure date (timestamp() wrap) script 628 ms range 20 ms 31×
pure date (CAST wrap) script 550 ms range 18 ms 31×
pure date (bare field, control) range 25 ms range 18 ms
+ selective filter, no head script 53 ms range 19 ms 2.8×
+ selective filter, head after where script 122 ms range 58 ms 2.1×
+ selective filter, head before where script 396 ms range 218 ms 1.8×

Shapes with a selective filter look milder only because that filter pushes down natively and leads the Lucene conjunction, so the per-document script runs on a small matching subset (~1.5k of 100k).

Guard cases were verified on a 3-doc deterministic index: timestamp(ts) / CAST(ts AS TIMESTAMP) flip scriptrange with unchanged counts, while date(ts), time(ts) and last_day(d) stay on the script path with unchanged counts.

Related Issues

Resolves #5680

Check List

  • New functionality includes testing.
    • v2 FilterQueryBuilderTest: the fold produces a range; a type-changing cast (date()/time() over a timestamp field) and a cast over a non-date field both stay on the script path.
    • v3 PredicateAnalyzerTest: the fold produces a RangeQueryBuilder; date() over a timestamp field and last_day() both stay on the script path.
  • New functionality has javadoc added.
  • New functionality has been documented.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

A PPL/SQL range comparison on a `date`-mapped field falls back to a
per-document script when the field is wrapped in timestamp()/
CAST(... AS TIMESTAMP) — the shape the Grafana OpenSearch data source
generates for its dashboard time filter — because LuceneQuery.canSupport()
only accepts a bare reference on the left operand. The scripted path
parses the timestamp per document (no BKD/points acceleration), which
saturates the search thread pool on large indices.

Wrapping an already date/time-typed field in a date/time cast is
order-preserving (a no-op for a range comparison), so fold the redundant
cast to the underlying field reference and let the predicate push down to
a native range query. Restricted to OpenSearchDateType references so a
genuine string/number->timestamp conversion still uses the script path.

Resolves opensearch-project#5680

Signed-off-by: Tom Burns <burnthm@amazon.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 60c39b2)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 60c39b2

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check before comparison

The method creates a new NamedFieldExpression object just to extract the field type.
If getCoreExprType() returns null, the subsequent equals() call could result in a
NullPointerException. Add a null check before the comparison to prevent potential
runtime errors.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.java [1031-1057]

 private boolean isRedundantDateCastOverField(RexCall call) {
   if (call.getOperands().size() != 1) {
     return false;
   }
   if (!(call.getOperands().get(0) instanceof RexInputRef inputRef)) {
     return false;
   }
   ...
   ExprType fieldType = new NamedFieldExpression(inputRef, schema, fieldTypes).getCoreExprType();
-  return udt.getExprCoreType().equals(fieldType);
+  return fieldType != null && udt.getExprCoreType().equals(fieldType);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that getCoreExprType() can return null (as indicated by the @Nullable annotation on line 1931), and the current code at line 1056 could throw a NullPointerException if fieldType is null. Adding a null check prevents a potential runtime error.

Medium
General
Add diagnostic information to exception

The method unwrapReference is marked as private but is called from build() which is
public. If canSupport() is not properly checked before calling build(), this could
throw an IllegalStateException. Consider adding a defensive check in build() or
making the precondition more explicit in the method documentation.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [148-158]

 private ReferenceExpression unwrapReference(Expression arg) {
   if (arg instanceof ReferenceExpression) {
     return (ReferenceExpression) arg;
   }
   if (referenceWrappedByRedundantDateCast(arg)) {
     return (ReferenceExpression) ((FunctionExpression) arg).getArguments().get(0);
   }
   throw new IllegalStateException(
       "Left operand must be a reference or a redundant date/time cast over a reference; "
-          + "canSupport() must be checked before build()");
+          + "canSupport() must be checked before build(). Actual arg type: " + arg.getClass().getName());
 }
Suggestion importance[1-10]: 5

__

Why: Adding the actual argument type to the exception message improves debugging capabilities when the precondition is violated. However, the suggestion assumes canSupport() might not be properly checked, which is a defensive programming practice rather than fixing an actual bug in the PR.

Low

Previous suggestions

Suggestions up to commit 24d4949
CategorySuggestion                                                                                                                                    Impact
General
Add null check before equals

The method creates a new NamedFieldExpression instance to retrieve fieldType, but
doesn't handle the case where getCoreExprType() returns null. Add a null check for
fieldType before calling equals() to prevent potential NullPointerException.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.java [1024-1042]

 private boolean isRedundantDateCastOverField(RexCall call) {
   if (call.getOperands().size() != 1) {
     return false;
   }
   if (!(call.getOperands().get(0) instanceof RexInputRef inputRef)) {
     return false;
   }
   ...
   ExprType fieldType = new NamedFieldExpression(inputRef, schema, fieldTypes).getCoreExprType();
-  return udt.getExprCoreType().equals(fieldType);
+  return fieldType != null && udt.getExprCoreType().equals(fieldType);
 }
Suggestion importance[1-10]: 7

__

Why: Valid suggestion to prevent potential NullPointerException. The getCoreExprType() method is annotated with @Nullable (line 1916), so fieldType could be null. Adding a null check before the equals() call improves robustness and prevents runtime errors.

Medium
Add validation before unchecked cast

The unwrapReference method is marked as private but is only called from build().
Consider adding a null check before the unchecked cast to ReferenceExpression in the
second branch to prevent potential ClassCastException if the inner argument is not
actually a ReferenceExpression, even though referenceWrappedByRedundantDateCast
should guarantee this.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [148-158]

 private ReferenceExpression unwrapReference(Expression arg) {
   if (arg instanceof ReferenceExpression) {
     return (ReferenceExpression) arg;
   }
   if (referenceWrappedByRedundantDateCast(arg)) {
-    return (ReferenceExpression) ((FunctionExpression) arg).getArguments().get(0);
+    Expression inner = ((FunctionExpression) arg).getArguments().get(0);
+    if (!(inner instanceof ReferenceExpression)) {
+      throw new IllegalStateException("Inner expression must be a ReferenceExpression");
+    }
+    return (ReferenceExpression) inner;
   }
   throw new IllegalStateException(
       "Left operand must be a reference or a redundant date/time cast over a reference; "
           + "canSupport() must be checked before build()");
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion adds defensive validation, but referenceWrappedByRedundantDateCast already checks that the inner expression is a ReferenceExpression (line 137), making this additional check redundant. The improvement is minimal since the existing logic already guarantees type safety.

Low
Suggestions up to commit 64dedec
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add parentheses for operator precedence clarity

The operator precedence in the boolean expression is ambiguous and may not evaluate
as intended. The || operator has lower precedence than &&, which could cause the
condition to be evaluated incorrectly. Add explicit parentheses to clarify the
intended grouping.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [54-61]

 public boolean canSupport(FunctionExpression func) {
-  return (func.getArguments().size() == 2)
+  return ((func.getArguments().size() == 2)
           && (func.getArguments().get(0) instanceof ReferenceExpression
               || referenceWrappedByRedundantDateCast(func.getArguments().get(0)))
           && (func.getArguments().get(1) instanceof LiteralExpression
-              || literalExpressionWrappedByCast(func))
+              || literalExpressionWrappedByCast(func)))
       || isMultiParameterQuery(func);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential operator precedence issue. While Java's operator precedence rules make the current code work as intended (since && has higher precedence than ||), adding explicit parentheses improves code clarity and prevents potential misinterpretation. However, this is a code style improvement rather than a bug fix.

Medium
Add null checks to prevent NullPointerException

The method does not handle potential null values from fn.getFunctionName() or
inner.type(), which could cause a NullPointerException. Add null checks before
calling equals() and instanceof to ensure robustness.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [111-129]

 protected boolean referenceWrappedByRedundantDateCast(Expression arg) {
   if (!(arg instanceof FunctionExpression)) {
     return false;
   }
   FunctionExpression fn = (FunctionExpression) arg;
   FunctionName name = fn.getFunctionName();
+  if (name == null) {
+    return false;
+  }
   boolean isDateCast =
       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());
   if (!isDateCast || fn.getArguments().size() != 1) {
     return false;
   }
   Expression inner = fn.getArguments().get(0);
-  return inner instanceof ReferenceExpression && inner.type() instanceof OpenSearchDateType;
+  return inner instanceof ReferenceExpression 
+      && inner.type() != null 
+      && inner.type() instanceof OpenSearchDateType;
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion addresses potential NullPointerException issues by adding null checks for fn.getFunctionName() and inner.type(). While this improves defensive programming, the score is moderate because these null cases may be prevented by the codebase's design or earlier validation, which we cannot verify from the PR context alone.

Low
Suggestions up to commit 8422367
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add validation for unexpected expression types

The unwrapReference method assumes the argument is either a ReferenceExpression or a
FunctionExpression wrapping one, but doesn't validate this assumption. If called
with an unexpected expression type, it will throw a ClassCastException. Add
validation or document the precondition that callers must ensure
referenceWrappedByRedundantDateCast returns true before calling this method.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [134-139]

 private ReferenceExpression unwrapReference(Expression arg) {
   if (arg instanceof ReferenceExpression) {
     return (ReferenceExpression) arg;
   }
-  return (ReferenceExpression) ((FunctionExpression) arg).getArguments().get(0);
+  if (arg instanceof FunctionExpression) {
+    Expression innerArg = ((FunctionExpression) arg).getArguments().get(0);
+    if (innerArg instanceof ReferenceExpression) {
+      return (ReferenceExpression) innerArg;
+    }
+  }
+  throw new IllegalArgumentException("Expected ReferenceExpression or wrapped ReferenceExpression");
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that unwrapReference lacks validation and could throw ClassCastException if called with unexpected types. However, given that canSupport validates the expression type before build is called, this is more of a defensive programming improvement than a critical bug fix.

Medium
General
Prevent potential index out of bounds

The method checks fn.getArguments().size() == 1 but then calls
fn.getArguments().get(0) twice without null safety. If getArguments() returns null
or an empty list between checks, this could cause IndexOutOfBoundsException or
NullPointerException. Store the argument in a local variable after the size check.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/lucene/LuceneQuery.java [122-125]

-return isDateCast
-    && fn.getArguments().size() == 1
-    && fn.getArguments().get(0) instanceof ReferenceExpression
-    && fn.getArguments().get(0).type() instanceof OpenSearchDateType;
+if (!isDateCast || fn.getArguments().size() != 1) {
+  return false;
+}
+Expression arg = fn.getArguments().get(0);
+return arg instanceof ReferenceExpression
+    && arg.type() instanceof OpenSearchDateType;
Suggestion importance[1-10]: 3

__

Why: While the refactoring improves code clarity by storing the argument in a variable, the concern about getArguments() returning null or changing between calls is unlikely in practice since getArguments() typically returns an immutable list. The suggestion offers marginal improvement in readability but doesn't address a realistic bug.

Low

Address automated review feedback on opensearch-project#5681:
- unwrapReference now validates the operand via
  referenceWrappedByRedundantDateCast and throws IllegalStateException
  instead of performing an unchecked cast, making the
  canSupport()-before-build() precondition explicit rather than risking a
  ClassCastException.
- referenceWrappedByRedundantDateCast stores the inner argument in a local
  to avoid evaluating getArguments().get(0) twice.

No behavior change; existing tests unaffected.

Signed-off-by: Tom Burns <burnthm@amazon.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 64dedec

@burnthm

burnthm commented Aug 5, 2026

Copy link
Copy Markdown
Author

On v3, a comparison whose field side is a CAST(... AS TIMESTAMP) / timestamp(field) (SqlKind.CAST, handled by supportedRexCall/visitCall → toCastExpression) won't fold to a bare field reference, so it falls back to a ScriptQueryBuilder instead of emitting a native rangeQuery, which is the same per-document parsing cost this issue describes, just on the Calcite engine. Users on the v3 engine (or with plugins.calcite.enabled) would still hit the regression.

Proposed follow-up: in PredicateAnalyzer, when the operand of a comparison is a CAST/date-builtin over a field whose OpenSearch type is already an OpenSearchDateType, unwrap it to the underlying field ref before building the range (mirroring referenceWrappedByRedundantDateCast/unwrapReference here), with the same guardrail: only unwrap when the inner field is genuinely date-typed so non-order-preserving conversions still use the script path.

Can handle this as a separate follow-up PR to keep this one focused on the v2 path, or fold it into this PR if reviewers prefer a single change.

burnthm added 2 commits August 6, 2026 12:35
The redundancy check accepted any date/time cast over any date/time-typed
field without requiring the two to match, so a cast that changes the
date/time type was folded away and silently changed results:

  date(<timestamp field>) truncates the time component, so
    date(ts) <= '2024-01-15'  is not  ts <= '2024-01-15'
  Against docs at 2024-01-15 08:00 and 2024-01-15 23:00 the predicate
  correctly matches 2 rows; folded to the bare field it matched 0.

  time(<timestamp field>) extracts the time of day, which is not even
  monotonic with respect to the timestamp (23:00 on day 1 sorts after
  01:00 on day 2), so no range rewrite is valid.

Map each cast function to the type it produces and fold only when that
target equals the field's own type, i.e. when the cast is genuinely a
no-op. This keeps the intended case -- timestamp()/CAST(... AS TIMESTAMP)
over a `date`-mapped field -- pushing down to a native range query.

Also addresses automated review feedback: add explicit parentheses to
canSupport() so the intended grouping of the size/operand checks against
the isMultiParameterQuery alternative is unambiguous.

Signed-off-by: Tom Burns <burnthm@amazon.com>
Calcite/v3 counterpart of the v2 LuceneQuery fold. PredicateAnalyzer only
pushes a comparison down to a native range query when the operand is a bare
field reference, so wrapping a date field in timestamp()/CAST(... AS
TIMESTAMP) makes the whole predicate fall back to a per-document script that
parses the timestamp for every scanned document. Without this the v2 fold has
no effect on the Calcite engine, which 3.x uses by default.

Fold the wrap to the underlying field reference when it is a genuine no-op.
Because date/time values are modelled as UDTs whose backing SqlTypeName is
VARCHAR, the UDT (EXPR_TIMESTAMP/EXPR_DATE/EXPR_TIME) identifies the cast
target rather than getSqlTypeName() -- which always reports VARCHAR and so
never matches a date/time type.

As in the v2 path, the fold requires the cast target to match the field's own
type exactly. date(<timestamp field>) truncates the time component and
time(<timestamp field>) extracts the time of day (not monotonic in the
timestamp), so those remain on the script path.

Validation -- v2 and v3 return identical doc counts
---------------------------------------------------
Both engines were run against an identical 100k-doc dataset (fixed RNG seed,
same insertion order; `event_action` histogram verified equal on both nodes:
break_enter=9935, cue_message=1965, gar=1946, impression=76143, other=10011).
Nodes: 2.19.0 + v2 fold, and 3.7.0 + this Calcite fold. Predicate window
2026-08-04 17:42:40..20:42:40, selective filter event_action in (gar,
cue_message).

  query shape                       v2 (2.19)        v3 (3.7)      counts
  ------------------------------------------------------------------------
  pure date, timestamp() wrap      RANGE / 37507   RANGE / 37507   match
  pure date, CAST(..) wrap         RANGE / 37507   RANGE / 37507   match
  pure date, bare field (control)  RANGE / 37507   RANGE / 37507   match
  + selective, no head             RANGE /  1488   RANGE /  1488   match
  + selective, head after where    RANGE /  1488   RANGE /  1488   match
  + selective, head before where   RANGE /   385   RANGE /   385   match

Every shape plans a native range on both engines and returns the same count,
including the bare-field control -- so the fold reproduces bare-field
semantics exactly rather than merely agreeing with itself.

Correctness of the type-match guard was checked separately on a 3-doc
deterministic index (docs at 2024-01-15 08:00, 2024-01-15 23:00,
2024-01-16 01:00), comparing stock 3.7 against the patched build:
timestamp(ts)/CAST(ts AS TIMESTAMP) flip SCRIPT -> RANGE with unchanged
counts, while date(ts) and time(ts) stay on the script path with unchanged
counts. Unguarded, date(ts) <= '2024-01-15' would have folded to
ts <= '2024-01-15' and returned 0 rows instead of 2.

Signed-off-by: Tom Burns <burnthm@amazon.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 24d4949

@burnthm

burnthm commented Aug 6, 2026

Copy link
Copy Markdown
Author

v3 (Calcite) fix added, plus a correctness fix to the v2 path

Following up on my earlier note about the Calcite engine — I've folded the v3 change into this PR rather than a separate one, since the two halves share the same rule and the same guard. Two new commits:

Commit What
fix: only fold a date cast when it does not change the type Correctness fix to the v2 path (details below)
fix: push down date filter wrapped by redundant date cast (Calcite) The v3 PredicateAnalyzer counterpart

1. Latent correctness bug in the original v2 commit (please look here first)

My first version checked "is this a date/time cast?" and "is the operand a date-typed field?" but never that the two matched. That folds away casts which are real conversions, silently changing results:

  • date(<timestamp field>) truncates the time component, so date(ts) <= '2024-01-15' is not ts <= '2024-01-15'. Against docs at 2024-01-15 08:00 and 2024-01-15 23:00 the predicate correctly matches 2 rows; folded to the bare field it matched 0.
  • time(<timestamp field>) extracts the time of day, which isn't even monotonic in the timestamp (23:00 on day 1 sorts after 01:00 on day 2), so no range rewrite is valid at all.

Fixed by mapping each cast function to the type it produces and folding only when that target equals the field's own type — i.e. only when the cast is genuinely a no-op. The intended case (timestamp() / CAST(... AS TIMESTAMP) over a date-mapped field, which is TIMESTAMP in OpenSearch) still pushes down. Both engines now carry the same guard, each with a negative test.

2. The v3/Calcite half

PredicateAnalyzer only pushes a comparison down to a native range when the operand is a bare field reference, so on the Calcite engine the wrapped form falls back to a per-document script exactly as it does on v2. Since 3.x uses Calcite by default, the v2 fold alone has no effect there — I confirmed this on a stock 3.7.0 node before writing the v3 change.

One implementation gotcha worth flagging for reviewers: date/time values are modelled as UDTs whose backing SqlTypeName is VARCHAR, so call.getType().getSqlTypeName() never returns TIMESTAMP/DATE/TIME. My first attempt checked getSqlTypeName() and was silently dead code — the patched node behaved exactly like stock. The UDT (EXPR_TIMESTAMP/EXPR_DATE/EXPR_TIME) is what identifies the cast target, matching the existing instanceof ExprSqlTypegetUdt() idiom already in that file.

3. Validation — v2 and v3 return identical doc counts

Both engines were run against an identical 100k-doc dataset (fixed RNG seed and insertion order; event_action histogram verified equal on both nodes: break_enter=9935, cue_message=1965, gar=1946, impression=76143, other=10011). Nodes: 2.19.0 + v2 fold, and 3.7.0 + the Calcite fold.

query shape v2 (2.19) v3 (3.7) counts
pure date, timestamp() wrap RANGE / 37507 RANGE / 37507 match
pure date, CAST(..) wrap RANGE / 37507 RANGE / 37507 match
pure date, bare field (control) RANGE / 37507 RANGE / 37507 match
+ selective filter, no head RANGE / 1488 RANGE / 1488 match
+ selective filter, head after where RANGE / 1488 RANGE / 1488 match
+ selective filter, head before where RANGE / 385 RANGE / 385 match

Every shape plans a native range on both engines and returns the same count — including the bare-field control, so the fold reproduces bare-field semantics rather than merely agreeing with itself. (Latency isn't compared across the two lines here; that isn't apples-to-apples across major versions. Same-engine before/after numbers are in #5680.)

The type-match guard was checked separately on a 3-doc deterministic index, stock vs patched: timestamp(ts) / CAST(ts AS TIMESTAMP) flip scriptrange with unchanged counts, while date(ts) and time(ts) stay on the script path with unchanged counts.

4. Automated review suggestions

  • Explicit parentheses in canSupport() — incorporated. It also documents that isMultiParameterQuery is an intentional alternative path rather than an accident of precedence.
  • Null checks on getFunctionName() / inner.type() — not taken as written. x instanceof T is already false for null, so inner.type() != null && inner.type() instanceof ... is redundant. The getFunctionName() concern is now moot: the boolean chain became a map lookup, and an unknown (or null) key simply returns null, which is already handled.
  • unwrapReference reachable via the isMultiParameterQuery branch — worth a note: base build() already assumed a bare reference at operand 0 before this PR (it did a direct (ReferenceExpression) cast), so this isn't a new failure mode; the change converts a would-be ClassCastException into an explicit IllegalStateException. In practice relevance functions override build(), so that path isn't reached. Happy to restructure if you'd prefer the precondition enforced differently.

./gradlew :opensearch:spotlessCheck :opensearch:test passes on the final state.

Open question for maintainers

Should the Calcite half ship here or as its own PR? I kept it together because the type-match guard needs to be identical on both paths, but I'm happy to split it if you'd rather review them separately.

The Calcite check keyed off the call's result type, so any single-argument
function returning a date/time UDT over a field of that same type was folded
to the bare field. That is wrong for functions which change the value rather
than just reinterpret it.

LAST_DAY is the clearest case: it takes one date/time argument and returns
DATE, so over a DATE-typed field (e.g. a field mapped with format
`yyyy-MM-dd`) it was rewritten to a range on the raw field. Against docs
d=2024-01-15 and d=2024-01-31:

  last_day(d) = '2024-01-31'   correct: 2 rows (both fall in January, and
                               last_day maps both to 2024-01-31)
  folded to d = '2024-01-31'   returned 1 row

Require the call to be a CAST or one of the timestamp()/date()/time()
conversion operators before considering the type match, mirroring the
function whitelist already used on the v2 path. Verified on a 3.7.0 node:
last_day() now stays on the script path while timestamp()/CAST(... AS
TIMESTAMP) still fold to a native range.

Signed-off-by: Tom Burns <burnthm@amazon.com>
@burnthm burnthm changed the title fix: push down date filter wrapped by redundant date cast fix: push down date filter wrapped by a redundant date cast (v2 + Calcite) Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 60c39b2

The unit tests build the predicate tree by hand, so they cannot show that a
real PPL query plans into the shape the fold matches, nor that the generated
DSL is accepted by a cluster and returns the same rows. Both gaps mattered
here: the Calcite check originally keyed off getSqlTypeName(), which always
reports VARCHAR for a date UDT, so it was dead code that unit tests could not
have caught.

Add three tests, run against a real cluster:

- ExplainIT.testFilterTimestampWrappedFieldPushDownExplain -- a
  timestamp()-wrapped filter on a timestamp field pushes down to a native
  range query. The generated request is byte-identical to the bare-field
  fixture (explain_filter_push_compare_timestamp_string), so the fold really
  does reproduce bare-field behaviour rather than merely something similar.

- ExplainIT.testFilterLastDayOverDateFieldNoPushDownExplain -- last_day()
  over a DATE-typed field is not folded. This is the regression guard for the
  case where keying off the result type alone rewrote the predicate into a
  range on the raw field and returned the wrong rows.

- CastFunctionIT.testRedundantDateCastOnFilteredFieldDoesNotChangeRows --
  the wrapped and bare forms return identical rows, so "only the plan
  changes" is enforced rather than asserted.

The explain tests inherit into CalciteExplainIT and CalciteNoPushdownIT, so
each one covers all three engine configurations: v2, Calcite with pushdown,
and Calcite with pushdown disabled. Expected plans added for all three.

Signed-off-by: Tom Burns <burnthm@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant