Skip to content

[analytics-engine] Rewrite engine-output CAST(TIMESTAMP AS VARCHAR) to to_char - #21650

Merged
mch2 merged 4 commits into
opensearch-project:mainfrom
mengweieric:ae-datetime-output-cast
May 15, 2026
Merged

[analytics-engine] Rewrite engine-output CAST(TIMESTAMP AS VARCHAR) to to_char#21650
mch2 merged 4 commits into
opensearch-project:mainfrom
mengweieric:ae-datetime-output-cast

Conversation

@mengweieric

@mengweieric mengweieric commented May 13, 2026

Copy link
Copy Markdown
Contributor

Fixes opensearch-project/sql#5420.

Problem

PPL's documented timestamp wire format uses a space separator ("2024-01-15 12:00:00"). On the analytics-engine route, DatetimeOutputCastRule wraps every output datetime field in CAST(<datetime> AS VARCHAR) so the unified planner stays backend-agnostic. Calcite renders that cast as the documented space-separated form; DataFusion's Arrow CAST kernel renders it ISO-8601 ("2024-01-15T12:00:00"). The datafusion.format.timestamp_format session config only affects DataFusion's CLI display pipeline, not the cast kernel.

Approach

A small pre-Substrait rewrite at the analytics-engine boundary swaps the engine-output cast for an explicit format call:

CAST(<TIMESTAMP> AS VARCHAR)  →  to_char(<TIMESTAMP>, '%Y-%m-%d %H:%M:%S')

Routed to DataFusion's native to_char scalar via the existing Substrait simple-extension mechanism — no new UDFs.

Scope is intentionally narrow:

  • Output Project only. Accepts any Project subclass (LogicalProject or OpenSearchProject) and descends through the unified planner's single LogicalSort system-limit wrapper when present. Inner projections round-trip verbatim. Not recursive.
  • Direct slots only. Casts buried inside CASE/COALESCE/UDF arguments round-trip verbatim.
  • TIMESTAMP → VARCHAR only. DATE/TIME cast cleanly through Arrow already; CHAR(n) and TIMESTAMP_WITH_LOCAL_TIME_ZONE are out of scope.
  • Seconds-only format. Matches the contract from DatetimeOutputCastRule's introducing PR (sql#5408): "PPL Calcite produces ANSI SQL format 2024-01-15 12:00:00." TIMESTAMP(9) sources resolve to the same seconds-only output.

Performance

Negligible. Plan-time: a single pass over the root projection's slot list, bounded by SELECT-list width. Runtime: replaces one row-level formatter with another in the same projection — no extra physical stage, same node count, on timestamp output columns only.

Test plan

  • DatetimeOutputCastRewriterTests — direct cast rewrite, Sort(Project) wrapper handling, inner projection untouched, nested cast inside CASE untouched, CHAR(n) untouched, DATE/TIME/integer sources untouched, plain field reference untouched, TIMESTAMP(9) source resolves to seconds-only format.
  • DataFusionFragmentConvertorTests#testProjectTimestampOutputCastEmitsToCharExtension — end-to-end Substrait serialization asserts the formatted-render extension function (not a raw cast) is emitted.
  • ./gradlew check -p sandbox -Dsandbox.enabled=true.
  • PPL IT smoke on a force-routed analytics-engine cluster: pre-fix, the canonical now() / current_timestamp() regression class failed with T-separator output; post-fix the same calls return space-separated strings and pass.

