Skip to content

Sandbox/qa - Add multi shard qa test suite - #21951

Merged
sandeshkr419 merged 2 commits into
opensearch-project:mainfrom
mch2:twoshard-merge-suite
Jun 2, 2026
Merged

Sandbox/qa - Add multi shard qa test suite#21951
sandeshkr419 merged 2 commits into
opensearch-project:mainfrom
mch2:twoshard-merge-suite

Conversation

@mch2

@mch2 mch2 commented Jun 2, 2026

Copy link
Copy Markdown
Member

Description

Adds a test suite to sandbox/qa for 2 shard setups. Also unmutes single shard tests that are now passing.
see sandbox/qa/analytics-engine-rest/src/test/resources/datasets/merge_coverage/README.md.
for current list of skipped/failing tests.

How

merge_coverage is provisioned at 1 shard (oracle) and 2 shards; each %INDEX% query runs against both. Exact tier asserts the two results are equal (unordered, numeric-tolerant, multiset-normalized for
values/list); curated shapes also pin a golden. Approximate tier (distinct_count/percentile) asserts the 2-shard result within tolerance of a required golden, since sketches drift across a merge. head/limit
carry a unique id tie-breaker for determinism. Indices are composite parquet + lucene, so the planner's per-arm backend selection is exercised.

Structure: one abstract TwoShardReduceTestCase (all machinery) + thin per-category ITs that only declare their dataset tier and known-issue set — TwoShardAggregationIT, TwoShardScalarIT, TwoShardShapeIT,
TwoShardJoinIT, TwoShardCommandIT, plus IpFieldMultiShardIT.

Currently muted tests:

Here's the full list of currently-muted tests (@AwaitsFix) in analytics-engine-rest

ere's the complete failure inventory across the analytics-engine-rest module.

  1-shard suites

  Per-query failures (suite runs, these qN skip)

  AggregationsPplIT       q7, q8, q9, q10                              (4 of 10)
  AppLogsPplIT            q5, q9                                       (2 of 10)
  ComplexJoinsPplIT       q1,q2,q3,q4,q7,q8,q9,q10                     (8 of 10)
  FulltextWindowPplIT     q1,q6,q8,q12,q13,q14,q15,q17,q19            (9 of 20)
  FunctionsPplIT          q13                                          (1 of 18)
  KubernetesLogsPplIT     q9                                           (1 of 10)
  MultiIndexQueriesPplIT  q2, q7, q10                                  (3 of 14)
  MultiSourceJoinsPplIT   q2, q4                                       (2 of 5)
  RexCommandPplIT         q1,q5,q7,q8,q13,q18                         (6 of 20)
  SecurityLogsPplIT       q1,q2,q3,q4,q5,q7,q8                        (7 of 10)
  ExtensiveCoveragePplIT  96 of 197 — q8,9,10,13,19,20,22,24,25,28,29,30,37,39,40,41,42,43,44,52,54,55,56,57,58,59,60,61,62,70,77,81,85,86,88,89,93,94,95,97,98,99,100,101,102,103,104,105,106,108,110,111,112,114,115,116,117,119,120,125,126,128,129,130,131,132,136,137,138,139,143,144,145,147,148,149,150,151,152,153,154,155,156,157,158,160,162,163,177,188,189,190,191,193,195,196

  Whole suite fails (100% of queries → class still @AwaitsFix)

  ComplexRegexPplIT       (5 of 5)   — grok/regex unsupported
  LookupJoinQueriesPplIT  (6 of 6)   — lookup-join unsupported
  LookupTableQueriesPplIT (2 of 2)   — lookup-table unsupported

  Individual method failures (method-level @AwaitsFix)

  ConditionalFunctionsIT.testEarliestAbsoluteLiteralSelectsSpecificRows
  DynamicMappingSearchIT.testSearchOnDynamicallyAddedFields
  MultisearchCommandIT.testMultisearchThreeBranchesByStr0
  PatternsCommandIT.testSimplePatternAggregationModeMultiShard

  2-shard suite (knownIssues)

  distinct_count_label / distinct_count_by_cat / dc_label   — HLL cross-shard merge over-counts
  streamstats_sum                                            — cumulative window over arrival-ordered gather
  join_inner_id_count / join_inner_category_count /
  join_left_count / join_semi_count / join_anti_count /
  join_inner_by_category                                     — #21867 mixed lucene/datafusion backend reject
  cmd_appendpipe                                             — same #21867 (union)
  cmd_search / cmd_append / cmd_regex / cmd_multisearch      — indexed-executor SIGSEGV (node crash)
  cmd_appendcols                                             — not in PPL grammar
  cmd_timechart                                              — needs @timestamp field

  Totals

  - 1-shard: ~138 failing queries across 10 partial suites + 3 fully-failing suites (13 queries) + 8 standalone methods.
  - 2-shard: 17 muted queries.

Related Issues

Resolves #[Issue number to be closed when this PR is merged]

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

Unmute single shard tests now passing.

Signed-off-by: Marc Handalian <marc.handalian@gmail.com>
@mch2
mch2 requested a review from a team as a code owner June 2, 2026 19:00
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit a577ad1)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Resource Leak

The FileSystem fs opened at line 363 may not be closed if an exception occurs between lines 364-377 before reaching the finally block. If PathUtils.get(uri) or Files.list(dir) throws, the FileSystem remains open. Move the FileSystem creation inside the try block or ensure it is closed in all exception paths.

FileSystem fs = null;
try {
    URI uri = url.toURI();
    Path dir;
    if ("jar".equals(uri.getScheme())) {
        fs = FileSystems.newFileSystem(uri, Collections.emptyMap());
        dir = fs.getPath(resourceDir);
    } else {
        dir = PathUtils.get(uri);
    }
    try (Stream<Path> stream = Files.list(dir)) {
        stream.forEach(p -> {
            String fileName = p.getFileName().toString();
            if (fileName.endsWith(".ppl")) {
                names.add(fileName.substring(0, fileName.length() - ".ppl".length()));
            }
        });
    }
} catch (Exception e) {
    throw new IOException("Failed to discover queries in [" + resourceDir + "]", e);
} finally {
    if (fs != null) {
        fs.close();
    }
}
Possible Issue

Lines 120-131 have early returns inside conditions that check for null or size mismatches, but these return statements are missing. The code falls through to line 134 where it attempts to copy and sort lists that may be null or mismatched in size, causing a NullPointerException or incorrect behavior. The removed code at lines 106-112 and 123-126 in the old hunk had explicit return statements that are now absent.

if (expectedRows == null && actualRows == null) {
    return null; // both empty
}

if (expectedRows == null) {
    return String.format(java.util.Locale.ROOT, "%s: Expected empty response but got %d rows", label, actualRows.size());
}

if (actualRows == null) {
    return String.format(java.util.Locale.ROOT, "%s: Expected %d rows but got empty response", label, expectedRows.size());
}

if (expectedRows.size() != actualRows.size()) {
    return String.format(java.util.Locale.ROOT, "%s: Row count mismatch - expected %d, got %d",
        label, expectedRows.size(), actualRows.size());
}

// Copy before sorting so we never reorder the caller's lists.
expectedRows = new java.util.ArrayList<>(expectedRows);
actualRows = new java.util.ArrayList<>(actualRows);
expectedRows.sort(new RowComparator());

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to a577ad1
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix null-equality check logic