@mengweieric mengweieric changed the title [analytics-engine] Rewrite engine-output CAST(TIMESTAMP AS VARCHAR) to to_char (#5420) [analytics-engine] Rewrite engine-output CAST(TIMESTAMP AS VARCHAR) to to_char May 13, 2026
@mengweieric
mengweieric marked this pull request as ready for review May 13, 2026 23:13
@mengweieric
mengweieric requested a review from a team as a code owner May 13, 2026 23:13
…o to_char

DatetimeOutputCastRule wraps datetime fields at the outermost LogicalProject
in CAST(... AS VARCHAR) so the unified planner stays backend-agnostic. Calcite
emits PPL's documented "2024-01-15 12:00:00" (space). DataFusion's Arrow CAST
kernel emits ISO-8601 "2024-01-15T12:00:00" (T-separator), and the
datafusion.format.timestamp_format session config affects only the CLI display
pipeline, not the cast kernel.

Add DatetimeOutputCastRewriter, a pre-isthmus pass that rewrites direct
project slots of shape CAST(<TIMESTAMP> AS VARCHAR) into
TO_CHAR(<TIMESTAMP>, '%Y-%m-%d %H:%M:%S'). Scope is intentionally narrow:

  - Only direct project slots — nested casts inside CASE/COALESCE/UDF args
    were authored by the user and must round-trip verbatim.
  - Only standard SqlTypeName.TIMESTAMP sources — DATE and TIME cast cleanly
    through Arrow already; TIMESTAMP_WITH_LOCAL_TIME_ZONE depends on the
    DataFusion session timezone and is deferred until a concrete failing
    case lands.

Wire SqlLibraryOperators.TO_CHAR through DataFusionFragmentConvertor's
ADDITIONAL_SCALAR_SIGS and declare a matching to_char extension in
opensearch_scalar_functions.yaml so DataFusion resolves the call to its
native to_char scalar.

Issue: opensearch-project/sql#5420.

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
@mengweieric
mengweieric force-pushed the ae-datetime-output-cast branch from 6f8f37b to 34c621c Compare May 14, 2026 00:14
@github-actions

github-actions Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit e2f7b3a)

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 May 14, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to e2f7b3a

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate operands list before access

Add null-safety check for call.getOperands() before accessing index 0. If the CAST
call has no operands (malformed expression), accessing index 0 will throw
IndexOutOfBoundsException. Validate that the operands list is non-empty before
proceeding with the rewrite logic.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatetimeOutputCastRewriter.java [164-182]

 private static RexNode rewriteDirectOutputCast(RexNode expr, RexBuilder rexBuilder) {
     if (!(expr instanceof RexCall call) || call.getKind() != SqlKind.CAST) {
+        return expr;
+    }
+    if (call.getOperands().isEmpty()) {
         return expr;
     }
     RexNode source = call.getOperands().get(0);
     SqlTypeName sourceType = source.getType().getSqlTypeName();
     SqlTypeName targetType = call.getType().getSqlTypeName();
     if (sourceType != SqlTypeName.TIMESTAMP) {
         return expr;
     }
     // VARCHAR-only: DatetimeOutputCastRule emits CAST(... AS VARCHAR) (length-unspecified).
     // CHAR(n) is user-authored and has length/padding semantics that to_char does not preserve.
     if (targetType != SqlTypeName.VARCHAR) {
         return expr;
     }
     RelDataType formatType = rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR);
     RexNode formatLiteral = rexBuilder.makeLiteral(PPL_TIMESTAMP_FORMAT, formatType, true);
     return rexBuilder.makeCall(call.getType(), SqlLibraryOperators.TO_CHAR, List.of(source, formatLiteral));
 }
Suggestion importance[1-10]: 3

__

Why: While adding a null-safety check for call.getOperands() is technically correct, Calcite's RexCall for CAST operations always has exactly one operand by design. A malformed CAST expression would be caught during earlier query validation phases. This defensive check adds minimal value in practice but doesn't hurt.

Low

Previous suggestions

Suggestions up to commit d0057a0
CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate operand list before access

Add null-safety check for call.getOperands() before accessing index 0. If the CAST
call has no operands (malformed expression), accessing get(0) will throw
IndexOutOfBoundsException. Validate operand list size before dereferencing.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatetimeOutputCastRewriter.java [164-182]

 private static RexNode rewriteDirectOutputCast(RexNode expr, RexBuilder rexBuilder) {
     if (!(expr instanceof RexCall call) || call.getKind() != SqlKind.CAST) {
+        return expr;
+    }
+    if (call.getOperands().isEmpty()) {
         return expr;
     }
     RexNode source = call.getOperands().get(0);
     SqlTypeName sourceType = source.getType().getSqlTypeName();
     SqlTypeName targetType = call.getType().getSqlTypeName();
     if (sourceType != SqlTypeName.TIMESTAMP) {
         return expr;
     }
     // VARCHAR-only: DatetimeOutputCastRule emits CAST(... AS VARCHAR) (length-unspecified).
     // CHAR(n) is user-authored and has length/padding semantics that to_char does not preserve.
     if (targetType != SqlTypeName.VARCHAR) {
         return expr;
     }
     RelDataType formatType = rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR);
     RexNode formatLiteral = rexBuilder.makeLiteral(PPL_TIMESTAMP_FORMAT, formatType, true);
     return rexBuilder.makeCall(call.getType(), SqlLibraryOperators.TO_CHAR, List.of(source, formatLiteral));
 }
Suggestion importance[1-10]: 3

__

Why: While adding a null-safety check for call.getOperands() is technically valid defensive programming, Calcite's RexCall for CAST operations always has exactly one operand by design. The check at line 165 already validates call.getKind() != SqlKind.CAST, which ensures the operand list structure is correct. This suggestion addresses an extremely unlikely edge case that would indicate a malformed Calcite expression tree, which should never occur in practice.

Low
Suggestions up to commit 657df0c
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add operands list empty check

Add null-safety check for call.getOperands() before accessing index 0. If the CAST
call has no operands (malformed expression), accessing get(0) will throw an
IndexOutOfBoundsException. Verify the operands list is non-empty before proceeding
with the rewrite logic.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatetimeOutputCastRewriter.java [122-140]

 private static RexNode rewriteDirectOutputCast(RexNode expr, RexBuilder rexBuilder) {
     if (!(expr instanceof RexCall call) || call.getKind() != SqlKind.CAST) {
+        return expr;
+    }
+    if (call.getOperands().isEmpty()) {
         return expr;
     }
     RexNode source = call.getOperands().get(0);
     SqlTypeName sourceType = source.getType().getSqlTypeName();
     SqlTypeName targetType = call.getType().getSqlTypeName();
     if (sourceType != SqlTypeName.TIMESTAMP) {
         return expr;
     }
     // VARCHAR-only: DatetimeOutputCastRule emits CAST(... AS VARCHAR) (length-unspecified).
     // CHAR(n) is user-authored and has length/padding semantics that to_char does not preserve.
     if (targetType != SqlTypeName.VARCHAR) {
         return expr;
     }
     RelDataType formatType = rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR);
     RexNode formatLiteral = rexBuilder.makeLiteral(PPL_TIMESTAMP_FORMAT, formatType, true);
     return rexBuilder.makeCall(call.getType(), SqlLibraryOperators.TO_CHAR, List.of(source, formatLiteral));
 }
Suggestion importance[1-10]: 3

__

Why: While adding a null-safety check for empty operands is technically valid defensive programming, Calcite's RexCall for CAST operations always has exactly one operand by design. The check at line 123 already validates call.getKind() != SqlKind.CAST, which ensures the call is a well-formed CAST expression. This suggestion addresses an edge case that should never occur in practice within Calcite's type system, making it a low-impact defensive addition rather than fixing an actual issue.

Low

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 657df0c: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

The unified planner wraps the output projection in a single LogicalSort
(LogicalSystemLimit query-size cap), so the rewriter must descend through
that Sort to reach the Project introduced by DatetimeOutputCastRule.
Without this, the rewriter no-ops in production and the T-separator output
returns despite the rule's output cast being present.

Also broaden the Project match from LogicalProject to any Project subclass
so OpenSearchProject (after OpenSearchProjectRule converts the rule's
output) is still rewritten. Project#copy preserves the subclass.

Add UTs:
  - testSortOverProjectDirectTimestampOutputCastIsRewritten
  - testSortOverNonProjectIsUntouched

Drop a redundant Project cast on Project#copy.