The null-equality check expected == actual is always false when one is null and the
other isn't (the condition already ensures at least one is null). The intended logic
should check if both are null. Use Objects.equals(expected, actual) or explicitly
check both are null.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TwoShardReduceTestCase.java [199-201]

 private static String compareWithinTolerance(List<List<Object>> expected, List<List<Object>> actual, String label) {
+    if (expected == null && actual == null) {
+        return null;
+    }
     if (expected == null || actual == null) {
-        return expected == actual ? null : label + ": one side empty";
+        return label + ": one side empty";
     }
Suggestion importance[1-10]: 8

__

Why: This is a genuine logic bug. The condition expected == actual when one is null will always be false, making the null-equality check ineffective. The suggested fix correctly separates the "both null" case from the "one null" case, preventing incorrect error messages.

Medium
General
Use try-with-resources for FileSystem

The FileSystem resource is manually managed with a finally block, but if an
exception occurs before fs is assigned (e.g., in url.toURI()), the finally block
still executes safely. However, using try-with-resources would be more idiomatic and
safer for resource management.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TwoShardReduceTestCase.java [360-384]

-FileSystem fs = null;
-try {
-    URI uri = url.toURI();
-    Path dir;
-    if ("jar".equals(uri.getScheme())) {
-        fs = FileSystems.newFileSystem(uri, Collections.emptyMap());
+URI uri = url.toURI();
+Path dir;
+if ("jar".equals(uri.getScheme())) {
+    try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) {
         dir = fs.getPath(resourceDir);
-    } else {
-        dir = PathUtils.get(uri);
+        try (Stream<Path> stream = Files.list(dir)) {
+            stream.forEach(p -> {
+                String fileName = p.getFileName().toString();
+                if (fileName.endsWith(".ppl")) {
+                    names.add(fileName.substring(0, fileName.length() - ".ppl".length()));
+                }
+            });
+        }
     }
-    ...
-} finally {
-    if (fs != null) {
-        fs.close();
+} else {
+    dir = PathUtils.get(uri);
+    try (Stream<Path> stream = Files.list(dir)) {
+        stream.forEach(p -> {
+            String fileName = p.getFileName().toString();
+            if (fileName.endsWith(".ppl")) {
+                names.add(fileName.substring(0, fileName.length() - ".ppl".length()));
+            }
+        });
     }
 }
Suggestion importance[1-10]: 6

__

Why: Valid suggestion to use try-with-resources for better resource management. The current manual approach is correct but less idiomatic. This improves code maintainability and reduces the risk of resource leaks, though the existing code is functionally correct.

Low
Deep copy rows before sorting

The shallow copy of row lists doesn't protect against mutation of the nested row
objects themselves. If RowComparator or downstream code modifies individual rows
(e.g., sorting multi-value cells), the caller's data is still affected. Consider
deep-copying the rows or documenting this limitation.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ResponseValidator.java [133-137]

-// Copy before sorting so we never reorder the caller's lists.
-expectedRows = new java.util.ArrayList<>(expectedRows);
-actualRows = new java.util.ArrayList<>(actualRows);
+// Deep copy before sorting so we never reorder or mutate the caller's data.
+expectedRows = deepCopyRows(expectedRows);
+actualRows = deepCopyRows(actualRows);
 expectedRows.sort(new RowComparator());
 actualRows.sort(new RowComparator());
 
+// Helper method to deep copy rows
+private static List<List<Object>> deepCopyRows(List<List<Object>> rows) {
+    List<List<Object>> copy = new ArrayList<>(rows.size());
+    for (List<Object> row : rows) {
+        copy.add(new ArrayList<>(row));
+    }
+    return copy;
+}
+
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that shallow copying doesn't protect nested row objects. However, the PR already includes normalizeArrayCells which sorts multi-value cells, so this is a real concern. The impact is moderate since it affects test data integrity but doesn't cause incorrect test results.

Low

Previous suggestions

Suggestions up to commit 83c90c7
CategorySuggestion                                                                                                                                    Impact
Possible issue
Handle null exception messages safely

The rootMessage method may return null when r.getMessage() is null, causing
potential NullPointerException in string concatenation contexts. Add null-safety by
using String.valueOf() or providing a default message when the root cause message is
null.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TwoShardReduceTestCase.java [320-326]

 private static String rootMessage(Throwable t) {
     Throwable r = t;
     while (r.getCause() != null && r.getCause() != r) {
         r = r.getCause();
     }
-    return r.getClass().getSimpleName() + ": " + r.getMessage();
+    String message = r.getMessage();
+    return r.getClass().getSimpleName() + ": " + (message != null ? message : "<no message>");
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that r.getMessage() can return null, which would result in the string "null" being concatenated. Adding explicit null-handling improves robustness and provides clearer error messages.

Low
General
Handle exceptions in stream operations

The forEach operation on the stream may throw unchecked exceptions (e.g., from
toString() or add()), which won't be properly wrapped by the outer IOException.
Consider using a traditional loop or explicitly handling exceptions within the
lambda to ensure proper error propagation.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TwoShardReduceTestCase.java [370-377]

 try (Stream<Path> stream = Files.list(dir)) {
     stream.forEach(p -> {
-        String fileName = p.getFileName().toString();
-        if (fileName.endsWith(".ppl")) {
-            names.add(fileName.substring(0, fileName.length() - ".ppl".length()));
+        try {
+            String fileName = p.getFileName().toString();
+            if (fileName.endsWith(".ppl")) {
+                names.add(fileName.substring(0, fileName.length() - ".ppl".length()));
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to process file: " + p, e);
         }
     });
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion addresses a potential issue where exceptions in the lambda might not be properly propagated. However, the operations (toString(), add()) are unlikely to throw exceptions in normal circumstances, making this a minor defensive improvement rather than a critical fix.

Low

Signed-off-by: Marc Handalian <marc.handalian@gmail.com>
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a577ad1

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

✅ Gradle check result for a577ad1: SUCCESS

@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.53%. Comparing base (4cafb38) to head (a577ad1).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main   #21951      +/-   ##
============================================
+ Coverage     73.43%   73.53%   +0.10%     
- Complexity    75527    75643     +116     
============================================
  Files          6035     6035              
  Lines        342721   342720       -1     
  Branches      49301    49301              
============================================
+ Hits         251676   252020     +344     
+ Misses        71018    70718     -300     
+ Partials      20027    19982      -45     

☔ 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.

* {@link FieldTypeCoverageIT#testIp()} covers {@code ip} at 1 shard; this exercises the 2-shard reduce
* path. Run unmuted — if the defect is present it fails here, otherwise it passes.
*/
public class IpFieldMultiShardIT extends AnalyticsRestTestCase {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why IpFieldMultiShardIT is different from other two shards tests which are extending TwoShardReduceTestCase.java?

@vinaykpud

vinaykpud commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

I found ip_multishard (this PR) and clickbench (plus big5 from #21953) are pinned to number_of_shards: 2 in their mapping files and never get a 1-shard run. Everything else including merge_coverage, says number_of_shards: 1. But merge_coverage also runs with 2 shard.

I think mapping file ends up being a misleading source of truth, and I think it's worth deciding whether we want to align on a single convention before more reduce-style suites get added on top. Curious what you think.

@sandeshkr419

Copy link
Copy Markdown
Member

Merging this change to get the sandbox healthy.

@mch2 Can you please address the comments in a quick follow-up.

@sandeshkr419
sandeshkr419 merged commit b0b1c25 into opensearch-project:main Jun 2, 2026
18 checks passed
mengweieric added a commit to mengweieric/OpenSearch that referenced this pull request Jun 17, 2026
Addresses review feedback to cover the multi-shard case for boolean min/max.
min(flag)/max(flag) over the merge_coverage dataset's boolean field are added as
golden cases in the TwoShardReduceTestCase suite (opensearch-project#21951 convention), so they run
at 1 shard and 2 shards with a differential equality check plus a pinned golden,
exercising the two-phase per-shard-partial then coordinator-merge reduce path.
The merge_coverage flag field has 15 false and 15 true rows, so min=false and
max=true regardless of shard layout.

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
sandeshkr419 pushed a commit that referenced this pull request Jun 18, 2026
…lytics-engine route + review fixes (#21975)

* [analytics-engine] Fix now()-family + enable now(fsp)/rand(seed)/sha2/span-month/ts-subtract/dc-approx on the analytics-engine route

Closes several PPL scalar/aggregate gaps on the analytics-engine (DataFusion)
route. Each was either an "Unable to convert call ..." Substrait failure or a
result-rendering gap; all are verified end-to-end via the CalcitePPL*IT remote
ITs on a parquet/composite (analytics-engine-routed) cluster.

now()-family (CalciteNowLikeFunctionIT 12/12):
- ArrowValues now converts Arrow Date32/Date64 -> LocalDate and
  Time{Sec,Milli,Micro,Nano} -> LocalTime so DATE/TIME results render as
  "uuuu-MM-dd" / "HH:mm:ss" via ExprValueUtils.fromObjectValue's existing
  temporal branch (mirrors how TIMESTAMP already arrives as LocalDateTime).
  Fixes current_date/curdate/current_time/curtime returning raw epoch-day /
  units-of-day integers.
- UTC_TIMESTAMP/UTC_DATE/UTC_TIME enum constants + adapter registrations route
  to the same DataFusion builtins as their CURRENT_* equivalents (cluster runs
  in UTC), reusing the existing now/currentDate/currentTime adapters.
- NowFspAdapter drops the optional fractional-seconds-precision arg so
  now(fsp)/current_timestamp(fsp)/sysdate(fsp) map to DataFusion's niladic
  now() instead of failing as "Unable to convert call now(i32)".

Other scalar/aggregate fixes:
- RandSeedAdapter drops the optional rand(seed) operand -> DataFusion random().
- Sha2FunctionAdapter raises a clear "Unsupported SHA2 algorithm [N]" for a
  concrete unsupported literal bit length, matching the SQL-plugin reference
  (CryptographicFunction) instead of surfacing a cryptic Substrait error.
- SpanAdapter supports variable-length month/quarter/year buckets.
- TimestampSubtractRewriter rewrites MINUS(timestamp, timestamp) to an
  epoch-second difference (to_unixtime), which is Substrait-convertible.
- PplAggregateCallRewriter + AggregateFunction map DISTINCT_COUNT_APPROX to
  APPROX_COUNT_DISTINCT (dc/distinct_count approx form).

Adds unit tests for the new adapters/rewriter and ArrowValues DATE/TIME
conversion; updates the SHA2 adapter test to assert the clear-error behavior.

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

* [analytics-engine] Address review: RAND seed fail-clear, shared Substrait preprocess, adapter arity bounds, span hardening, SHA2 message

Review-driven hardening on top of the initial PR:

- RandSeedAdapter: stop silently dropping the rand(seed) operand (which turned a
  deterministic seeded call into a non-deterministic one). Niladic rand() still
  maps to DataFusion random(); seeded rand(seed) now fails with a clear
  unsupported-shape error until a seeded random is available on the backend.
- DataFusionFragmentConvertor: extract preprocessForSubstrait() and call it from
  both convertToSubstrait and convertStandalone so TimestampSubtractRewriter
  (and any future rewriter) runs on the wrapper/partial-aggregate path too, not
  only the top-level fragment path.
- NowFspAdapter / RandSeedAdapter: only normalize the valid 0-arg / 1-arg shapes;
  leave unexpected arities untouched instead of inventing a valid call.
- SpanAdapter: guard the month and second interval multiplications with
  Math.multiplyExact (clear "interval is too large" error on overflow), and use
  floored modulo/division for the month-index bucketing so pre-1970 (negative
  month index) timestamps snap down to the correct bucket start.
- Sha2FunctionAdapter: list the supported bit lengths (224, 256, 384, 512) in the
  unsupported-algorithm error.

Tests: add NowFsp unexpected-arity test, RAND seeded-reject test, span
month-bucket + overflow tests; update SHA2 test to assert the supported-values
message.

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

* [analytics-engine] Fix grouped distinct_count_approx nullability + make TimestampSubtractRewriter identity-preserving

Two correctness fixes found while verifying the review round:

- PplAggregateCallRewriter (DISTINCT_COUNT_APPROX): pin the remapped aggregate's
  explicit return type to NOT NULL BIGINT. APPROX_COUNT_DISTINCT is a count, so its
  return-type inference is BIGINT NOT NULL; reusing the PPL call's nullable BIGINT
  left the declared and inferred types disagreeing and tripped Calcite's validity
  assertion ("aggCall type BIGINT vs inferred BIGINT NOT NULL") on the grouped
  two-phase path (e.g. `stats distinct_count_approx(x) by g`). This was a latent bug
  in the un-grouped-only test coverage; the grouped form is now verified green.

- TimestampSubtractRewriter: make it a true identity no-op for plans without a
  MINUS(timestamp, timestamp). Previously it walked every RelNode and called
  RelNode.accept(RexShuttle) unconditionally, which re-derives expression / aggCall
  types and can flip a cached nullable BIGINT to BIGINT NOT NULL — breaking unrelated
  shapes once the shared preprocessing pipeline runs it on the two-phase aggregate
  path. It now detects the target shape first (read-only visitors) and only applies
  the rewriting RexShuttle to nodes that actually contain it, returning the original
  object otherwise. Adds identity (assertSame) regression tests.

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

* [analytics-engine] Drop TimestampSubtractRewriter — superseded by upstream MinusAdapter (#21978)

Upstream #21978 added MinusAdapter, the proper named ScalarFunctionAdapter for
timestamp/date subtraction (t1 - t2 -> from_unixtime(to_unixtime(t1) - to_unixtime(t2))).
It also coordinates with WidthBucketAdapter by deliberately leaving the
MINUS(MAX OVER(), MIN OVER()) binning shape untouched. Our pre-Substrait
TimestampSubtractRewriter rewrote every MINUS(timestamp, timestamp) including that
binning shape, so keeping both risked clobbering the binning path. Remove the
rewriter (and its test); MinusAdapter fully covers the case. The shared
preprocessForSubstrait helper is retained for the remaining rewriters.

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

* [analytics-engine] Bind distinct_count_approx after SQL-plugin emits APPROX_COUNT_DISTINCT (#5525)

sql#5525 changed PPLBuiltinOperators.DISTINCT_COUNT_APPROX to emit a user-defined
SqlAggFunction NAMED "APPROX_COUNT_DISTINCT" (DistinctCountApproxLogicalAggFunction).
isthmus binds aggregate sigs by operator identity, not name, so this custom op has no
substrait sig and grouped/ungrouped `stats distinct_count_approx(x)` fails with
"Unable to find binding for call APPROX_COUNT_DISTINCT". (The `dc`->COUNT(DISTINCT)
path works via OpenSearchDistinctCountRule/#22013; the named UDAF path does not.)

Re-key the PplAggregateCallRewriter case from the old name DISTINCT_COUNT_APPROX to
APPROX_COUNT_DISTINCT and remap the PPL marker to stock SqlStdOperatorTable.APPROX_COUNT_DISTINCT
(which ADDITIONAL_AGGREGATE_SIGS binds to DataFusion approx_distinct), guarding against
the stock operator itself. Remove the now-dead DISTINCT_COUNT_APPROX alias in
AggregateFunction.fromNameOrError — the enum constant is APPROX_COUNT_DISTINCT, which
valueOf resolves directly (addresses review comment).

Verified: CalcitePPLAggregationIT.testCountDistinctApprox + testCountDistinctApproxWithAlias
pass on a force-routed analytics-engine cluster.

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

* [analytics-engine] Don't push filters below non-deterministic projects (fixes rand() predicates)

`eval r = rand() | where r > 0` failed with "Comparison performance-delegation requires
(RexInputRef, RexLiteral); got RAND()". Calcite's stock FILTER_PROJECT_TRANSPOSE only guards
against window functions (!containsOver()), so it pushed the filter below the rand() project,
inlining RAND() into the predicate — turning a delegatable ($ref > literal) comparison into
RAND() > literal on the scan, which Lucene performance-delegation cannot serialize (and which
must stay on the single in-memory engine, since each backend would draw different values).

Replace the stock rule with FILTER_PROJECT_TRANSPOSE_DETERMINISTIC: a FilterProjectTransposeRule
configured to refuse the transpose when the Project computes any non-deterministic expression
(RexUtil.isDeterministic). Keeping the Filter above the Project preserves the clean
($ref > literal) shape evaluated in memory.

Verified: CalcitePPLBuiltinFunctionIT.testRand passes on a force-routed analytics-engine cluster.
Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

* [analytics-engine] Restore distinct_count_approx binding on the analytics-engine route

Validation requested in PR review (sandeshkr419) found that sql#5525 alone does
NOT make `stats distinct_count_approx(x)` work on the analytics-engine route, so
the engine-side handling removed in the prior commit is still required. On a
fresh, force-routed cluster with sql#5525 deployed, CalcitePPLAggregationIT
testCountDistinctApprox + testCountDistinctApproxWithAlias fail in two stages:

  1. Planner stage: OpenSearchAggregateRule.resolveViableBackendsForCall reads
     aggCall.getAggregation().getName(), which is still "DISTINCT_COUNT_APPROX"
     (the DistinctCountApproxLogicalAggFunction marker). fromNameOrError then
     throws "No enum constant ...AggregateFunction.DISTINCT_COUNT_APPROX".
  2. Substrait stage: the PplAggregateCallRewriter case keyed only on
     "APPROX_COUNT_DISTINCT" never matched the marker, so the unbound op reached
     isthmus -> "Unable to find binding for call DISTINCT_COUNT_APPROX($1)".

sql#5525 only sets the substrait-emission name to APPROX_COUNT_DISTINCT, which is
a later stage than both of the above, so the marker's runtime name still reaches
them. Fixes:

  - Restore the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
    AggregateFunction.fromNameOrError (covers the planner path).
  - Match both "DISTINCT_COUNT_APPROX" and "APPROX_COUNT_DISTINCT" in the
    PplAggregateCallRewriter case (covers the substrait-binding path); keep the
    guard that skips the already-bound stock operator.
  - Add a fromNameOrError alias unit test.

Verified on a force-routed analytics-engine cluster (parquet-backed indices,
AE-routed): both tests pass; the surrounding 4-class IT sweep is unchanged at
6 pre-existing failures (date_format/strftime/percentile/nested-field), i.e. no
regressions.

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

* [analytics-engine] Add qa-module ITs for distinct_count_approx + min/max(boolean)

Engine-side end-to-end regression coverage for the two analytics-engine binding
fixes in this PR, run against the production /_plugins/_ppl surface on the
qa-module's analytics-routed cluster (mirrors the SQL-repo CalcitePPLAggregationIT
cases that proved the fixes):

  - DistinctCountApproxAggregationIT: grouped / aliased / dc-shorthand / ungrouped
    distinct_count_approx (guards the planner enum alias + rewriter rebind).
  - MinMaxBooleanAggregationIT: ungrouped / grouped / mixed boolean+string
    min/max (guards the boolean min/max substrait overload).

Each IT creates its own parquet-backed (composite/parquet + lucene secondary)
index, bulk-ingests a small fixed dataset, and asserts oracle values. All 7
tests pass (4 + 3).

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

* [analytics-engine] Fix date_format %c month padding; correct stale RAND comments

Two small follow-ups surfaced while verifying the analytics-engine route:

  - date_format/strftime %c now emits the zero-padded month (Jan -> "01"),
    matching the PPL reference (SQL-plugin DateTimeFormatterUtil maps %c -> "MM"
    in date mode), which differs from stock MySQL's no-leading-zero %c. Time mode
    keeps the reference's single-"0" literal. Fixes the %c portion of
    CalciteDateTimeFunctionIT.testDateFormat and updates the Rust token-set unit
    test expectation accordingly. (The test's %U/%u/%V/%v week-number padding is a
    separate, unrelated date-format issue left for a follow-up.)

  - Correct two stale comments that described the old "drop the seed" RAND
    behavior; the adapter now rejects RAND(seed) with a clear error (addresses
    review feedback). No behavior change — comment/javadoc only.

Verified on a force-routed analytics-engine cluster: date_format(ts,'%c') returns
"01"; the now/sha2/dc/min-max ITs remain green (no regressions).

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

* [analytics-engine] Drop DISTINCT_COUNT_APPROX enum alias per review

Remove the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
AggregateFunction.fromNameOrError (and its unit test), and key the
PplAggregateCallRewriter case on "APPROX_COUNT_DISTINCT" only, per
@sandeshkr419's review. The SQL plugin (sql#5525) emits the operator named
APPROX_COUNT_DISTINCT, which the enum resolves directly, so the legacy-name
alias is no longer needed.

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

* [analytics-engine] Address review: bulk NDJSON content-type, dc comments, error wording

Review follow-ups (sandeshkr419):

  - QA ITs: send _bulk as application/x-ndjson via the per-index endpoint with bare
    {"index": {}} action metadata, matching the existing CountFastPathIT pattern.
    setJsonEntity alone defaults to application/json, which the bulk endpoint rejects.
  - Clarify the distinct_count_approx marker naming across layers: current runtime name
    is APPROX_COUNT_DISTINCT (sql#5525), DISTINCT_COUNT_APPROX is the legacy/defensive
    spelling; OpenSearchDistinctCountRule is the primary planner rewrite and
    PplAggregateCallRewriter is a late Substrait-emission defensive fallback.
  - Document the phase-specific nullability handling: the early planner rewrite preserves
    the marker's nullable type (LogicalAggregate.copy re-validates row type); the late
    Substrait rewrite pins NOT NULL (Calcite validates against the stock op's inferred type).
  - SPAN overflow message now includes the operands.
  - Drop "yet" from the seeded-RAND error so it doesn't imply imminent support.

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

* [analytics-engine] Empty commit to retrigger CI

No code change. The prior gradle-check failed in the Jenkins-trigger step
(jq parse error polling the Jenkins API, 10 retries, empty result) — a
transient CI-infra glitch unrelated to this PR's changes. Re-running checks.

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

* [analytics-engine] Fix DatetimeCoverageIT %c assertions to match zero-padded month

The date_format %c code change (no-leading-zero -> zero-padded "MM") aligns the
analytics-engine Rust UDF with the legacy PPL contract: DateTimeFormatterUtil.DATE_HANDLERS
maps %c -> "MM" (date_format dispatches through DATE_HANDLERS), so January renders as "01".
The Rust code + unit test were updated for this, but the two DatetimeCoverageIT assertions
still expected the old unpadded "1" — the gradle-check / sandbox-check failures.

Update both assertions to "01", matching the canonical merged ITs (ppl/DateTimeFunctionIT
and sql/DateTimeFunctionIT both assert "Sat Jan 01 31st ..." for the same spec). Test-only.

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

* [analytics-engine] Enable now()/rand(seed)/sha2/span-month on the analytics-engine route + review fixes

Enables these PPL functions on the force-routed analytics-engine (DataFusion)
path and addresses review feedback:

- now()-family: NowFspAdapter handles now([fsp]) / current_timestamp /
  localtimestamp, where fsp is MySQL fractional-seconds precision (0-6).
- rand([seed]): RandSeedAdapter.
- sha2(input, bitLen): Sha2FunctionAdapter -> encode(digest(input, 'shaN'), 'hex'),
  with a backend fail-clear guard for unsupported bit lengths (TODO to move
  validation to the frontend once the handoff carries function metadata).
- span() month bucketing: SpanAdapter.
- strftime: os_strftime.rs.
- date_format %c: zero-padded month (DatetimeCoverageIT updated to match the
  merged ppl/sql DateTimeFunctionIT expectations).
- PlannerImpl: don't push filters below non-deterministic projects; rand() in a
  pushed-down predicate would be re-evaluated and draw a fresh value. Documented
  as a semantic-correctness guard.
- DataFusionFragmentConvertor: shared Substrait preprocess entry point.
- New QA ITs: MinMaxBooleanAggregationIT.

Review fixes: removed the late defensive APPROX_COUNT_DISTINCT fallback in
PplAggregateCallRewriter (fail loud instead); restored the Substrait-layer
assessment TODO; clarified fsp in NowFspAdapter; tidied AggregateFunction.

distinct_count_approx on the analytics-engine route is handled upstream by #22120
and is not part of this PR.

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

* [analytics-engine] Add 2-shard reduce coverage for boolean min/max

Addresses review feedback to cover the multi-shard case for boolean min/max.
min(flag)/max(flag) over the merge_coverage dataset's boolean field are added as
golden cases in the TwoShardReduceTestCase suite (#21951 convention), so they run
at 1 shard and 2 shards with a differential equality check plus a pinned golden,
exercising the two-phase per-shard-partial then coordinator-merge reduce path.
The merge_coverage flag field has 15 false and 15 true rows, so min=false and
max=true regardless of shard layout.

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

---------

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
OVyshnevskyi pushed a commit to OVyshnevskyi/OpenSearch that referenced this pull request Jun 22, 2026
…lytics-engine route + review fixes (opensearch-project#21975)

* [analytics-engine] Fix now()-family + enable now(fsp)/rand(seed)/sha2/span-month/ts-subtract/dc-approx on the analytics-engine route

Closes several PPL scalar/aggregate gaps on the analytics-engine (DataFusion)
route. Each was either an "Unable to convert call ..." Substrait failure or a
result-rendering gap; all are verified end-to-end via the CalcitePPL*IT remote
ITs on a parquet/composite (analytics-engine-routed) cluster.

now()-family (CalciteNowLikeFunctionIT 12/12):
- ArrowValues now converts Arrow Date32/Date64 -> LocalDate and
  Time{Sec,Milli,Micro,Nano} -> LocalTime so DATE/TIME results render as
  "uuuu-MM-dd" / "HH:mm:ss" via ExprValueUtils.fromObjectValue's existing
  temporal branch (mirrors how TIMESTAMP already arrives as LocalDateTime).
  Fixes current_date/curdate/current_time/curtime returning raw epoch-day /
  units-of-day integers.
- UTC_TIMESTAMP/UTC_DATE/UTC_TIME enum constants + adapter registrations route
  to the same DataFusion builtins as their CURRENT_* equivalents (cluster runs
  in UTC), reusing the existing now/currentDate/currentTime adapters.
- NowFspAdapter drops the optional fractional-seconds-precision arg so
  now(fsp)/current_timestamp(fsp)/sysdate(fsp) map to DataFusion's niladic
  now() instead of failing as "Unable to convert call now(i32)".

Other scalar/aggregate fixes:
- RandSeedAdapter drops the optional rand(seed) operand -> DataFusion random().
- Sha2FunctionAdapter raises a clear "Unsupported SHA2 algorithm [N]" for a
  concrete unsupported literal bit length, matching the SQL-plugin reference
  (CryptographicFunction) instead of surfacing a cryptic Substrait error.
- SpanAdapter supports variable-length month/quarter/year buckets.
- TimestampSubtractRewriter rewrites MINUS(timestamp, timestamp) to an
  epoch-second difference (to_unixtime), which is Substrait-convertible.
- PplAggregateCallRewriter + AggregateFunction map DISTINCT_COUNT_APPROX to
  APPROX_COUNT_DISTINCT (dc/distinct_count approx form).

Adds unit tests for the new adapters/rewriter and ArrowValues DATE/TIME
conversion; updates the SHA2 adapter test to assert the clear-error behavior.

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

* [analytics-engine] Address review: RAND seed fail-clear, shared Substrait preprocess, adapter arity bounds, span hardening, SHA2 message

Review-driven hardening on top of the initial PR:

- RandSeedAdapter: stop silently dropping the rand(seed) operand (which turned a
  deterministic seeded call into a non-deterministic one). Niladic rand() still
  maps to DataFusion random(); seeded rand(seed) now fails with a clear
  unsupported-shape error until a seeded random is available on the backend.
- DataFusionFragmentConvertor: extract preprocessForSubstrait() and call it from
  both convertToSubstrait and convertStandalone so TimestampSubtractRewriter
  (and any future rewriter) runs on the wrapper/partial-aggregate path too, not
  only the top-level fragment path.
- NowFspAdapter / RandSeedAdapter: only normalize the valid 0-arg / 1-arg shapes;
  leave unexpected arities untouched instead of inventing a valid call.
- SpanAdapter: guard the month and second interval multiplications with
  Math.multiplyExact (clear "interval is too large" error on overflow), and use
  floored modulo/division for the month-index bucketing so pre-1970 (negative
  month index) timestamps snap down to the correct bucket start.
- Sha2FunctionAdapter: list the supported bit lengths (224, 256, 384, 512) in the
  unsupported-algorithm error.

Tests: add NowFsp unexpected-arity test, RAND seeded-reject test, span
month-bucket + overflow tests; update SHA2 test to assert the supported-values
message.

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

* [analytics-engine] Fix grouped distinct_count_approx nullability + make TimestampSubtractRewriter identity-preserving

Two correctness fixes found while verifying the review round:

- PplAggregateCallRewriter (DISTINCT_COUNT_APPROX): pin the remapped aggregate's
  explicit return type to NOT NULL BIGINT. APPROX_COUNT_DISTINCT is a count, so its
  return-type inference is BIGINT NOT NULL; reusing the PPL call's nullable BIGINT
  left the declared and inferred types disagreeing and tripped Calcite's validity
  assertion ("aggCall type BIGINT vs inferred BIGINT NOT NULL") on the grouped
  two-phase path (e.g. `stats distinct_count_approx(x) by g`). This was a latent bug
  in the un-grouped-only test coverage; the grouped form is now verified green.

- TimestampSubtractRewriter: make it a true identity no-op for plans without a
  MINUS(timestamp, timestamp). Previously it walked every RelNode and called
  RelNode.accept(RexShuttle) unconditionally, which re-derives expression / aggCall
  types and can flip a cached nullable BIGINT to BIGINT NOT NULL — breaking unrelated
  shapes once the shared preprocessing pipeline runs it on the two-phase aggregate
  path. It now detects the target shape first (read-only visitors) and only applies
  the rewriting RexShuttle to nodes that actually contain it, returning the original
  object otherwise. Adds identity (assertSame) regression tests.

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

* [analytics-engine] Drop TimestampSubtractRewriter — superseded by upstream MinusAdapter (opensearch-project#21978)

Upstream opensearch-project#21978 added MinusAdapter, the proper named ScalarFunctionAdapter for
timestamp/date subtraction (t1 - t2 -> from_unixtime(to_unixtime(t1) - to_unixtime(t2))).
It also coordinates with WidthBucketAdapter by deliberately leaving the
MINUS(MAX OVER(), MIN OVER()) binning shape untouched. Our pre-Substrait
TimestampSubtractRewriter rewrote every MINUS(timestamp, timestamp) including that
binning shape, so keeping both risked clobbering the binning path. Remove the
rewriter (and its test); MinusAdapter fully covers the case. The shared
preprocessForSubstrait helper is retained for the remaining rewriters.

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

* [analytics-engine] Bind distinct_count_approx after SQL-plugin emits APPROX_COUNT_DISTINCT (opensearch-project#5525)

sql#5525 changed PPLBuiltinOperators.DISTINCT_COUNT_APPROX to emit a user-defined
SqlAggFunction NAMED "APPROX_COUNT_DISTINCT" (DistinctCountApproxLogicalAggFunction).
isthmus binds aggregate sigs by operator identity, not name, so this custom op has no
substrait sig and grouped/ungrouped `stats distinct_count_approx(x)` fails with
"Unable to find binding for call APPROX_COUNT_DISTINCT". (The `dc`->COUNT(DISTINCT)
path works via OpenSearchDistinctCountRule/opensearch-project#22013; the named UDAF path does not.)

Re-key the PplAggregateCallRewriter case from the old name DISTINCT_COUNT_APPROX to
APPROX_COUNT_DISTINCT and remap the PPL marker to stock SqlStdOperatorTable.APPROX_COUNT_DISTINCT
(which ADDITIONAL_AGGREGATE_SIGS binds to DataFusion approx_distinct), guarding against
the stock operator itself. Remove the now-dead DISTINCT_COUNT_APPROX alias in
AggregateFunction.fromNameOrError — the enum constant is APPROX_COUNT_DISTINCT, which
valueOf resolves directly (addresses review comment).

Verified: CalcitePPLAggregationIT.testCountDistinctApprox + testCountDistinctApproxWithAlias
pass on a force-routed analytics-engine cluster.

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

* [analytics-engine] Don't push filters below non-deterministic projects (fixes rand() predicates)

`eval r = rand() | where r > 0` failed with "Comparison performance-delegation requires
(RexInputRef, RexLiteral); got RAND()". Calcite's stock FILTER_PROJECT_TRANSPOSE only guards
against window functions (!containsOver()), so it pushed the filter below the rand() project,
inlining RAND() into the predicate — turning a delegatable ($ref > literal) comparison into
RAND() > literal on the scan, which Lucene performance-delegation cannot serialize (and which
must stay on the single in-memory engine, since each backend would draw different values).

Replace the stock rule with FILTER_PROJECT_TRANSPOSE_DETERMINISTIC: a FilterProjectTransposeRule
configured to refuse the transpose when the Project computes any non-deterministic expression
(RexUtil.isDeterministic). Keeping the Filter above the Project preserves the clean
($ref > literal) shape evaluated in memory.

Verified: CalcitePPLBuiltinFunctionIT.testRand passes on a force-routed analytics-engine cluster.
Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

* [analytics-engine] Restore distinct_count_approx binding on the analytics-engine route

Validation requested in PR review (sandeshkr419) found that sql#5525 alone does
NOT make `stats distinct_count_approx(x)` work on the analytics-engine route, so
the engine-side handling removed in the prior commit is still required. On a
fresh, force-routed cluster with sql#5525 deployed, CalcitePPLAggregationIT
testCountDistinctApprox + testCountDistinctApproxWithAlias fail in two stages:

  1. Planner stage: OpenSearchAggregateRule.resolveViableBackendsForCall reads
     aggCall.getAggregation().getName(), which is still "DISTINCT_COUNT_APPROX"
     (the DistinctCountApproxLogicalAggFunction marker). fromNameOrError then
     throws "No enum constant ...AggregateFunction.DISTINCT_COUNT_APPROX".
  2. Substrait stage: the PplAggregateCallRewriter case keyed only on
     "APPROX_COUNT_DISTINCT" never matched the marker, so the unbound op reached
     isthmus -> "Unable to find binding for call DISTINCT_COUNT_APPROX($1)".

sql#5525 only sets the substrait-emission name to APPROX_COUNT_DISTINCT, which is
a later stage than both of the above, so the marker's runtime name still reaches
them. Fixes:

  - Restore the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
    AggregateFunction.fromNameOrError (covers the planner path).
  - Match both "DISTINCT_COUNT_APPROX" and "APPROX_COUNT_DISTINCT" in the
    PplAggregateCallRewriter case (covers the substrait-binding path); keep the
    guard that skips the already-bound stock operator.
  - Add a fromNameOrError alias unit test.

Verified on a force-routed analytics-engine cluster (parquet-backed indices,
AE-routed): both tests pass; the surrounding 4-class IT sweep is unchanged at
6 pre-existing failures (date_format/strftime/percentile/nested-field), i.e. no
regressions.

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

* [analytics-engine] Add qa-module ITs for distinct_count_approx + min/max(boolean)

Engine-side end-to-end regression coverage for the two analytics-engine binding
fixes in this PR, run against the production /_plugins/_ppl surface on the
qa-module's analytics-routed cluster (mirrors the SQL-repo CalcitePPLAggregationIT
cases that proved the fixes):

  - DistinctCountApproxAggregationIT: grouped / aliased / dc-shorthand / ungrouped
    distinct_count_approx (guards the planner enum alias + rewriter rebind).
  - MinMaxBooleanAggregationIT: ungrouped / grouped / mixed boolean+string
    min/max (guards the boolean min/max substrait overload).

Each IT creates its own parquet-backed (composite/parquet + lucene secondary)
index, bulk-ingests a small fixed dataset, and asserts oracle values. All 7
tests pass (4 + 3).

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

* [analytics-engine] Fix date_format %c month padding; correct stale RAND comments

Two small follow-ups surfaced while verifying the analytics-engine route:

  - date_format/strftime %c now emits the zero-padded month (Jan -> "01"),
    matching the PPL reference (SQL-plugin DateTimeFormatterUtil maps %c -> "MM"
    in date mode), which differs from stock MySQL's no-leading-zero %c. Time mode
    keeps the reference's single-"0" literal. Fixes the %c portion of
    CalciteDateTimeFunctionIT.testDateFormat and updates the Rust token-set unit
    test expectation accordingly. (The test's %U/%u/%V/%v week-number padding is a
    separate, unrelated date-format issue left for a follow-up.)

  - Correct two stale comments that described the old "drop the seed" RAND
    behavior; the adapter now rejects RAND(seed) with a clear error (addresses
    review feedback). No behavior change — comment/javadoc only.

Verified on a force-routed analytics-engine cluster: date_format(ts,'%c') returns
"01"; the now/sha2/dc/min-max ITs remain green (no regressions).

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

* [analytics-engine] Drop DISTINCT_COUNT_APPROX enum alias per review

Remove the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
AggregateFunction.fromNameOrError (and its unit test), and key the
PplAggregateCallRewriter case on "APPROX_COUNT_DISTINCT" only, per
@sandeshkr419's review. The SQL plugin (sql#5525) emits the operator named
APPROX_COUNT_DISTINCT, which the enum resolves directly, so the legacy-name
alias is no longer needed.

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

* [analytics-engine] Address review: bulk NDJSON content-type, dc comments, error wording

Review follow-ups (sandeshkr419):

  - QA ITs: send _bulk as application/x-ndjson via the per-index endpoint with bare
    {"index": {}} action metadata, matching the existing CountFastPathIT pattern.
    setJsonEntity alone defaults to application/json, which the bulk endpoint rejects.
  - Clarify the distinct_count_approx marker naming across layers: current runtime name
    is APPROX_COUNT_DISTINCT (sql#5525), DISTINCT_COUNT_APPROX is the legacy/defensive
    spelling; OpenSearchDistinctCountRule is the primary planner rewrite and
    PplAggregateCallRewriter is a late Substrait-emission defensive fallback.
  - Document the phase-specific nullability handling: the early planner rewrite preserves
    the marker's nullable type (LogicalAggregate.copy re-validates row type); the late
    Substrait rewrite pins NOT NULL (Calcite validates against the stock op's inferred type).
  - SPAN overflow message now includes the operands.
  - Drop "yet" from the seeded-RAND error so it doesn't imply imminent support.

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

* [analytics-engine] Empty commit to retrigger CI

No code change. The prior gradle-check failed in the Jenkins-trigger step
(jq parse error polling the Jenkins API, 10 retries, empty result) — a
transient CI-infra glitch unrelated to this PR's changes. Re-running checks.

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

* [analytics-engine] Fix DatetimeCoverageIT %c assertions to match zero-padded month

The date_format %c code change (no-leading-zero -> zero-padded "MM") aligns the
analytics-engine Rust UDF with the legacy PPL contract: DateTimeFormatterUtil.DATE_HANDLERS
maps %c -> "MM" (date_format dispatches through DATE_HANDLERS), so January renders as "01".
The Rust code + unit test were updated for this, but the two DatetimeCoverageIT assertions
still expected the old unpadded "1" — the gradle-check / sandbox-check failures.

Update both assertions to "01", matching the canonical merged ITs (ppl/DateTimeFunctionIT
and sql/DateTimeFunctionIT both assert "Sat Jan 01 31st ..." for the same spec). Test-only.

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

* [analytics-engine] Enable now()/rand(seed)/sha2/span-month on the analytics-engine route + review fixes

Enables these PPL functions on the force-routed analytics-engine (DataFusion)
path and addresses review feedback:

- now()-family: NowFspAdapter handles now([fsp]) / current_timestamp /
  localtimestamp, where fsp is MySQL fractional-seconds precision (0-6).
- rand([seed]): RandSeedAdapter.
- sha2(input, bitLen): Sha2FunctionAdapter -> encode(digest(input, 'shaN'), 'hex'),
  with a backend fail-clear guard for unsupported bit lengths (TODO to move
  validation to the frontend once the handoff carries function metadata).
- span() month bucketing: SpanAdapter.
- strftime: os_strftime.rs.
- date_format %c: zero-padded month (DatetimeCoverageIT updated to match the
  merged ppl/sql DateTimeFunctionIT expectations).
- PlannerImpl: don't push filters below non-deterministic projects; rand() in a
  pushed-down predicate would be re-evaluated and draw a fresh value. Documented
  as a semantic-correctness guard.
- DataFusionFragmentConvertor: shared Substrait preprocess entry point.
- New QA ITs: MinMaxBooleanAggregationIT.

Review fixes: removed the late defensive APPROX_COUNT_DISTINCT fallback in
PplAggregateCallRewriter (fail loud instead); restored the Substrait-layer
assessment TODO; clarified fsp in NowFspAdapter; tidied AggregateFunction.

distinct_count_approx on the analytics-engine route is handled upstream by opensearch-project#22120
and is not part of this PR.

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

* [analytics-engine] Add 2-shard reduce coverage for boolean min/max

Addresses review feedback to cover the multi-shard case for boolean min/max.
min(flag)/max(flag) over the merge_coverage dataset's boolean field are added as
golden cases in the TwoShardReduceTestCase suite (opensearch-project#21951 convention), so they run
at 1 shard and 2 shards with a differential equality check plus a pinned golden,
exercising the two-phase per-shard-partial then coordinator-merge reduce path.
The merge_coverage flag field has 15 false and 15 true rows, so min=false and
max=true regardless of shard layout.

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

---------

Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
Signed-off-by: Marc Handalian <marc.handalian@gmail.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
…lytics-engine route + review fixes (opensearch-project#21975)

* [analytics-engine] Fix now()-family + enable now(fsp)/rand(seed)/sha2/span-month/ts-subtract/dc-approx on the analytics-engine route

Closes several PPL scalar/aggregate gaps on the analytics-engine (DataFusion)
route. Each was either an "Unable to convert call ..." Substrait failure or a
result-rendering gap; all are verified end-to-end via the CalcitePPL*IT remote
ITs on a parquet/composite (analytics-engine-routed) cluster.

now()-family (CalciteNowLikeFunctionIT 12/12):
- ArrowValues now converts Arrow Date32/Date64 -> LocalDate and
  Time{Sec,Milli,Micro,Nano} -> LocalTime so DATE/TIME results render as
  "uuuu-MM-dd" / "HH:mm:ss" via ExprValueUtils.fromObjectValue's existing
  temporal branch (mirrors how TIMESTAMP already arrives as LocalDateTime).
  Fixes current_date/curdate/current_time/curtime returning raw epoch-day /
  units-of-day integers.
- UTC_TIMESTAMP/UTC_DATE/UTC_TIME enum constants + adapter registrations route
  to the same DataFusion builtins as their CURRENT_* equivalents (cluster runs
  in UTC), reusing the existing now/currentDate/currentTime adapters.
- NowFspAdapter drops the optional fractional-seconds-precision arg so
  now(fsp)/current_timestamp(fsp)/sysdate(fsp) map to DataFusion's niladic
  now() instead of failing as "Unable to convert call now(i32)".

Other scalar/aggregate fixes:
- RandSeedAdapter drops the optional rand(seed) operand -> DataFusion random().
- Sha2FunctionAdapter raises a clear "Unsupported SHA2 algorithm [N]" for a
  concrete unsupported literal bit length, matching the SQL-plugin reference
  (CryptographicFunction) instead of surfacing a cryptic Substrait error.
- SpanAdapter supports variable-length month/quarter/year buckets.
- TimestampSubtractRewriter rewrites MINUS(timestamp, timestamp) to an
  epoch-second difference (to_unixtime), which is Substrait-convertible.
- PplAggregateCallRewriter + AggregateFunction map DISTINCT_COUNT_APPROX to
  APPROX_COUNT_DISTINCT (dc/distinct_count approx form).

Adds unit tests for the new adapters/rewriter and ArrowValues DATE/TIME
conversion; updates the SHA2 adapter test to assert the clear-error behavior.

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

* [analytics-engine] Address review: RAND seed fail-clear, shared Substrait preprocess, adapter arity bounds, span hardening, SHA2 message

Review-driven hardening on top of the initial PR:

- RandSeedAdapter: stop silently dropping the rand(seed) operand (which turned a
  deterministic seeded call into a non-deterministic one). Niladic rand() still
  maps to DataFusion random(); seeded rand(seed) now fails with a clear
  unsupported-shape error until a seeded random is available on the backend.
- DataFusionFragmentConvertor: extract preprocessForSubstrait() and call it from
  both convertToSubstrait and convertStandalone so TimestampSubtractRewriter
  (and any future rewriter) runs on the wrapper/partial-aggregate path too, not
  only the top-level fragment path.
- NowFspAdapter / RandSeedAdapter: only normalize the valid 0-arg / 1-arg shapes;
  leave unexpected arities untouched instead of inventing a valid call.
- SpanAdapter: guard the month and second interval multiplications with
  Math.multiplyExact (clear "interval is too large" error on overflow), and use
  floored modulo/division for the month-index bucketing so pre-1970 (negative
  month index) timestamps snap down to the correct bucket start.
- Sha2FunctionAdapter: list the supported bit lengths (224, 256, 384, 512) in the
  unsupported-algorithm error.

Tests: add NowFsp unexpected-arity test, RAND seeded-reject test, span
month-bucket + overflow tests; update SHA2 test to assert the supported-values
message.

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

* [analytics-engine] Fix grouped distinct_count_approx nullability + make TimestampSubtractRewriter identity-preserving

Two correctness fixes found while verifying the review round:

- PplAggregateCallRewriter (DISTINCT_COUNT_APPROX): pin the remapped aggregate's
  explicit return type to NOT NULL BIGINT. APPROX_COUNT_DISTINCT is a count, so its
  return-type inference is BIGINT NOT NULL; reusing the PPL call's nullable BIGINT
  left the declared and inferred types disagreeing and tripped Calcite's validity
  assertion ("aggCall type BIGINT vs inferred BIGINT NOT NULL") on the grouped
  two-phase path (e.g. `stats distinct_count_approx(x) by g`). This was a latent bug
  in the un-grouped-only test coverage; the grouped form is now verified green.

- TimestampSubtractRewriter: make it a true identity no-op for plans without a
  MINUS(timestamp, timestamp). Previously it walked every RelNode and called
  RelNode.accept(RexShuttle) unconditionally, which re-derives expression / aggCall
  types and can flip a cached nullable BIGINT to BIGINT NOT NULL — breaking unrelated
  shapes once the shared preprocessing pipeline runs it on the two-phase aggregate
  path. It now detects the target shape first (read-only visitors) and only applies
  the rewriting RexShuttle to nodes that actually contain it, returning the original
  object otherwise. Adds identity (assertSame) regression tests.

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

* [analytics-engine] Drop TimestampSubtractRewriter — superseded by upstream MinusAdapter (opensearch-project#21978)

Upstream opensearch-project#21978 added MinusAdapter, the proper named ScalarFunctionAdapter for
timestamp/date subtraction (t1 - t2 -> from_unixtime(to_unixtime(t1) - to_unixtime(t2))).
It also coordinates with WidthBucketAdapter by deliberately leaving the
MINUS(MAX OVER(), MIN OVER()) binning shape untouched. Our pre-Substrait
TimestampSubtractRewriter rewrote every MINUS(timestamp, timestamp) including that
binning shape, so keeping both risked clobbering the binning path. Remove the
rewriter (and its test); MinusAdapter fully covers the case. The shared
preprocessForSubstrait helper is retained for the remaining rewriters.

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

* [analytics-engine] Bind distinct_count_approx after SQL-plugin emits APPROX_COUNT_DISTINCT (opensearch-project#5525)

sql#5525 changed PPLBuiltinOperators.DISTINCT_COUNT_APPROX to emit a user-defined
SqlAggFunction NAMED "APPROX_COUNT_DISTINCT" (DistinctCountApproxLogicalAggFunction).
isthmus binds aggregate sigs by operator identity, not name, so this custom op has no
substrait sig and grouped/ungrouped `stats distinct_count_approx(x)` fails with
"Unable to find binding for call APPROX_COUNT_DISTINCT". (The `dc`->COUNT(DISTINCT)
path works via OpenSearchDistinctCountRule/opensearch-project#22013; the named UDAF path does not.)

Re-key the PplAggregateCallRewriter case from the old name DISTINCT_COUNT_APPROX to
APPROX_COUNT_DISTINCT and remap the PPL marker to stock SqlStdOperatorTable.APPROX_COUNT_DISTINCT
(which ADDITIONAL_AGGREGATE_SIGS binds to DataFusion approx_distinct), guarding against
the stock operator itself. Remove the now-dead DISTINCT_COUNT_APPROX alias in
AggregateFunction.fromNameOrError — the enum constant is APPROX_COUNT_DISTINCT, which
valueOf resolves directly (addresses review comment).

Verified: CalcitePPLAggregationIT.testCountDistinctApprox + testCountDistinctApproxWithAlias
pass on a force-routed analytics-engine cluster.

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

* [analytics-engine] Don't push filters below non-deterministic projects (fixes rand() predicates)

`eval r = rand() | where r > 0` failed with "Comparison performance-delegation requires
(RexInputRef, RexLiteral); got RAND()". Calcite's stock FILTER_PROJECT_TRANSPOSE only guards
against window functions (!containsOver()), so it pushed the filter below the rand() project,
inlining RAND() into the predicate — turning a delegatable ($ref > literal) comparison into
RAND() > literal on the scan, which Lucene performance-delegation cannot serialize (and which
must stay on the single in-memory engine, since each backend would draw different values).

Replace the stock rule with FILTER_PROJECT_TRANSPOSE_DETERMINISTIC: a FilterProjectTransposeRule
configured to refuse the transpose when the Project computes any non-deterministic expression
(RexUtil.isDeterministic). Keeping the Filter above the Project preserves the clean
($ref > literal) shape evaluated in memory.

Verified: CalcitePPLBuiltinFunctionIT.testRand passes on a force-routed analytics-engine cluster.
Signed-off-by: Eric Wei <mengwei.eric@gmail.com>

* [analytics-engine] Restore distinct_count_approx binding on the analytics-engine route

Validation requested in PR review (sandeshkr419) found that sql#5525 alone does
NOT make `stats distinct_count_approx(x)` work on the analytics-engine route, so
the engine-side handling removed in the prior commit is still required. On a
fresh, force-routed cluster with sql#5525 deployed, CalcitePPLAggregationIT
testCountDistinctApprox + testCountDistinctApproxWithAlias fail in two stages:

  1. Planner stage: OpenSearchAggregateRule.resolveViableBackendsForCall reads
     aggCall.getAggregation().getName(), which is still "DISTINCT_COUNT_APPROX"
     (the DistinctCountApproxLogicalAggFunction marker). fromNameOrError then
     throws "No enum constant ...AggregateFunction.DISTINCT_COUNT_APPROX".
  2. Substrait stage: the PplAggregateCallRewriter case keyed only on
     "APPROX_COUNT_DISTINCT" never matched the marker, so the unbound op reached
     isthmus -> "Unable to find binding for call DISTINCT_COUNT_APPROX($1)".

sql#5525 only sets the substrait-emission name to APPROX_COUNT_DISTINCT, which is
a later stage than both of the above, so the marker's runtime name still reaches
them. Fixes:

  - Restore the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
    AggregateFunction.fromNameOrError (covers the planner path).
  - Match both "DISTINCT_COUNT_APPROX" and "APPROX_COUNT_DISTINCT" in the
    PplAggregateCallRewriter case (covers the substrait-binding path); keep the
    guard that skips the already-bound stock operator.
  - Add a fromNameOrError alias unit test.

Verified on a force-routed analytics-engine cluster (parquet-backed indices,
AE-routed): both tests pass; the surrounding 4-class IT sweep is unchanged at
6 pre-existing failures (date_format/strftime/percentile/nested-field), i.e. no
regressions.

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

* [analytics-engine] Add qa-module ITs for distinct_count_approx + min/max(boolean)

Engine-side end-to-end regression coverage for the two analytics-engine binding
fixes in this PR, run against the production /_plugins/_ppl surface on the
qa-module's analytics-routed cluster (mirrors the SQL-repo CalcitePPLAggregationIT
cases that proved the fixes):

  - DistinctCountApproxAggregationIT: grouped / aliased / dc-shorthand / ungrouped
    distinct_count_approx (guards the planner enum alias + rewriter rebind).
  - MinMaxBooleanAggregationIT: ungrouped / grouped / mixed boolean+string
    min/max (guards the boolean min/max substrait overload).

Each IT creates its own parquet-backed (composite/parquet + lucene secondary)
index, bulk-ingests a small fixed dataset, and asserts oracle values. All 7
tests pass (4 + 3).

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

* [analytics-engine] Fix date_format %c month padding; correct stale RAND comments

Two small follow-ups surfaced while verifying the analytics-engine route:

  - date_format/strftime %c now emits the zero-padded month (Jan -> "01"),
    matching the PPL reference (SQL-plugin DateTimeFormatterUtil maps %c -> "MM"
    in date mode), which differs from stock MySQL's no-leading-zero %c. Time mode
    keeps the reference's single-"0" literal. Fixes the %c portion of
    CalciteDateTimeFunctionIT.testDateFormat and updates the Rust token-set unit
    test expectation accordingly. (The test's %U/%u/%V/%v week-number padding is a
    separate, unrelated date-format issue left for a follow-up.)

  - Correct two stale comments that described the old "drop the seed" RAND
    behavior; the adapter now rejects RAND(seed) with a clear error (addresses
    review feedback). No behavior change — comment/javadoc only.

Verified on a force-routed analytics-engine cluster: date_format(ts,'%c') returns
"01"; the now/sha2/dc/min-max ITs remain green (no regressions).

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

* [analytics-engine] Drop DISTINCT_COUNT_APPROX enum alias per review

Remove the DISTINCT_COUNT_APPROX -> APPROX_COUNT_DISTINCT alias in
AggregateFunction.fromNameOrError (and its unit test), and key the
PplAggregateCallRewriter case on "APPROX_COUNT_DISTINCT" only, per
@sandeshkr419's review. The SQL plugin (sql#5525) emits the operator named
APPROX_COUNT_DISTINCT, which the enum resolves directly, so the legacy-name
alias is no longer needed.

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

* [analytics-engine] Address review: bulk NDJSON content-type, dc comments, error wording

Review follow-ups (sandeshkr419):

  - QA ITs: send _bulk as application/x-ndjson via the per-index endpoint with bare
    {"index": {}} action metadata, matching the existing CountFastPathIT pattern.
    setJsonEntity alone defaults to application/json, which the bulk endpoint rejects.
  - Clarify the distinct_count_approx marker naming across layers: current runtime name
    is APPROX_COUNT_DISTINCT (sql#5525), DISTINCT_COUNT_APPROX is the legacy/defensive
    spelling; OpenSearchDistinctCountRule is the primary planner rewrite and
    PplAggregateCallRewriter is a late Substrait-emission defensive fallback.
  - Document the phase-specific nullability handling: the early planner rewrite preserves
    the marker's nullable type (LogicalAggregate.copy re-validates row type); the late
    Substrait rewrite pins NOT NULL (Calcite validates against the stock op's inferred type).
  - SPAN overflow message now includes the operands.
  - Drop "yet" from the seeded-RAND error so it doesn't imply imminent support.

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

* [analytics-engine] Empty commit to retrigger CI

No code change. The prior gradle-check failed in the Jenkins-trigger step
(jq parse error polling the Jenkins API, 10 retries, empty result) — a
transient CI-infra glitch unrelated to this PR's changes. Re-running checks.

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

* [analytics-engine] Fix DatetimeCoverageIT %c assertions to match zero-padded month

The date_format %c code change (no-leading-zero -> zero-padded "MM") aligns the
analytics-engine Rust UDF with the legacy PPL contract: DateTimeFormatterUtil.DATE_HANDLERS
maps %c -> "MM" (date_format dispatches through DATE_HANDLERS), so January renders as "01".
The Rust code + unit test were updated for this, but the two DatetimeCoverageIT assertions
still expected the old unpadded "1" — the gradle-check / sandbox-check failures.

Update both assertions to "01", matching the canonical merged ITs (ppl/DateTimeFunctionIT
and sql/DateTimeFunctionIT both assert "Sat Jan 01 31st ..." for the same spec). Test-only.

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

* [analytics-engine] Enable now()/rand(seed)/sha2/span-month on the analytics-engine route + review fixes

Enables these PPL functions on the force-routed analytics-engine (DataFusion)
path and addresses review feedback:

- now()-family: NowFspAdapter handles now([fsp]) / current_timestamp /
  localtimestamp, where fsp is MySQL fractional-seconds precision (0-6).
- rand([seed]): RandSeedAdapter.
- sha2(input, bitLen): Sha2FunctionAdapter -> encode(digest(input, 'shaN'), 'hex'),
  with a backend fail-clear guard for unsupported bit lengths (TODO to move
  validation to the frontend once the handoff carries function metadata).
- span() month bucketing: SpanAdapter.
- strftime: os_strftime.rs.
- date_format %c: zero-padded month (DatetimeCoverageIT updated to match the
  merged ppl/sql DateTimeFunctionIT expectations).
- PlannerImpl: don't push filters below non-deterministic projects; rand() in a
  pushed-down predicate would be re-evaluated and draw a fresh value. Documented
  as a semantic-correctness guard.
- DataFusionFragmentConvertor: shared Substrait preprocess entry point.
- New QA ITs: MinMaxBooleanAggregationIT.

Review fixes: removed the late defensive APPROX_COUNT_DISTINCT fallback in
PplAggregateCallRewriter (fail loud instead); restored the Substrait-layer
assessment TODO; clarified fsp in NowFspAdapter; tidied AggregateFunction.

distinct_count_approx on the analytics-engine route is handled upstream by opensearch-project#22120
and is not part of this PR.

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

* [analytics-engine] Add 2-shard reduce coverage for boolean min/max

Addresses review feedback to cover the multi-shard case for boolean min/max.
min(flag)/max(flag) over the merge_coverage dataset's boolean field are added as
golden cases in the TwoShardReduceTestCase suite (opensearch-project#21951 convention), so they run
at 1 shard and 2 shards with a differential equality check plus a pinned golden,
exercising the two-phase per-shard-partial then coordinator-merge reduce path.
The merge_coverage flag field has 15 false and 15 true rows, so min=false and
max=true regardless of shard layout.

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

---------

Signed-off-by: Eric Wei <mengwei.eric@gmail.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.

3 participants