Issue: opensearch-project/sql#5420.

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d0057a0

…his PR)

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e2f7b3a

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for e2f7b3a: SUCCESS

@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.45%. Comparing base (3bea28a) to head (e2f7b3a).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main   #21650      +/-   ##
============================================
- Coverage     73.48%   73.45%   -0.03%     
+ Complexity    74736    74713      -23     
============================================
  Files          5983     5983              
  Lines        339062   339062              
  Branches      48882    48882              
============================================
- Hits         249162   249061     -101     
- Misses        70064    70165     +101     
  Partials      19836    19836              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mch2
mch2 merged commit 001f3b3 into opensearch-project:main May 15, 2026
16 checks passed
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 15, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 15, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 15, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request May 17, 2026
…o to_char (opensearch-project#21650)

* [analytics-engine] Rewrite engine-output CAST(TIMESTAMP AS VARCHAR) to to_char

DatetimeOutputCastRule wraps datetime fields at the outermost LogicalProject
in CAST(... AS VARCHAR) so the unified planner stays backend-agnostic. Calcite
emits PPL's documented "2024-01-15 12:00:00" (space). DataFusion's Arrow CAST
kernel emits ISO-8601 "2024-01-15T12:00:00" (T-separator), and the
datafusion.format.timestamp_format session config affects only the CLI display
pipeline, not the cast kernel.

Add DatetimeOutputCastRewriter, a pre-isthmus pass that rewrites direct
project slots of shape CAST(<TIMESTAMP> AS VARCHAR) into
TO_CHAR(<TIMESTAMP>, '%Y-%m-%d %H:%M:%S'). Scope is intentionally narrow:

  - Only direct project slots — nested casts inside CASE/COALESCE/UDF args
    were authored by the user and must round-trip verbatim.
  - Only standard SqlTypeName.TIMESTAMP sources — DATE and TIME cast cleanly
    through Arrow already; TIMESTAMP_WITH_LOCAL_TIME_ZONE depends on the
    DataFusion session timezone and is deferred until a concrete failing
    case lands.

Wire SqlLibraryOperators.TO_CHAR through DataFusionFragmentConvertor's
ADDITIONAL_SCALAR_SIGS and declare a matching to_char extension in
opensearch_scalar_functions.yaml so DataFusion resolves the call to its
native to_char scalar.

Issue: opensearch-project/sql#5420.

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

* [analytics-engine] Handle Sort(Project) wrapper and any Project subclass

The unified planner wraps the output projection in a single LogicalSort
(LogicalSystemLimit query-size cap), so the rewriter must descend through
that Sort to reach the Project introduced by DatetimeOutputCastRule.
Without this, the rewriter no-ops in production and the T-separator output
returns despite the rule's output cast being present.

Also broaden the Project match from LogicalProject to any Project subclass
so OpenSearchProject (after OpenSearchProjectRule converts the rule's
output) is still rewritten. Project#copy preserves the subclass.

Add UTs:
  - testSortOverProjectDirectTimestampOutputCastIsRewritten
  - testSortOverNonProjectIsUntouched

Drop a redundant Project cast on Project#copy.

Issue: opensearch-project/sql#5420.

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

* ci: retrigger sandbox-check (flaky composite-engine IT unrelated to this PR)

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

---------

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
Signed-off-by: Khishorekumar BS <bkhishor@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 18, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 18, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 19, 2026
…eOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
mch2 pushed a commit that referenced this pull request May 19, 2026
…-engine route (#21681)

* [analytics-backend-datafusion] Register TIMESTAMP in STANDARD_PROJECT_OPS

PPL `timestamp(expr)` lowers to a `ScalarFunction.TIMESTAMP` call. The
TimestampFunctionAdapter already wires the call into DataFusion's native
`to_timestamp`, but `STANDARD_PROJECT_OPS` did not declare TIMESTAMP, so
`OpenSearchProjectRule.annotateExpr` rejected every plan that contained
the operator with "No backend supports scalar function [TIMESTAMP]
among [datafusion]".

Same call also shows up implicitly after the analyzer coerces a string
literal to TIMESTAMP for column comparisons such as
`@timestamp="2024-01-15T10:30:00Z"` once `@timestamp` is typed as
TIMESTAMP (see the date_nanos schema fix in the previous commit).

Update the inline comment block to match: the legacy-engine-only path
the prior comment described is gone now that the capability is wired.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-engine] Catch Calcite Litmus.THROW AssertionError in DefaultPlanExecutor

RexUtil.isFlat / RelOptUtil.eq / Project.isValid / RexChecker call into
Calcite's Litmus.THROW, which raises AssertionError from raw Java code
rather than via the `assert` keyword. JVM `-da` doesn't gate that path,
so an assertion firing inside a search thread escapes to
OpenSearchUncaughtExceptionHandler and exits the cluster JVM.

This bit hard once `CalciteRelNodeVisitor` started lowering structured
PPL `search` predicates to native filter shape: queries like
`severityNumber="not-a-number"` fold to `=(SAFE_CAST($X), null)` ahead of
the marking phase, and the Litmus check fires before any plan-executor
listener gets to translate the error. The cluster died mid-IT and 21
subsequent tests failed with `Connection refused`.

Convert AssertionError caught at the executor entrypoint to an
IllegalStateException so the query reports as HTTP 500 with a
bucketable message and the cluster survives. The same pattern is
already in place at `UnifiedQueryPlanner.plan` on the SQL plugin side;
this is the analytics-engine-side mirror so neither layer can produce a
cluster-fatal assertion.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Preserve fractional seconds in DatetimeOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Apply schema coercion on indexed-executor placeholder

The indexed-execution path inferred the parquet schema via build_segments
(which calls FileFormat::infer_schema) and registered it directly on the
PlaceholderProvider that from_substrait_plan binds against. The non-indexed
paths (session_context.rs, query_executor.rs, api.rs) all routed their
inferred schemas through schema_coerce::coerce_inferred_schema before
registering, but the indexed path skipped this step.

Result: an OpenSearch `ip` column lands as parquet `BinaryView` on disk;
isthmus on the Java side serializes the Substrait base schema as plain
`Binary` (Substrait has no view types). The placeholder reports `BinaryView`
while the plan declares `Binary` — DataFusion's substrait consumer rejects
the bind with:

    Substrait error: Field '<x>' in Substrait schema has a different type
    (Binary) than the corresponding field in the table schema (BinaryView).

Every analytics-engine query against an index that includes an `ip` column
(every OTEL-logs query in CalciteSearchCommandIT, for example) fails at
fragment start.

Apply coerce_inferred_schema right after build_segments, before
PlaceholderProvider construction. The placeholder, the
expr_to_bool_tree analysis, and the downstream IndexedTableProvider all
see the same Substrait-compatible (Binary / Int64 / Float32) schema, so
the bind succeeds and the parquet reader's SchemaAdapter handles the
per-batch BinaryView→Binary relabeling at scan time.

This restores parity with the other infer_schema sites; no behavior
change for non-IP columns.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
nssuresh2007 pushed a commit to nssuresh2007/OpenSearch that referenced this pull request May 20, 2026
…-engine route (opensearch-project#21681)

* [analytics-backend-datafusion] Register TIMESTAMP in STANDARD_PROJECT_OPS

PPL `timestamp(expr)` lowers to a `ScalarFunction.TIMESTAMP` call. The
TimestampFunctionAdapter already wires the call into DataFusion's native
`to_timestamp`, but `STANDARD_PROJECT_OPS` did not declare TIMESTAMP, so
`OpenSearchProjectRule.annotateExpr` rejected every plan that contained
the operator with "No backend supports scalar function [TIMESTAMP]
among [datafusion]".

Same call also shows up implicitly after the analyzer coerces a string
literal to TIMESTAMP for column comparisons such as
`@timestamp="2024-01-15T10:30:00Z"` once `@timestamp` is typed as
TIMESTAMP (see the date_nanos schema fix in the previous commit).

Update the inline comment block to match: the legacy-engine-only path
the prior comment described is gone now that the capability is wired.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-engine] Catch Calcite Litmus.THROW AssertionError in DefaultPlanExecutor

RexUtil.isFlat / RelOptUtil.eq / Project.isValid / RexChecker call into
Calcite's Litmus.THROW, which raises AssertionError from raw Java code
rather than via the `assert` keyword. JVM `-da` doesn't gate that path,
so an assertion firing inside a search thread escapes to
OpenSearchUncaughtExceptionHandler and exits the cluster JVM.

This bit hard once `CalciteRelNodeVisitor` started lowering structured
PPL `search` predicates to native filter shape: queries like
`severityNumber="not-a-number"` fold to `=(SAFE_CAST($X), null)` ahead of
the marking phase, and the Litmus check fires before any plan-executor
listener gets to translate the error. The cluster died mid-IT and 21
subsequent tests failed with `Connection refused`.

Convert AssertionError caught at the executor entrypoint to an
IllegalStateException so the query reports as HTTP 500 with a
bucketable message and the cluster survives. The same pattern is
already in place at `UnifiedQueryPlanner.plan` on the SQL plugin side;
this is the analytics-engine-side mirror so neither layer can produce a
cluster-fatal assertion.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Preserve fractional seconds in DatetimeOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Apply schema coercion on indexed-executor placeholder

The indexed-execution path inferred the parquet schema via build_segments
(which calls FileFormat::infer_schema) and registered it directly on the
PlaceholderProvider that from_substrait_plan binds against. The non-indexed
paths (session_context.rs, query_executor.rs, api.rs) all routed their
inferred schemas through schema_coerce::coerce_inferred_schema before
registering, but the indexed path skipped this step.

Result: an OpenSearch `ip` column lands as parquet `BinaryView` on disk;
isthmus on the Java side serializes the Substrait base schema as plain
`Binary` (Substrait has no view types). The placeholder reports `BinaryView`
while the plan declares `Binary` — DataFusion's substrait consumer rejects
the bind with:

    Substrait error: Field '<x>' in Substrait schema has a different type
    (Binary) than the corresponding field in the table schema (BinaryView).

Every analytics-engine query against an index that includes an `ip` column
(every OTEL-logs query in CalciteSearchCommandIT, for example) fails at
fragment start.

Apply coerce_inferred_schema right after build_segments, before
PlaceholderProvider construction. The placeholder, the
expr_to_bool_tree analysis, and the downstream IndexedTableProvider all
see the same Substrait-compatible (Binary / Int64 / Float32) schema, so
the bind succeeds and the parquet reader's SchemaAdapter handles the
per-batch BinaryView→Binary relabeling at scan time.

This restores parity with the other infer_schema sites; no behavior
change for non-IP columns.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
…-engine route (opensearch-project#21681)

* [analytics-backend-datafusion] Register TIMESTAMP in STANDARD_PROJECT_OPS

PPL `timestamp(expr)` lowers to a `ScalarFunction.TIMESTAMP` call. The
TimestampFunctionAdapter already wires the call into DataFusion's native
`to_timestamp`, but `STANDARD_PROJECT_OPS` did not declare TIMESTAMP, so
`OpenSearchProjectRule.annotateExpr` rejected every plan that contained
the operator with "No backend supports scalar function [TIMESTAMP]
among [datafusion]".

Same call also shows up implicitly after the analyzer coerces a string
literal to TIMESTAMP for column comparisons such as
`@timestamp="2024-01-15T10:30:00Z"` once `@timestamp` is typed as
TIMESTAMP (see the date_nanos schema fix in the previous commit).

Update the inline comment block to match: the legacy-engine-only path
the prior comment described is gone now that the capability is wired.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-engine] Catch Calcite Litmus.THROW AssertionError in DefaultPlanExecutor

RexUtil.isFlat / RelOptUtil.eq / Project.isValid / RexChecker call into
Calcite's Litmus.THROW, which raises AssertionError from raw Java code
rather than via the `assert` keyword. JVM `-da` doesn't gate that path,
so an assertion firing inside a search thread escapes to
OpenSearchUncaughtExceptionHandler and exits the cluster JVM.

This bit hard once `CalciteRelNodeVisitor` started lowering structured
PPL `search` predicates to native filter shape: queries like
`severityNumber="not-a-number"` fold to `=(SAFE_CAST($X), null)` ahead of
the marking phase, and the Litmus check fires before any plan-executor
listener gets to translate the error. The cluster died mid-IT and 21
subsequent tests failed with `Connection refused`.

Convert AssertionError caught at the executor entrypoint to an
IllegalStateException so the query reports as HTTP 500 with a
bucketable message and the cluster survives. The same pattern is
already in place at `UnifiedQueryPlanner.plan` on the SQL plugin side;
this is the analytics-engine-side mirror so neither layer can produce a
cluster-fatal assertion.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Preserve fractional seconds in DatetimeOutputCastRewriter format

Widen the format string passed to `to_char` from the seconds-only
{@code "%Y-%m-%d %H:%M:%S"} to {@code "%Y-%m-%d %H:%M:%S%.f"}. The
trailing {@code %.f} is chrono's variable-length fractional-second
specifier — a leading dot followed by 0-9 digits, omitted when the
value has no sub-second precision.

This matches PPL's legacy formatting for {@code date} and
{@code date_nanos} fields where the displayed precision tracks the
source value:

- {@code 2024-01-15T10:30:01.23456789Z} (date_nanos) →
  {@code "2024-01-15 10:30:01.23456789"} (legacy) / now
  {@code "2024-01-15 10:30:01.234567890"} (analytics route — internally
  9-digit, leading 0 because Arrow Timestamp(ns) precision is fixed)
- {@code 2025-08-01T03:47:41Z} (date)            →
  {@code "2025-08-01 03:47:41"} (both paths) — no decimal because the
  source value has no fractional component

Surfaced by `CalciteSearchCommandIT.testSearchWithDateRangeComparisons`.
Follow-up to opensearch-project/sql#5420 which the original PR (opensearch-project#21650)
closed with a seconds-only format that dropped the fractional digits
the tests still expect.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [analytics-backend-datafusion] Apply schema coercion on indexed-executor placeholder

The indexed-execution path inferred the parquet schema via build_segments
(which calls FileFormat::infer_schema) and registered it directly on the
PlaceholderProvider that from_substrait_plan binds against. The non-indexed
paths (session_context.rs, query_executor.rs, api.rs) all routed their
inferred schemas through schema_coerce::coerce_inferred_schema before
registering, but the indexed path skipped this step.

Result: an OpenSearch `ip` column lands as parquet `BinaryView` on disk;
isthmus on the Java side serializes the Substrait base schema as plain
`Binary` (Substrait has no view types). The placeholder reports `BinaryView`
while the plan declares `Binary` — DataFusion's substrait consumer rejects
the bind with:

    Substrait error: Field '<x>' in Substrait schema has a different type
    (Binary) than the corresponding field in the table schema (BinaryView).

Every analytics-engine query against an index that includes an `ip` column
(every OTEL-logs query in CalciteSearchCommandIT, for example) fails at
fragment start.

Apply coerce_inferred_schema right after build_segments, before
PlaceholderProvider construction. The placeholder, the
expr_to_bool_tree analysis, and the downstream IndexedTableProvider all
see the same Substrait-compatible (Binary / Int64 / Float32) schema, so
the bind succeeds and the parquet reader's SchemaAdapter handles the
per-batch BinaryView→Binary relabeling at scan time.

This restores parity with the other infer_schema sites; no behavior
change for non-IP columns.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

---------

Signed-off-by: Kai Huang <ahkcs@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

Development

Successfully merging this pull request may close these issues.

Datetime output cast format diverges between Calcite and DataFusion engines

3 participants