Skip to content

[Analytics Engine] Eliminate string/utf8 views & timestamp field coercion across the data-node→coordinator pipeline - #21690

Merged
sandeshkr419 merged 3 commits into
opensearch-project:mainfrom
sandeshkr419:hulk
May 16, 2026
Merged

[Analytics Engine] Eliminate string/utf8 views & timestamp field coercion across the data-node→coordinator pipeline#21690
sandeshkr419 merged 3 commits into
opensearch-project:mainfrom
sandeshkr419:hulk

Conversation

@sandeshkr419

Copy link
Copy Markdown
Member

Co-Authored-By: Claude Opus 4.7 (1M context)

Description

Producer (data node) emits Utf8View strings and Timestamp(Millisecond) timestamps. The coordinator's StreamingTable was registered with Utf8 and Timestamp(Second) multiple places, forcing a per-cell coercion at the wire and a per-batch cast at every UDF call site to bridge the schema mismatch.

This PR aligns the coordinator's declared schema with what the producer actually emits. Schema is now derived in Rust at registration time from the producer's substrait, schema crosses the FFI once. Scalar UDFs accept all three string variants natively via a single dispatch enum.

Net effect: the data path is fully zero-copy for strings and timestamps across the FFI, and string-heavy aggregates / UDF queries skip an O(rows × bytes) memcpy + buffer reallocation per batch. Downstream operators consume the producer's emitted types unchanged.

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.

@sandeshkr419
sandeshkr419 requested a review from a team as a code owner May 16, 2026 10:05
@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 5b48bff)

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

Possible Issue

derive_schema_from_partial_plan registers MemTables with empty batch vectors (vec![vec![]]). If the producer plan contains aggregates or filters that rely on actual data to determine nullability or physical types, the derived schema may diverge from what a real data-bearing execution would produce. This could cause schema mismatches at runtime when the coordinator feeds actual batches.

let table = MemTable::try_new(Arc::new(arrow_schema), vec![vec![]])?;
// Plan may scan the same table twice; the second register is a no-op.
Possible Issue

collect_reads recursively walks the substrait plan but does not handle all possible RelType variants. If the plan contains a RelType not explicitly matched (e.g., Cross, Window, ExtensionSingle, etc.), those branches are silently ignored. If such a branch contains a ReadRel, it will not be registered, leading to a missing table error when the plan is lowered.

fn collect_reads(rel: &substrait::proto::Rel, out: &mut Vec<substrait::proto::ReadRel>) {
    use substrait::proto::rel::RelType;
    match rel.rel_type.as_ref() {
        Some(RelType::Read(r)) => out.push((**r).clone()),
        Some(RelType::Filter(f)) => {
            if let Some(input) = &f.input {
                collect_reads(input, out);
            }
        }
        Some(RelType::Project(p)) => {
            if let Some(input) = &p.input {
                collect_reads(input, out);
            }
        }
        Some(RelType::Aggregate(a)) => {
            if let Some(input) = &a.input {
                collect_reads(input, out);
            }
        }
        Some(RelType::Sort(s)) => {
            if let Some(input) = &s.input {
                collect_reads(input, out);
            }
        }
        Some(RelType::Fetch(f)) => {
            if let Some(input) = &f.input {
                collect_reads(input, out);
            }
        }
        Some(RelType::Join(j)) => {
            if let Some(left) = &j.left {
                collect_reads(left, out);
            }
            if let Some(right) = &j.right {
                collect_reads(right, out);
            }
        }
        Some(RelType::Set(s)) => {
            for input in &s.inputs {
                collect_reads(input, out);
            }
        }
        _ => {}
    }
}
Possible Issue

coerce_unsupported_timestamp_precision only coerces Timestamp(Second) to Timestamp(Millisecond). If the producer emits Timestamp(Microsecond) or Timestamp(Nanosecond) and the coordinator's declared schema is Timestamp(Second), no coercion occurs. The comment claims parquet only supports MILLIS/MICROS/NANOS, but the code does not handle MICROS or NANOS coercion, potentially causing a mismatch.

fn coerce_unsupported_timestamp_precision(
    schema: &arrow::datatypes::Schema,
) -> arrow::datatypes::Schema {
    use arrow::datatypes::{DataType, Field, TimeUnit};
    let fields: Vec<Field> = schema
        .fields()
        .iter()
        .map(|f| match f.data_type() {
            DataType::Timestamp(TimeUnit::Second, tz) => Field::new(
                f.name(),
                DataType::Timestamp(TimeUnit::Millisecond, tz.clone()),
                f.is_nullable(),
            )
            .with_metadata(f.metadata().clone()),
            _ => f.as_ref().clone(),
        })
        .collect();
    arrow::datatypes::Schema::new_with_metadata(fields, schema.metadata().clone())
}
Possible Issue

typesMatch ignores nullability and all Timestamp precision/timezone parameters. If the producer emits Timestamp(Millisecond, "UTC") and the declared schema is Timestamp(Millisecond, null), the types match here but Arrow's C Data import may reject the batch due to timezone mismatch. The comment claims divergence is harmless, but timezone mismatches can cause runtime errors in Arrow FFI.

private static boolean typesMatch(Schema actual, Schema declared) {
    List<Field> a = actual.getFields();
    List<Field> d = declared.getFields();
    if (a.size() != d.size()) {
        return false;
    }
    for (int i = 0; i < a.size(); i++) {
        ArrowType at = a.get(i).getType();
        ArrowType dt = d.get(i).getType();
        if (at.getTypeID() == ArrowType.ArrowTypeID.Timestamp && dt.getTypeID() == ArrowType.ArrowTypeID.Timestamp) {
            continue;
        }
        if (!at.equals(dt)) {
            return false;
        }
    }
    return true;
}

@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 9a0c6b0

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Verify field names match

The typesMatch method should also verify field names match between schemas.
Currently it only checks types and field count, but mismatched field names could
cause data corruption when columns are accessed by name rather than index. Add a
name equality check alongside the type comparison.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [263-280]

 private static boolean typesMatch(Schema actual, Schema declared) {
     List<Field> a = actual.getFields();
     List<Field> d = declared.getFields();
     if (a.size() != d.size()) {
         return false;
     }
     for (int i = 0; i < a.size(); i++) {
+        if (!a.get(i).getName().equals(d.get(i).getName())) {
+            return false;
+        }
         ArrowType at = a.get(i).getType();
         ArrowType dt = d.get(i).getType();
         if (at.getTypeID() == ArrowType.ArrowTypeID.Timestamp && dt.getTypeID() == ArrowType.ArrowTypeID.Timestamp) {
             continue;
         }
         if (!at.equals(dt)) {
             return false;
         }
     }
     return true;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that typesMatch only validates field types and count but not field names. Since Arrow schemas can be accessed by name, mismatched names could lead to incorrect column mapping. Adding a name check would strengthen the validation, though the current implementation may work if columns are accessed by index only.

Medium

Previous suggestions

Suggestions up to commit 5b48bff
CategorySuggestion                                                                                                                                    Impact
Possible issue
Capture schema before closing batch

The batch is closed before throwing the exception, but the exception message
includes batch.getSchema() which accesses the closed batch. This can lead to
undefined behavior or errors when accessing a closed VectorSchemaRoot. Capture the
schema before closing the batch.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [210-219]

 if (!typesMatch(batch.getSchema(), declaredSchema)) {
+    Schema batchSchema = batch.getSchema();
     batch.close();
     throw new IllegalStateException(
         "DatafusionReduceSink: batch schema types do not match declared schema. "
             + "declared="
             + declaredSchema
             + " batch="
-            + batch.getSchema()
+            + batchSchema
     );
 }
Suggestion importance[1-10]: 9

__

Why: Accessing batch.getSchema() after closing the batch can lead to undefined behavior. The suggestion correctly identifies this critical issue and provides a proper fix by capturing the schema before closing.

High
General
Add cleanup for partial registration failures

If registerPartitionStream throws an exception after successfully registering some
inputs, the partially-registered senders are not cleaned up. This can lead to
resource leaks. Wrap the registration loop in a try-catch block and close any
registered senders on failure.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [113-123]

-NativeBridge.RegisteredInput registered = NativeBridge.registerPartitionStream(
-    session.getPointer(),
-    inputIdFor(childStageId),
-    producerPlanBytes
-);
-senders.put(childStageId, new DatafusionPartitionSender(registered.pointer()));
-childSchemas.put(childStageId, ArrowSchemaIpc.fromBytes(registered.schemaIpc()));
+try {
+    for (Map.Entry<Integer, byte[]> child : childInputs.entrySet()) {
+        int childStageId = child.getKey();
+        byte[] producerPlanBytes = child.getValue();
+        NativeBridge.RegisteredInput registered = NativeBridge.registerPartitionStream(
+            session.getPointer(),
+            inputIdFor(childStageId),
+            producerPlanBytes
+        );
+        senders.put(childStageId, new DatafusionPartitionSender(registered.pointer()));
+        childSchemas.put(childStageId, ArrowSchemaIpc.fromBytes(registered.schemaIpc()));
+    }
+} catch (Exception e) {
+    for (DatafusionPartitionSender sender : senders.values()) {
+        sender.close();
+    }
+    throw e;
+}
Suggestion importance[1-10]: 7

__

Why: The suggestion identifies a valid resource leak scenario where partial registration failures could leave senders unclosed. However, the existing code already has a try-catch block at the outer level (line 95) that handles cleanup, making this less critical than initially appears.

Medium
Suggestions up to commit da94c42
CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid accessing closed batch schema

The batch is closed before throwing the exception, but the exception message
references batch.getSchema() which accesses the closed batch. This could lead to
undefined behavior or errors when constructing the exception message. Move the
schema extraction before closing the batch.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [210-219]

 if (!typesMatch(batch.getSchema(), declaredSchema)) {
+    String batchSchemaStr = batch.getSchema().toString();
     batch.close();
     throw new IllegalStateException(
         "DatafusionReduceSink: batch schema types do not match declared schema. "
             + "declared="
             + declaredSchema
             + " batch="
-            + batch.getSchema()
+            + batchSchemaStr
     );
 }
Suggestion importance[1-10]: 9

__

Why: Accessing batch.getSchema() after batch.close() is a critical bug that could cause undefined behavior or exceptions. The schema should be extracted before closing the batch.

High
General
Handle nested timestamp precision coercion

The function only handles top-level Timestamp(Second) fields but doesn't recursively
process nested struct or list fields that might contain timestamps. If the schema
contains nested timestamp fields with unsupported precision, they won't be coerced,
potentially causing schema mismatches.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [642-660]

 fn coerce_unsupported_timestamp_precision(
     schema: &arrow::datatypes::Schema,
 ) -> arrow::datatypes::Schema {
     use arrow::datatypes::{DataType, Field, TimeUnit};
-    let fields: Vec<Field> = schema
-        .fields()
-        .iter()
-        .map(|f| match f.data_type() {
-            DataType::Timestamp(TimeUnit::Second, tz) => Field::new(
-                f.name(),
+    fn coerce_field(f: &Field) -> Field {
+        let coerced_type = match f.data_type() {
+            DataType::Timestamp(TimeUnit::Second, tz) => 
                 DataType::Timestamp(TimeUnit::Millisecond, tz.clone()),
-                f.is_nullable(),
-            )
-            .with_metadata(f.metadata().clone()),
-            _ => f.as_ref().clone(),
-        })
-        .collect();
+            DataType::Struct(fields) => 
+                DataType::Struct(fields.iter().map(coerce_field).collect()),
+            DataType::List(inner) => 
+                DataType::List(Arc::new(coerce_field(inner))),
+            _ => f.data_type().clone(),
+        };
+        Field::new(f.name(), coerced_type, f.is_nullable())
+            .with_metadata(f.metadata().clone())
+    }
+    let fields: Vec<Field> = schema.fields().iter().map(coerce_field).collect();
     arrow::datatypes::Schema::new_with_metadata(fields, schema.metadata().clone())
 }
Suggestion importance[1-10]: 6

__

Why: Valid observation that nested timestamp fields aren't handled, but the current implementation may be sufficient for the actual data structures used. The suggestion adds complexity that may not be needed unless nested timestamps are actually encountered in production.

Low
Clarify schema transformation ownership

The coerce_unsupported_timestamp_precision function returns a new Schema but the
result is assigned to a variable with the same name, shadowing the previous binding.
This pattern works but could be clearer by using a mutable variable or a different
name to indicate the transformation.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [607]

-let arrow_schema = coerce_unsupported_timestamp_precision(&arrow_schema);
+let arrow_schema = Arc::new(coerce_unsupported_timestamp_precision(arrow_schema.as_ref()));
Suggestion importance[1-10]: 3

__

Why: The variable shadowing pattern is valid Rust and commonly used for transformations. The suggestion to wrap in Arc::new changes the type unnecessarily since coerce_unsupported_timestamp_precision already returns a Schema, not an Arc<Schema>.

Low
Suggestions up to commit 9510f74
CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid accessing closed batch schema

The batch is closed before throwing the exception, but the exception message
includes batch.getSchema() which accesses the already-closed batch. This could lead
to undefined behavior or errors when constructing the exception message. Move the
schema extraction before closing the batch.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [210-219]

 if (!typesMatch(batch.getSchema(), declaredSchema)) {
+    String batchSchemaStr = batch.getSchema().toString();
     batch.close();
     throw new IllegalStateException(
         "DatafusionReduceSink: batch schema types do not match declared schema. "
             + "declared="
             + declaredSchema
             + " batch="
-            + batch.getSchema()
+            + batchSchemaStr
     );
 }
Suggestion importance[1-10]: 9

__

Why: Accessing batch.getSchema() after batch.close() is a critical bug that could lead to undefined behavior or errors. The schema should be extracted before closing the batch.

High
General
Reduce schema cloning overhead

The table_schema is cloned for every batch in the loop. For large batch counts, this
repeated cloning of the schema (which includes metadata and field definitions) could
impact performance. Consider using Arc::clone on a wrapped schema or restructure to
avoid repeated clones.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [914-937]

 let table_schema = derive_schema_from_partial_plan(partial_plan_bytes)?;
 let schema_ipc = schema_to_ipc_bytes(table_schema.as_ref())?;
+let table_schema_arc = Arc::new((*table_schema).clone());
 ...
 for (&array_ptr, &schema_ptr) in array_ptrs.iter().zip(schema_ptrs.iter()) {
     ...
-    let aligned = RecordBatch::try_new(table_schema.clone(), columns)?;
+    let aligned = RecordBatch::try_new(Arc::clone(&table_schema_arc), columns)?;
     batches.push(aligned);
 }
Suggestion importance[1-10]: 5

__

Why: Reducing repeated schema clones in a loop is a reasonable performance optimization, especially for large batch counts. However, the actual impact depends on batch size and schema complexity, making this a moderate improvement.

Low
Optimize error path execution order

If register_partition fails after schema_to_ipc_bytes succeeds, the function returns
an error but the allocated schema_ipc buffer is already constructed. While this is
not a memory leak (the buffer is owned and will be dropped), consider the ordering
to avoid unnecessary work on the error path.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [769-773]

 let schema = derive_schema_from_partial_plan(partial_plan_bytes)?;
+let sender = session.register_partition(input_id, schema.clone())?;
 let schema_ipc = schema_to_ipc_bytes(schema.as_ref())?;
-let sender = session.register_partition(input_id, schema)?;
 Ok((Box::into_raw(Box::new(sender)) as i64, schema_ipc))
Suggestion importance[1-10]: 4

__

Why: While reordering operations to avoid unnecessary work on error paths is a valid optimization, the impact is minimal since schema_to_ipc_bytes is relatively lightweight and errors from register_partition are expected to be rare.

Low
Suggestions up to commit 9510f74
CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid accessing closed batch schema

The batch is closed before throwing the exception, but the exception message
includes batch.getSchema() which accesses the now-closed batch. This could lead to
undefined behavior or errors when constructing the error message. Move the schema
extraction before closing the batch.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java [210-219]

 if (!typesMatch(batch.getSchema(), declaredSchema)) {
+    String batchSchemaStr = batch.getSchema().toString();
     batch.close();
     throw new IllegalStateException(
         "DatafusionReduceSink: batch schema types do not match declared schema. "
             + "declared="
             + declaredSchema
             + " batch="
-            + batch.getSchema()
+            + batchSchemaStr
     );
 }
Suggestion importance[1-10]: 9

__

Why: Accessing batch.getSchema() after batch.close() is a critical bug that could lead to undefined behavior or errors. The schema should be extracted before closing the batch to ensure safe access in the error message.

High
General
Handle table registration errors explicitly

The comment states that duplicate registration is a no-op, but the result is
silently discarded with let _ =. If registration fails for reasons other than
duplication (e.g., invalid table name), the error is ignored. Consider handling or
at least logging registration failures.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [609-612]

 let table = MemTable::try_new(Arc::new(arrow_schema), vec![vec![]])?;
 // Plan may scan the same table twice; the second register is a no-op.
-let _ = ctx.register_table(&table_name, Arc::new(table));
+if let Err(e) = ctx.register_table(&table_name, Arc::new(table)) {
+    log::debug!("Table registration for '{}' returned error (may be duplicate): {}", table_name, e);
+}
Suggestion importance[1-10]: 5

__

Why: The suggestion to log registration failures is reasonable for debugging, but the comment explicitly states duplicate registration is expected and harmless. Adding logging would improve observability without changing behavior.

Low
Optimize error path execution order

If register_partition fails after schema_to_ipc_bytes succeeds, the function returns
an error but the allocated schema_ipc buffer is already constructed. While this is
not a memory leak (Rust will drop it), consider whether the error path should be
optimized to avoid unnecessary work.

sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs [769-777]

 let schema = derive_schema_from_partial_plan(partial_plan_bytes)?;
+let sender = session.register_partition(input_id, schema.clone())?;
 let schema_ipc = schema_to_ipc_bytes(schema.as_ref())?;
-let sender = session.register_partition(input_id, schema)?;
 Ok((Box::into_raw(Box::new(sender)) as i64, schema_ipc))
Suggestion importance[1-10]: 3

__

Why: While reordering operations could avoid unnecessary work in the error path, the current implementation is correct and the performance impact is negligible. The schema_ipc encoding is relatively cheap and the error path is exceptional.

Low

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9510f74

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit da94c42

…vertFragment

Drop the convertShardScanFragment(String, RelNode) and
convertFinalAggFragment(RelNode) overloads from the FragmentConvertor
SPI in favor of a single convertFragment(RelNode). The two overloads
had identical bodies in DataFusionFragmentConvertor (the table-name
parameter was unused after upstream refactors), so the surface
distinction was carrying no semantic weight.

attachPartialAggOnTop and attachFragmentOnTop remain unchanged; only
the leaf conversion entry collapses.

Net SPI surface: -2 methods, -80 LOC across the SPI, the DF
implementation, and the FragmentConversionDriver caller. Tests
realigned to call the unified entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5b48bff

sandeshkr419 and others added 2 commits May 16, 2026 14:10
…nto input registration

Eliminate the per-cell + per-batch type coercions on the data-node ->
coordinator wire path by deriving the consumer's StreamingTable schema
from the producer's substrait at registration time on the Rust side.
Two FFI hops collapse into one; the schema crosses the language
boundary once, in the direction it's needed.

Java side
* NativeBridge.registerPartitionStream / registerMemtable now take the
  producer-side substrait plan bytes and return both a sender pointer
  and the IPC-encoded schema the native session settled on after
  lowering, packed in a RegisteredInput record. partialPlanOutputSchema
  wrapper deleted.
* ExchangeSinkProvider.partialAggOutputSchema SPI method deleted along
  with the DataFusion override.
* ExchangeSinkContext.ChildInput drops its Arrow Schema field; carries
  the producer plan bytes instead. inputSchema() convenience deleted.
* DatafusionReduceSink: per-cell coerceToDeclaredSchema loop replaced
  with a typesMatch tripwire fed from the native-returned schema.
  Per-cell allocation gone; type validation is type-only and ignores
  nullability + Timestamp precision.
* DataFusionReduceState gains an inputSchemas list parallel to senders,
  threading post-registration schemas from FinalAggregateInstructionHandler
  to DatafusionReduceSink.
* AbstractDatafusionReduceSink populates childSchemas lazily from each
  registration return rather than eagerly from ctx.childInputs().
* LocalStageScheduler stops asking the backend for per-child schema;
  hands plan bytes through. ArrowSchemaFromCalcite (the row-type-based
  fallback) deleted.

Rust side
* api.rs: partial_plan_output_schema standalone FFI deleted; folded
  into register_partition_stream / register_memtable as
  derive_schema_from_partial_plan. Schema is encoded once via
  schema_to_ipc_bytes and returned through a caller-allocated out
  buffer.
* ffm.rs: df_partial_plan_output_schema C-ABI deleted; new
  write_out_buffer helper deduplicates the "copy bytes into caller
  buffer + write byte count" pattern across df_sql_to_substrait and
  the two register_* exports.
* derive_schema_from_partial_plan registers a synthetic MemTable from
  the substrait base_schema, then runs from_substrait_plan +
  create_physical_plan to get the lowered output schema. The synthetic
  leaf must match what the data-node parquet read leaf would produce,
  so two parquet-read transformations are mirrored on the synthetic
  base_schema before MemTable construction:
    - Utf8 -> Utf8View (gated on schema_force_view_types)
    - Timestamp(Second) -> Timestamp(Millisecond) (parquet has no
      logical TIMESTAMP_SECOND, so the data node always promotes)
  Both are zero-copy at runtime -- they only configure the
  StreamingTable's declared schema so producer batches slot in via FFI
  without reinterpretation. Long-term plan: have the data node embed
  its lowered output schema as substrait extension metadata so the
  coordinator skips the throwaway lowering and both mirrors evaporate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
…UDFs

DataFusion's parquet reader emits Utf8View for string columns when
schema_force_view_types is true (default). Previously every
string-accepting UDF in this plugin canonicalized inputs to Utf8 via
either coerce_types (json family + reusers of CoerceMode::Utf8) or
explicit Signature::Exact lists (sha1, strftime, tostring), which
caused DataFusion to insert a per-batch vectorized cast at every UDF
call site in the partial plan -- O(rows x bytes_per_value) memcpy and
an Arrow buffer allocation each time.

Drop the canonicalization. UDFs now accept Utf8 / LargeUtf8 / Utf8View
natively and dispatch over them via a shared StringArrayView enum
(introduced in udf::json_common). The columnar dispatch happens once
at array-acquisition time; per-row access is a small enum match. No
per-batch cast, no buffer copy.

Touched UDFs:
* coerce_slot in udf::mod, mode CoerceMode::Utf8: now passes the
  observed variant through unchanged.
* json family (7 UDFs): json_append, json_array_length, json_delete,
  json_extend, json_extract, json_keys, json_set -- switch from
  as_utf8_array to StringArrayView.
* Other coerce_args(.., CoerceMode::Utf8) users (6 UDFs): convert_tz,
  rex_extract, rex_extract_multi, rex_offset, mvfind, tonumber --
  bodies switch to StringArrayView. mvfind also fixes its inner list-
  element scan, which previously silently no-matched on Utf8View list
  children.
* UDFs with explicit Signature::Exact lists (3): sha1, strftime,
  tostring -- Signatures gain Utf8View entries; bodies use
  StringArrayView. strftime's coerce_types passes the format variant
  through instead of forcing Utf8.

mvappend was already correct (already dispatched over all three
string variants) and is untouched.

Net diff: ~+150 / -490 LOC across the udf/ tree. Test duplicates
removed where the per-variant accept/reject contract is now covered
centrally by udf::tests::utf8_*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
@sandeshkr419
sandeshkr419 merged commit 35ce147 into opensearch-project:main May 16, 2026
13 of 15 checks passed
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request May 17, 2026
…cion across the data-node→coordinator pipeline (opensearch-project#21690)

* [Analytics Engine] Collapse FragmentConvertor convert* methods to convertFragment

Drop the convertShardScanFragment(String, RelNode) and
convertFinalAggFragment(RelNode) overloads from the FragmentConvertor
SPI in favor of a single convertFragment(RelNode). The two overloads
had identical bodies in DataFusionFragmentConvertor (the table-name
parameter was unused after upstream refactors), so the surface
distinction was carrying no semantic weight.

attachPartialAggOnTop and attachFragmentOnTop remain unchanged; only
the leaf conversion entry collapses.

Net SPI surface: -2 methods, -80 LOC across the SPI, the DF
implementation, and the FragmentConversionDriver caller. Tests
realigned to call the unified entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* [Analytics Engine / DataFusion] Fold partial-plan schema derivation into input registration

Eliminate the per-cell + per-batch type coercions on the data-node ->
coordinator wire path by deriving the consumer's StreamingTable schema
from the producer's substrait at registration time on the Rust side.
Two FFI hops collapse into one; the schema crosses the language
boundary once, in the direction it's needed.

Java side
* NativeBridge.registerPartitionStream / registerMemtable now take the
  producer-side substrait plan bytes and return both a sender pointer
  and the IPC-encoded schema the native session settled on after
  lowering, packed in a RegisteredInput record. partialPlanOutputSchema
  wrapper deleted.
* ExchangeSinkProvider.partialAggOutputSchema SPI method deleted along
  with the DataFusion override.
* ExchangeSinkContext.ChildInput drops its Arrow Schema field; carries
  the producer plan bytes instead. inputSchema() convenience deleted.
* DatafusionReduceSink: per-cell coerceToDeclaredSchema loop replaced
  with a typesMatch tripwire fed from the native-returned schema.
  Per-cell allocation gone; type validation is type-only and ignores
  nullability + Timestamp precision.
* DataFusionReduceState gains an inputSchemas list parallel to senders,
  threading post-registration schemas from FinalAggregateInstructionHandler
  to DatafusionReduceSink.
* AbstractDatafusionReduceSink populates childSchemas lazily from each
  registration return rather than eagerly from ctx.childInputs().
* LocalStageScheduler stops asking the backend for per-child schema;
  hands plan bytes through. ArrowSchemaFromCalcite (the row-type-based
  fallback) deleted.

Rust side
* api.rs: partial_plan_output_schema standalone FFI deleted; folded
  into register_partition_stream / register_memtable as
  derive_schema_from_partial_plan. Schema is encoded once via
  schema_to_ipc_bytes and returned through a caller-allocated out
  buffer.
* ffm.rs: df_partial_plan_output_schema C-ABI deleted; new
  write_out_buffer helper deduplicates the "copy bytes into caller
  buffer + write byte count" pattern across df_sql_to_substrait and
  the two register_* exports.
* derive_schema_from_partial_plan registers a synthetic MemTable from
  the substrait base_schema, then runs from_substrait_plan +
  create_physical_plan to get the lowered output schema. The synthetic
  leaf must match what the data-node parquet read leaf would produce,
  so two parquet-read transformations are mirrored on the synthetic
  base_schema before MemTable construction:
    - Utf8 -> Utf8View (gated on schema_force_view_types)
    - Timestamp(Second) -> Timestamp(Millisecond) (parquet has no
      logical TIMESTAMP_SECOND, so the data node always promotes)
  Both are zero-copy at runtime -- they only configure the
  StreamingTable's declared schema so producer batches slot in via FFI
  without reinterpretation. Long-term plan: have the data node embed
  its lowered output schema as substrait extension metadata so the
  coordinator skips the throwaway lowering and both mirrors evaporate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* [Analytics Backend / DataFusion] Accept any string variant in scalar UDFs

DataFusion's parquet reader emits Utf8View for string columns when
schema_force_view_types is true (default). Previously every
string-accepting UDF in this plugin canonicalized inputs to Utf8 via
either coerce_types (json family + reusers of CoerceMode::Utf8) or
explicit Signature::Exact lists (sha1, strftime, tostring), which
caused DataFusion to insert a per-batch vectorized cast at every UDF
call site in the partial plan -- O(rows x bytes_per_value) memcpy and
an Arrow buffer allocation each time.

Drop the canonicalization. UDFs now accept Utf8 / LargeUtf8 / Utf8View
natively and dispatch over them via a shared StringArrayView enum
(introduced in udf::json_common). The columnar dispatch happens once
at array-acquisition time; per-row access is a small enum match. No
per-batch cast, no buffer copy.

Touched UDFs:
* coerce_slot in udf::mod, mode CoerceMode::Utf8: now passes the
  observed variant through unchanged.
* json family (7 UDFs): json_append, json_array_length, json_delete,
  json_extend, json_extract, json_keys, json_set -- switch from
  as_utf8_array to StringArrayView.
* Other coerce_args(.., CoerceMode::Utf8) users (6 UDFs): convert_tz,
  rex_extract, rex_extract_multi, rex_offset, mvfind, tonumber --
  bodies switch to StringArrayView. mvfind also fixes its inner list-
  element scan, which previously silently no-matched on Utf8View list
  children.
* UDFs with explicit Signature::Exact lists (3): sha1, strftime,
  tostring -- Signatures gain Utf8View entries; bodies use
  StringArrayView. strftime's coerce_types passes the format variant
  through instead of forcing Utf8.

mvappend was already correct (already dispatched over all three
string variants) and is untouched.

Net diff: ~+150 / -490 LOC across the udf/ tree. Test duplicates
removed where the per-variant accept/reject contract is now covered
centrally by udf::tests::utf8_*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Khishorekumar BS <bkhishor@amazon.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request May 19, 2026
…g aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 19, 2026
Mirrors the union surface from CalciteUnionCommandIT in opensearch-project/sql
so the analytics-engine path can be verified inside core without cross-plugin
dependencies on the SQL plugin. Seven tests against POST /_analytics/ppl over
the existing calcs dataset:

- testBasicUnionTwoSubsearches — two-subsearch shape
- testUnionThreeSubsearches — three-subsearch shape
- testUnionMidPipelineSingleExplicitDataset — search ... | union [search ...]
- testUnionPreservesDuplicates — three identical branches → 3x row count
- testUnionWithEmptySubsearch — one branch returns 0 rows
- testUnionWithAllEmptyDatasets — both branches empty
- testUnionWithSingleSubsearchThrowsError — validation error surfaces

Assertions are dataset-independent (sums of branch counts, not hardcoded
totals) so the suite stays resilient to calcs bulk-data changes.

Also serves as the end-to-end regression net for the reduce-sink's
Timestamp handling. calcs carries date/time/datetime columns; each
branch's partial output schema includes them, so DatafusionReduceSink.typesMatch
(the loose-match tripwire added in opensearch-project#21690 alongside the native-side coercion
in derive_schema_from_partial_plan) gets exercised on every test here even
when the final projection is | stats count().

Originally this IT was paired with a Java-side Timestamp→Timestamp coercion
patch in DatafusionReduceSink.coerceToDeclaredSchema. PR opensearch-project#21690 superseded
that approach by moving the coercion into Rust at input-registration time,
so the Java-side patch is dropped from this PR; the IT remains because the
union surface itself is worth keeping in core's analytics-engine QA suite.

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

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
mch2 pushed a commit that referenced this pull request May 20, 2026
…o-end (#21731)

* analytics-framework: extend AggregateFunction SPI for state-expanding aggregates

Adds five enum entries for PPL TAKE / FIRST / LAST / LIST / VALUES and an
input-parameterised IntermediateField type resolver. Existing fixed-shape
aggregates (HLL Binary sketch, COUNT Int64 counter) keep their constant
intermediate types via IntermediateTypeResolver.fixed(); state-expanding
aggregates whose FINAL state shape derives from arg 0 use
IntermediateTypeResolver.passThroughArg0() so the planner can resolve the
exchange column type from the actual call's arg type rather than a constant.

Internalises the Arrow → Calcite type mapping (previously in the planner
module's ArrowCalciteTypes) so the SPI is self-contained.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: wire 5 PPL state-expanding aggregates end-to-end

Wires PPL TAKE / FIRST / LAST / LIST / VALUES through the DataFusion backend:

* Rust UDAF for take(field, n) at rust/src/udaf/take.rs. Limit is read from a
  Substrait Literal when present, or from row 0 of arg 1 when Calcite has
  materialised n as a constant Project column. Default n = 10. State is a
  bounded List<elem> emitted from state(); coordinator-side merge_batch
  concatenates and re-truncates. UDAF registered on every SessionContext
  via crate::udaf::register_all.

* Substrait extension entries in opensearch_aggregate_functions.yaml for
  take, first_value, last_value, and array_agg. take's 2-arg shape uses
  distinct any1/any2 generics so isthmus's wildcard binding accepts
  mismatched arg types (state list<elem> + integer n).

* DataFusionFragmentConvertor: LOCAL_TAKE_OP / LOCAL_FIRST_OP / LOCAL_LAST_OP /
  LOCAL_ARRAY_AGG_OP stub SqlAggFunctions plus a pre-emit RelShuttle that
  rewrites PPL aggregation calls (TAKE / FIRST / LAST / LIST / VALUES) onto
  the stubs so isthmus's AggregateFunctionConverter resolves them through
  ADDITIONAL_AGGREGATE_SIGS to the YAML extension names. LIST/VALUES rebuild
  the call's return type as ARRAY<actual-arg0> to override PPL's lossy
  STRING_ARRAY which would otherwise force ARRAY<VARCHAR>.

* SubstraitPlanRewriter.visit(Aggregate): post-emit pass that inlines
  Project-column literals into Aggregate.Measure args. Required because
  Calcite's RelBuilder.aggregate auto-projects literal aggregate-args as
  $f1 columns, leaving the substrait emit with FieldRefs instead of
  Literals.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: distributed planner rewrites for PPL state-expanding aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post #21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: cross-shard LIST and VALUES via list_merge UDAF

Two stacked issues blocked cross-shard LIST/VALUES:

1. PPL declares LIST/VALUES return type via PPLReturnTypes.STRING_ARRAY which
   forces ARRAY<VARCHAR> regardless of input element type. Fix: repair the
   PARTIAL aggCall return type to ARRAY<actual-arg0> in OpenSearchAggregateSplitRule
   (PARTIAL only — FINAL keeps the original list to satisfy Volcano's parent
   row-type check on transformTo). The corrected type propagates through
   PARTIAL output → StageInputScan → FINAL substrait base_schema.

2. DataFusion's substrait consumer ignores AggregationPhase, so a FINAL
   array_agg(state) gets lowered as a single-pass aggregate that re-wraps each
   shard's list rather than concatenating. Fix: custom Rust UDAFs `list_merge`
   and `list_merge_distinct` whose `update_batch` un-nests each List<elem> row
   into elements (with optional dedup). Same pattern as TAKE's accumulator.
   The convertor's pre-emit RelShuttle detects the FINAL form (arg0 already
   a list type) and routes LIST → list_merge, VALUES → list_merge_distinct.

PARTIAL still uses DataFusion's native array_agg (with INVOCATION_DISTINCT for
VALUES). Cross-shard testListAcrossShards / testValuesAcrossShards pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: relocate state-expanding aggregate rewrites

Addresses two review comments on the state-expanding aggregate (TAKE / FIRST
/ LAST / LIST / VALUES) wiring.

1. Move literal-arg inlining off the post-emit Substrait IR pass and onto
   the isthmus binding layer. Calcite's RelBuilder.aggregate auto-projects
   literal aggregate-args (e.g. TAKE's N) as separate columns and
   AggregateCall.argList only carries column indices, so without inlining
   our Rust UDAFs only see a column reference at construction time. With
   DataFusion's two-stage execution, the Final accumulator dispatches
   through merge_batch — which receives only state columns, not the
   constant — so the limit can never be resolved on the coordinator.

   The fix overrides AggregateFunctionConverter.convert() in createVisitor()
   (next to the existing getSigs() override): after isthmus binds the
   AggregateFunctionInvocation, replace any FieldReference arg pointing to
   a literal Project column with the converted RexLiteral via the supplied
   Function<RexNode, Expression> — same Substrait literal an inlined arg
   would produce, routed through isthmus's standard literal converter.
   SubstraitPlanRewriter.visit(Aggregate) and its simpleStructOffset helper
   are deleted; the post-emit pass now only handles Filter rewrites and
   the PrecisionTimestampLiteral precision fix.

2. Extract the PPL aggregate-name → LOCAL_*_OP rewrite from
   DataFusionFragmentConvertor into a new package-private
   PplAggregateCallRewriter, matching the UntypedNullPreprocessor /
   DatetimeOutputCastRewriter sibling pattern. The if/else-if chain on
   operator name becomes a switch on toUpperCase(Locale.ROOT) and the
   six-way `==` chain that gates already-rewritten calls becomes a
   Set<SqlAggFunction> contains check. The two call sites in
   DataFusionFragmentConvertor now read like the other preprocessor
   invocations.

CoordinatorReduceIT (18 tests), integTestMemtable, and integTestStreaming
all pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
nssuresh2007 pushed a commit to nssuresh2007/OpenSearch that referenced this pull request May 20, 2026
…o-end (opensearch-project#21731)

* analytics-framework: extend AggregateFunction SPI for state-expanding aggregates

Adds five enum entries for PPL TAKE / FIRST / LAST / LIST / VALUES and an
input-parameterised IntermediateField type resolver. Existing fixed-shape
aggregates (HLL Binary sketch, COUNT Int64 counter) keep their constant
intermediate types via IntermediateTypeResolver.fixed(); state-expanding
aggregates whose FINAL state shape derives from arg 0 use
IntermediateTypeResolver.passThroughArg0() so the planner can resolve the
exchange column type from the actual call's arg type rather than a constant.

Internalises the Arrow → Calcite type mapping (previously in the planner
module's ArrowCalciteTypes) so the SPI is self-contained.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: wire 5 PPL state-expanding aggregates end-to-end

Wires PPL TAKE / FIRST / LAST / LIST / VALUES through the DataFusion backend:

* Rust UDAF for take(field, n) at rust/src/udaf/take.rs. Limit is read from a
  Substrait Literal when present, or from row 0 of arg 1 when Calcite has
  materialised n as a constant Project column. Default n = 10. State is a
  bounded List<elem> emitted from state(); coordinator-side merge_batch
  concatenates and re-truncates. UDAF registered on every SessionContext
  via crate::udaf::register_all.

* Substrait extension entries in opensearch_aggregate_functions.yaml for
  take, first_value, last_value, and array_agg. take's 2-arg shape uses
  distinct any1/any2 generics so isthmus's wildcard binding accepts
  mismatched arg types (state list<elem> + integer n).

* DataFusionFragmentConvertor: LOCAL_TAKE_OP / LOCAL_FIRST_OP / LOCAL_LAST_OP /
  LOCAL_ARRAY_AGG_OP stub SqlAggFunctions plus a pre-emit RelShuttle that
  rewrites PPL aggregation calls (TAKE / FIRST / LAST / LIST / VALUES) onto
  the stubs so isthmus's AggregateFunctionConverter resolves them through
  ADDITIONAL_AGGREGATE_SIGS to the YAML extension names. LIST/VALUES rebuild
  the call's return type as ARRAY<actual-arg0> to override PPL's lossy
  STRING_ARRAY which would otherwise force ARRAY<VARCHAR>.

* SubstraitPlanRewriter.visit(Aggregate): post-emit pass that inlines
  Project-column literals into Aggregate.Measure args. Required because
  Calcite's RelBuilder.aggregate auto-projects literal aggregate-args as
  $f1 columns, leaving the substrait emit with FieldRefs instead of
  Literals.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: distributed planner rewrites for PPL state-expanding aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: cross-shard LIST and VALUES via list_merge UDAF

Two stacked issues blocked cross-shard LIST/VALUES:

1. PPL declares LIST/VALUES return type via PPLReturnTypes.STRING_ARRAY which
   forces ARRAY<VARCHAR> regardless of input element type. Fix: repair the
   PARTIAL aggCall return type to ARRAY<actual-arg0> in OpenSearchAggregateSplitRule
   (PARTIAL only — FINAL keeps the original list to satisfy Volcano's parent
   row-type check on transformTo). The corrected type propagates through
   PARTIAL output → StageInputScan → FINAL substrait base_schema.

2. DataFusion's substrait consumer ignores AggregationPhase, so a FINAL
   array_agg(state) gets lowered as a single-pass aggregate that re-wraps each
   shard's list rather than concatenating. Fix: custom Rust UDAFs `list_merge`
   and `list_merge_distinct` whose `update_batch` un-nests each List<elem> row
   into elements (with optional dedup). Same pattern as TAKE's accumulator.
   The convertor's pre-emit RelShuttle detects the FINAL form (arg0 already
   a list type) and routes LIST → list_merge, VALUES → list_merge_distinct.

PARTIAL still uses DataFusion's native array_agg (with INVOCATION_DISTINCT for
VALUES). Cross-shard testListAcrossShards / testValuesAcrossShards pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: relocate state-expanding aggregate rewrites

Addresses two review comments on the state-expanding aggregate (TAKE / FIRST
/ LAST / LIST / VALUES) wiring.

1. Move literal-arg inlining off the post-emit Substrait IR pass and onto
   the isthmus binding layer. Calcite's RelBuilder.aggregate auto-projects
   literal aggregate-args (e.g. TAKE's N) as separate columns and
   AggregateCall.argList only carries column indices, so without inlining
   our Rust UDAFs only see a column reference at construction time. With
   DataFusion's two-stage execution, the Final accumulator dispatches
   through merge_batch — which receives only state columns, not the
   constant — so the limit can never be resolved on the coordinator.

   The fix overrides AggregateFunctionConverter.convert() in createVisitor()
   (next to the existing getSigs() override): after isthmus binds the
   AggregateFunctionInvocation, replace any FieldReference arg pointing to
   a literal Project column with the converted RexLiteral via the supplied
   Function<RexNode, Expression> — same Substrait literal an inlined arg
   would produce, routed through isthmus's standard literal converter.
   SubstraitPlanRewriter.visit(Aggregate) and its simpleStructOffset helper
   are deleted; the post-emit pass now only handles Filter rewrites and
   the PrecisionTimestampLiteral precision fix.

2. Extract the PPL aggregate-name → LOCAL_*_OP rewrite from
   DataFusionFragmentConvertor into a new package-private
   PplAggregateCallRewriter, matching the UntypedNullPreprocessor /
   DatetimeOutputCastRewriter sibling pattern. The if/else-if chain on
   operator name becomes a switch on toUpperCase(Locale.ROOT) and the
   six-way `==` chain that gates already-rewritten calls becomes a
   Set<SqlAggFunction> contains check. The two call sites in
   DataFusionFragmentConvertor now read like the other preprocessor
   invocations.

CoordinatorReduceIT (18 tests), integTestMemtable, and integTestStreaming
all pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
songkant-aws added a commit to songkant-aws/OpenSearch that referenced this pull request May 30, 2026
Removes the FragmentConversionDriver convertReduceFragment unification that
this PR introduced, restoring main's recursive convertReduceNode form. Also
removes the two regression assertions we added on top:

- testReduceStageWindowHelperChainConvertedAsSingleSubtree (deleted)
- the attachFragmentCalled assertFalse line in testTwoStageSortOnAggregateOnFilteredScan
- RecordingConvertor.attachFragmentCalled field

Verified empirically against the full analytics-engine-rest IT suite with
the recursive form restored: 770 / 770 IT pass (42 pre-existing @AwaitsFix
skips). The DataFusion runtime panic that originally motivated Part 2 ("index
out of bounds: len=6 idx=14" on streamstats by helper chains) is no longer
reachable on top of merged main — likely covered by upstream rel-layer fixes
since the original investigation (opensearch-project#21875's DistributedAggregateRewriter
overrideExchangeType transformer + opensearch-project#21690's string/utf8 view & timestamp
coercion elimination).

Keep this PR focused on Part 1 (wire dc / earliest / latest / nth_value).
The reduce-stage simplification, if revisited, should be its own PR with
fresh justification.

Signed-off-by: Songkan Tang <songkant@amazon.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request Jun 5, 2026
…CT via HEP rule + strip aggregate state suffix in coord StreamingTable schema

Two complementary fixes for the analytics-engine cross-shard aggregate path.

(1) Plan-layer rewrite (Java, OpenSearchDistinctCountRule)

PPL `dc(x)` / `distinct_count(x)` parse to `COUNT(DISTINCT x)` at Calcite via
the SQL plugin's `AstExpressionBuilder.visitDistinctCountFunctionCall`. Without
intervention the call lands as `SqlStdOperatorTable.COUNT` with isDistinct=true,
the additive split rule decomposes it (PARTIAL count(distinct) + FINAL SUM-of-counts),
and cross-shard reduce over-counts any value present on more than one shard.

Replace 6d98's `AggregateFunction.resolveOperator` SPI hook (which polluted the
framework enum and rebuilt AggregateCalls with the original COUNT return type, risking
`Aggregate.typeMatchesInferred` mismatches against APPROX_COUNT_DISTINCT's
inferReturnType) with a dedicated HEP rule:

* New `OpenSearchDistinctCountRule` matches plain `LogicalAggregate` containing a
  single-arg `COUNT(DISTINCT x)` and rewrites it to `APPROX_COUNT_DISTINCT(x)`
  (isDistinct=false on the rewritten call). Built via the long-form `AggregateCall.create`
  with `(groupCount, input, type=null)` so Calcite re-infers the return type from
  `APPROX_COUNT_DISTINCT.inferReturnType(...)` — avoids the typeMatchesInferred mismatch
  the SPI rebuild would produce.
* Wired into the existing `PlannerImpl.decomposeAggregates` HEP phase alongside
  `OpenSearchAggregateReduceRule`, before `OpenSearchAggregateRule` marks the aggregate.
  Multi-arg `COUNT(DISTINCT a, b)` doesn't match — falls through to the residual
  `aggCall.isDistinct()` skip in `OpenSearchAggregateSplitRule`.

After this, the aggregate engages the existing
`AggregateFunction.APPROX_COUNT_DISTINCT(Type.APPROXIMATE, intermediateFields=[sketch:Binary, reducer==self])`
registration — capability resolution, structural split, `DistributedAggregateRewriter.overrideExchangeType`,
and the Substrait extension YAML `approx_distinct` alias all light up automatically.

* Drop `AggregateFunction.COUNT.resolveOperator` override + the default `resolveOperator`
  method on the enum.
* Drop `OpenSearchAggregateRule.resolveAggregateCall` + its unused `SqlAggFunction` import.
* Drop `Type.APPROXIMATE` from `OpenSearchAggregateSplitRule.shouldSkipPartialFinalSplit`
  (residual `aggCall.isDistinct()` skip stays for multi-arg fallback). APPROXIMATE now goes
  through the structural PARTIAL/FINAL split.

(2) Wire-layer schema bridge (Rust, derive_schema_from_partial_plan)

Commit `35ce14790c2` (opensearch-project#21690) folded the consumer's StreamingTable schema derivation into Rust
to eliminate per-cell coercion overhead; `coerceToDeclaredSchema` on the Java
`feedToSender` path was deleted in favour of running the producer's substrait through
DataFusion's substrait consumer + physical planner and using its output schema verbatim.

The DataFusion 53.x physical planner emits `AggregateExec(Mode::Partial)` columns with
state-suffixed names (`dc[hll_registers]`, `$f0[sum]`, `count(opt)[count]`), but the
FINAL substrait emitted by `attachPartialAggOnTop` declares the user-facing aliases
(`dc`, `$f0`, `count(opt)`). DataFusion's substrait consumer name-resolves the FINAL
Read against the registered StreamingTable and fails with
`Schema error: No field named dc. Valid fields are input-0.dc[hll_registers]`.

Add `strip_aggregate_state_suffix` after `coerce_inferred_schema` in
`derive_schema_from_partial_plan`. Splits each field name on the first `[` so the
StreamingTable's declared names match what the FINAL substrait expects. Wire data still
flows positionally via Arrow C Data — names don't affect runtime data movement;
`typesMatch` on the Java path is type-only by position.

This bridges the same physical-output ↔ declared-schema gap that pre-35ce14790c2 Java's
`coerceToDeclaredSchema` covered, but at the schema-declaration layer (per design §14.5.1
plan-authoring layer) rather than per-cell on every batch.

Tests:
* New `AggregatePlanShapeTests.testCountDistinct_1shard` and `testCountDistinct_2shard` pin
  the rewrite + structural split shape (1-shard SINGLE; 2-shard
  Aggregate(FINAL,APPROX_COUNT_DISTINCT) over Reducer over Aggregate(PARTIAL,APPROX_COUNT_DISTINCT)).
* `countDistinctCall` and `approxCountDistinctCall` helpers added to `BasePlannerRulesTests`
  for plan-shape construction.
* `testCountDistinctRewrittenToApproxCountDistinct` in `AggregateRuleTests` continues to
  validate the rewrite end-to-end through the planner — now via the HEP rule instead of
  the SPI hook.
* TwoShardAggregationIT 2-shard reduce checks: failures down from 17 → 5, all 5 remaining
  are HLL-specific Binary↔Int64 type mismatches at the FINAL substrait Read boundary
  (`Substrait error: Field 'dc' has a different type (Binary) than the corresponding
  field in the table schema (Int64)`). SUM/COUNT/AVG/MIN/MAX now all pass.
* Existing CoordinatorReduceIT `testDistinctCountAcrossShards` and
  `testDistinctCountCrossShardOverlap` / `testDistinctCountCrossShardOverlapKeyword`
  failures are HLL-specific and tracked separately — distributed sketch-merge requires
  activating the dormant SETUP_FINAL_AGGREGATE / prepareFinalPlan path.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
sandeshkr419 added a commit to sandeshkr419/OpenSearch that referenced this pull request Jun 5, 2026
…CT via HEP rule + strip aggregate state suffix in coord StreamingTable schema

Two complementary fixes for the analytics-engine cross-shard aggregate path.

(1) Plan-layer rewrite (Java, OpenSearchDistinctCountRule)

PPL `dc(x)` / `distinct_count(x)` parse to `COUNT(DISTINCT x)` at Calcite via
the SQL plugin's `AstExpressionBuilder.visitDistinctCountFunctionCall`. Without
intervention the call lands as `SqlStdOperatorTable.COUNT` with isDistinct=true,
the additive split rule decomposes it (PARTIAL count(distinct) + FINAL SUM-of-counts),
and cross-shard reduce over-counts any value present on more than one shard.

Replace 6d98's `AggregateFunction.resolveOperator` SPI hook (which polluted the
framework enum and rebuilt AggregateCalls with the original COUNT return type, risking
`Aggregate.typeMatchesInferred` mismatches against APPROX_COUNT_DISTINCT's
inferReturnType) with a dedicated HEP rule:

* New `OpenSearchDistinctCountRule` matches plain `LogicalAggregate` containing a
  single-arg `COUNT(DISTINCT x)` and rewrites it to `APPROX_COUNT_DISTINCT(x)`
  (isDistinct=false on the rewritten call). Built via the long-form `AggregateCall.create`
  with `(groupCount, input, type=null)` so Calcite re-infers the return type from
  `APPROX_COUNT_DISTINCT.inferReturnType(...)` — avoids the typeMatchesInferred mismatch
  the SPI rebuild would produce.
* Wired into the existing `PlannerImpl.decomposeAggregates` HEP phase alongside
  `OpenSearchAggregateReduceRule`, before `OpenSearchAggregateRule` marks the aggregate.
  Multi-arg `COUNT(DISTINCT a, b)` doesn't match — falls through to the residual
  `aggCall.isDistinct()` skip in `OpenSearchAggregateSplitRule`.

After this, the aggregate engages the existing
`AggregateFunction.APPROX_COUNT_DISTINCT(Type.APPROXIMATE, intermediateFields=[sketch:Binary, reducer==self])`
registration — capability resolution, structural split, `DistributedAggregateRewriter.overrideExchangeType`,
and the Substrait extension YAML `approx_distinct` alias all light up automatically.

* Drop `AggregateFunction.COUNT.resolveOperator` override + the default `resolveOperator`
  method on the enum.
* Drop `OpenSearchAggregateRule.resolveAggregateCall` + its unused `SqlAggFunction` import.
* Drop `Type.APPROXIMATE` from `OpenSearchAggregateSplitRule.shouldSkipPartialFinalSplit`
  (residual `aggCall.isDistinct()` skip stays for multi-arg fallback). APPROXIMATE now goes
  through the structural PARTIAL/FINAL split.

(2) Wire-layer schema bridge (Rust, derive_schema_from_partial_plan)

Commit `35ce14790c2` (opensearch-project#21690) folded the consumer's StreamingTable schema derivation into Rust
to eliminate per-cell coercion overhead; `coerceToDeclaredSchema` on the Java
`feedToSender` path was deleted in favour of running the producer's substrait through
DataFusion's substrait consumer + physical planner and using its output schema verbatim.

The DataFusion 53.x physical planner emits `AggregateExec(Mode::Partial)` columns with
state-suffixed names (`dc[hll_registers]`, `$f0[sum]`, `count(opt)[count]`), but the
FINAL substrait emitted by `attachPartialAggOnTop` declares the user-facing aliases
(`dc`, `$f0`, `count(opt)`). DataFusion's substrait consumer name-resolves the FINAL
Read against the registered StreamingTable and fails with
`Schema error: No field named dc. Valid fields are input-0.dc[hll_registers]`.

Add `strip_aggregate_state_suffix` after `coerce_inferred_schema` in
`derive_schema_from_partial_plan`. Splits each field name on the first `[` so the
StreamingTable's declared names match what the FINAL substrait expects. Wire data still
flows positionally via Arrow C Data — names don't affect runtime data movement;
`typesMatch` on the Java path is type-only by position.

This bridges the same physical-output ↔ declared-schema gap that pre-35ce14790c2 Java's
`coerceToDeclaredSchema` covered, but at the schema-declaration layer (per design §14.5.1
plan-authoring layer) rather than per-cell on every batch.

Tests:
* New `AggregatePlanShapeTests.testCountDistinct_1shard` and `testCountDistinct_2shard` pin
  the rewrite + structural split shape (1-shard SINGLE; 2-shard
  Aggregate(FINAL,APPROX_COUNT_DISTINCT) over Reducer over Aggregate(PARTIAL,APPROX_COUNT_DISTINCT)).
* `countDistinctCall` and `approxCountDistinctCall` helpers added to `BasePlannerRulesTests`
  for plan-shape construction.
* `testCountDistinctRewrittenToApproxCountDistinct` in `AggregateRuleTests` continues to
  validate the rewrite end-to-end through the planner — now via the HEP rule instead of
  the SPI hook.
* TwoShardAggregationIT 2-shard reduce checks: failures down from 17 → 5, all 5 remaining
  are HLL-specific Binary↔Int64 type mismatches at the FINAL substrait Read boundary
  (`Substrait error: Field 'dc' has a different type (Binary) than the corresponding
  field in the table schema (Int64)`). SUM/COUNT/AVG/MIN/MAX now all pass.
* Existing CoordinatorReduceIT `testDistinctCountAcrossShards` and
  `testDistinctCountCrossShardOverlap` / `testDistinctCountCrossShardOverlapKeyword`
  failures are HLL-specific and tracked separately — distributed sketch-merge requires
  activating the dormant SETUP_FINAL_AGGREGATE / prepareFinalPlan path.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
mch2 pushed a commit that referenced this pull request Jun 6, 2026
…sketch merge + TopK optimization (#22013)

* feat(analytics-engine): rewrite COUNT(DISTINCT) → APPROX_COUNT_DISTINCT via HEP rule + strip aggregate state suffix in coord StreamingTable schema

Two complementary fixes for the analytics-engine cross-shard aggregate path.

(1) Plan-layer rewrite (Java, OpenSearchDistinctCountRule)

PPL `dc(x)` / `distinct_count(x)` parse to `COUNT(DISTINCT x)` at Calcite via
the SQL plugin's `AstExpressionBuilder.visitDistinctCountFunctionCall`. Without
intervention the call lands as `SqlStdOperatorTable.COUNT` with isDistinct=true,
the additive split rule decomposes it (PARTIAL count(distinct) + FINAL SUM-of-counts),
and cross-shard reduce over-counts any value present on more than one shard.

Replace 6d98's `AggregateFunction.resolveOperator` SPI hook (which polluted the
framework enum and rebuilt AggregateCalls with the original COUNT return type, risking
`Aggregate.typeMatchesInferred` mismatches against APPROX_COUNT_DISTINCT's
inferReturnType) with a dedicated HEP rule:

* New `OpenSearchDistinctCountRule` matches plain `LogicalAggregate` containing a
  single-arg `COUNT(DISTINCT x)` and rewrites it to `APPROX_COUNT_DISTINCT(x)`
  (isDistinct=false on the rewritten call). Built via the long-form `AggregateCall.create`
  with `(groupCount, input, type=null)` so Calcite re-infers the return type from
  `APPROX_COUNT_DISTINCT.inferReturnType(...)` — avoids the typeMatchesInferred mismatch
  the SPI rebuild would produce.
* Wired into the existing `PlannerImpl.decomposeAggregates` HEP phase alongside
  `OpenSearchAggregateReduceRule`, before `OpenSearchAggregateRule` marks the aggregate.
  Multi-arg `COUNT(DISTINCT a, b)` doesn't match — falls through to the residual
  `aggCall.isDistinct()` skip in `OpenSearchAggregateSplitRule`.

After this, the aggregate engages the existing
`AggregateFunction.APPROX_COUNT_DISTINCT(Type.APPROXIMATE, intermediateFields=[sketch:Binary, reducer==self])`
registration — capability resolution, structural split, `DistributedAggregateRewriter.overrideExchangeType`,
and the Substrait extension YAML `approx_distinct` alias all light up automatically.

* Drop `AggregateFunction.COUNT.resolveOperator` override + the default `resolveOperator`
  method on the enum.
* Drop `OpenSearchAggregateRule.resolveAggregateCall` + its unused `SqlAggFunction` import.
* Drop `Type.APPROXIMATE` from `OpenSearchAggregateSplitRule.shouldSkipPartialFinalSplit`
  (residual `aggCall.isDistinct()` skip stays for multi-arg fallback). APPROXIMATE now goes
  through the structural PARTIAL/FINAL split.

(2) Wire-layer schema bridge (Rust, derive_schema_from_partial_plan)

Commit `35ce14790c2` (#21690) folded the consumer's StreamingTable schema derivation into Rust
to eliminate per-cell coercion overhead; `coerceToDeclaredSchema` on the Java
`feedToSender` path was deleted in favour of running the producer's substrait through
DataFusion's substrait consumer + physical planner and using its output schema verbatim.

The DataFusion 53.x physical planner emits `AggregateExec(Mode::Partial)` columns with
state-suffixed names (`dc[hll_registers]`, `$f0[sum]`, `count(opt)[count]`), but the
FINAL substrait emitted by `attachPartialAggOnTop` declares the user-facing aliases
(`dc`, `$f0`, `count(opt)`). DataFusion's substrait consumer name-resolves the FINAL
Read against the registered StreamingTable and fails with
`Schema error: No field named dc. Valid fields are input-0.dc[hll_registers]`.

Add `strip_aggregate_state_suffix` after `coerce_inferred_schema` in
`derive_schema_from_partial_plan`. Splits each field name on the first `[` so the
StreamingTable's declared names match what the FINAL substrait expects. Wire data still
flows positionally via Arrow C Data — names don't affect runtime data movement;
`typesMatch` on the Java path is type-only by position.

This bridges the same physical-output ↔ declared-schema gap that pre-35ce14790c2 Java's
`coerceToDeclaredSchema` covered, but at the schema-declaration layer (per design §14.5.1
plan-authoring layer) rather than per-cell on every batch.

Tests:
* New `AggregatePlanShapeTests.testCountDistinct_1shard` and `testCountDistinct_2shard` pin
  the rewrite + structural split shape (1-shard SINGLE; 2-shard
  Aggregate(FINAL,APPROX_COUNT_DISTINCT) over Reducer over Aggregate(PARTIAL,APPROX_COUNT_DISTINCT)).
* `countDistinctCall` and `approxCountDistinctCall` helpers added to `BasePlannerRulesTests`
  for plan-shape construction.
* `testCountDistinctRewrittenToApproxCountDistinct` in `AggregateRuleTests` continues to
  validate the rewrite end-to-end through the planner — now via the HEP rule instead of
  the SPI hook.
* TwoShardAggregationIT 2-shard reduce checks: failures down from 17 → 5, all 5 remaining
  are HLL-specific Binary↔Int64 type mismatches at the FINAL substrait Read boundary
  (`Substrait error: Field 'dc' has a different type (Binary) than the corresponding
  field in the table schema (Int64)`). SUM/COUNT/AVG/MIN/MAX now all pass.
* Existing CoordinatorReduceIT `testDistinctCountAcrossShards` and
  `testDistinctCountCrossShardOverlap` / `testDistinctCountCrossShardOverlapKeyword`
  failures are HLL-specific and tracked separately — distributed sketch-merge requires
  activating the dormant SETUP_FINAL_AGGREGATE / prepareFinalPlan path.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* feat(analytics-engine): distributed HLL sketch-merge for dc()/APPROX_COUNT_DISTINCT

Coordinator merges per-shard HLL sketches via SETUP_FINAL_AGGREGATE +
force_aggregate_mode(Final), instead of gathering all rows then re-aggregating.
Three pieces:

(1) FragmentConversionDriver
  * Coord-side: emit SETUP_FINAL_AGGREGATE on engine-native-merge FINAL stages
    (containsEngineNativeFinalAggregate walks past Project/Sort wrappers).
  * Skip pure-reorder Projects above engine-native-merge FINAL when attaching
    fragments — DataFusion's substrait consumer can't bind their input field
    names against the FINAL aggregate's measure column. Java reduce sink
    consumes by name so the order shift is invisible.
  * Add isPureReorderProject + hasEngineNativeMergeFinalBelow helpers.

(2) Rust agg_mode
  * force_aggregate_mode(Final) now treats AggregateMode::FinalPartitioned as
    Final — the partitioned variant DataFusion picks for grouped aggregates
    consuming hash-repartitioned input. Without this, by-cat HLL stripped the
    only Final and shipped Binary state.
  * wrap_with_user_facing_names rebuilt structurally: walks to the topmost
    AggregateExec, derives expected names from each AggregateFunctionExpr's
    name() / state_fields() (single-state → final alias; multi-state → keep
    state names), and only wraps when the plan is AggregateExec(Partial).
    Replaces the prior name.contains('[') heuristic.

(3) Wire-in
  * Apply wrap_with_user_facing_names on every execute path that produces
    Partial-aggregate output: prepare_partial_plan, prepare_final_plan,
    LocalSession::execute_substrait, query_executor's two execute paths.
    No-op outside Partial mode.

Targeted IT pass: TwoShardAggregationIT all 36 reduce checks (incl.
distinct_count_by_cat), CoordinatorReduceIT 21/21 (incl. testQ10ShapeAcrossShards,
testGroupByCountMultiShard_*).

Signed-off-by: Sandesh Kumar <kusandes@amazon.com>

* feat(analytics-engine): wire reduce_eval into TopK for dc()/APPROX_COUNT_DISTINCT

TopK shard-side oversampling previously failed for engine-native-merge
aggregates (APPROX_COUNT_DISTINCT/HLL) because partial state is Binary
sketch bytes that cannot be sorted directly by cardinality.

Insert reduce_eval("approx_distinct", sketch) Project between the PARTIAL
aggregate and the shard Sort to derive a sortable UInt64 cardinality from
opaque HLL state. A strip Project above Sort removes the extra column
before the wire ships [group, sketch:Binary] to the coord for final merge.

Three coordinated changes:

(1) OpenSearchTopKRewriter (Java)
  * Detect sort collation referencing engine-native-merge measures
  * Insert OpenSearchProject(reduce_eval) below Sort, OpenSearchProject(strip) above
  * Adjust collation to reference the new reduce_eval column index

(2) FragmentConversionDriver (Java)
  * Layered substrait conversion for buried PARTIAL aggregates: scan →
    attachPartialAggOnTop (INITIAL_TO_INTERMEDIATE) → attachFragmentOnTop
    per operator above, so derive_schema_from_partial_plan sees Binary type
  * containsEngineNativePartialAggregate tree-walk for SETUP_PARTIAL_AGGREGATE
  * strip() propagates stripped children through non-OpenSearch nodes
  * DAGBuilder.findFieldStorage walks past non-OpenSearch nodes

(3) agg_mode.rs (Rust)
  * force_aggregate_mode: when a ProjectionExec's child schema changes
    (names OR types), rebuild the projection with remapped Column references
    via remap_column_names — fixes the name/type mismatch between
    the reduce_eval Project and the state-suffixed Partial aggregate output

All ShardBucketOversamplingIT, TwoShardAggregationIT, CoordinatorReduceIT pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* refactor: harden and simplify dc/TopK implementation

- Remove wrap_with_user_facing_names (wire is positional, names irrelevant)
- Replace strip_aggregate_state_suffix with substrait Root.names
- Deduplicate isEngineNativeMerge + REDUCE_EVAL_OP into AggregateFunction SPI
- Add reduceEvalName() — derive UDF name from enum, not hardcoded string
- Unify convert() paths (aggregate-at-top = degenerate buried case)
- Simplify partial_aggregate_schema to delegate to find_partial_input
- Remove DAGBuilder.findFieldStorage (direct cast, TopK only inserts OpenSearchRelNode)
- Remove Sort marker coupling (chain-length > 0 is sufficient)
- Trim bloated comments and remove debug loggers

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* test: add dc combination coverage (mixed TopK + plain grouped merge)

- testMultiAgg_sortByDc_head10: dc + sum grouped, TopK sorted on dc
- dc_count_by_category: dc + count grouped without TopK (2-shard merge)

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* chore: apply spotless formatting

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* test: add dc/TopK plan shape + combination tests, fix HAVING flakiness

- testRewrite_dcByGroup_splitAndTopK: assert exact plan shape for
  dc + TopK (reduce_eval Project, strip Project, oversampled Sort)
- testRewrite_multiGroupByCount_splitAndTopK: assert PARTIAL/FINAL split
  fires for multi-group-by COUNT (was broken on main — stayed SINGLE)
- testMultiAgg_sortByDc_head10: IT for mixed dc + sum with TopK
- dc_count_by_category: golden-file IT for dc + count grouped (no TopK)
- testCountByGroup_having_sortDesc_head10: lenient assertion to tolerate
  TopK's approximate pruning while still catching double-counting bugs

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
Signed-off-by: Sandesh Kumar <kusandes@amazon.com>
Co-authored-by: Sandesh Kumar <kusandes@amazon.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
…cion across the data-node→coordinator pipeline (opensearch-project#21690)

* [Analytics Engine] Collapse FragmentConvertor convert* methods to convertFragment

Drop the convertShardScanFragment(String, RelNode) and
convertFinalAggFragment(RelNode) overloads from the FragmentConvertor
SPI in favor of a single convertFragment(RelNode). The two overloads
had identical bodies in DataFusionFragmentConvertor (the table-name
parameter was unused after upstream refactors), so the surface
distinction was carrying no semantic weight.

attachPartialAggOnTop and attachFragmentOnTop remain unchanged; only
the leaf conversion entry collapses.

Net SPI surface: -2 methods, -80 LOC across the SPI, the DF
implementation, and the FragmentConversionDriver caller. Tests
realigned to call the unified entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* [Analytics Engine / DataFusion] Fold partial-plan schema derivation into input registration

Eliminate the per-cell + per-batch type coercions on the data-node ->
coordinator wire path by deriving the consumer's StreamingTable schema
from the producer's substrait at registration time on the Rust side.
Two FFI hops collapse into one; the schema crosses the language
boundary once, in the direction it's needed.

Java side
* NativeBridge.registerPartitionStream / registerMemtable now take the
  producer-side substrait plan bytes and return both a sender pointer
  and the IPC-encoded schema the native session settled on after
  lowering, packed in a RegisteredInput record. partialPlanOutputSchema
  wrapper deleted.
* ExchangeSinkProvider.partialAggOutputSchema SPI method deleted along
  with the DataFusion override.
* ExchangeSinkContext.ChildInput drops its Arrow Schema field; carries
  the producer plan bytes instead. inputSchema() convenience deleted.
* DatafusionReduceSink: per-cell coerceToDeclaredSchema loop replaced
  with a typesMatch tripwire fed from the native-returned schema.
  Per-cell allocation gone; type validation is type-only and ignores
  nullability + Timestamp precision.
* DataFusionReduceState gains an inputSchemas list parallel to senders,
  threading post-registration schemas from FinalAggregateInstructionHandler
  to DatafusionReduceSink.
* AbstractDatafusionReduceSink populates childSchemas lazily from each
  registration return rather than eagerly from ctx.childInputs().
* LocalStageScheduler stops asking the backend for per-child schema;
  hands plan bytes through. ArrowSchemaFromCalcite (the row-type-based
  fallback) deleted.

Rust side
* api.rs: partial_plan_output_schema standalone FFI deleted; folded
  into register_partition_stream / register_memtable as
  derive_schema_from_partial_plan. Schema is encoded once via
  schema_to_ipc_bytes and returned through a caller-allocated out
  buffer.
* ffm.rs: df_partial_plan_output_schema C-ABI deleted; new
  write_out_buffer helper deduplicates the "copy bytes into caller
  buffer + write byte count" pattern across df_sql_to_substrait and
  the two register_* exports.
* derive_schema_from_partial_plan registers a synthetic MemTable from
  the substrait base_schema, then runs from_substrait_plan +
  create_physical_plan to get the lowered output schema. The synthetic
  leaf must match what the data-node parquet read leaf would produce,
  so two parquet-read transformations are mirrored on the synthetic
  base_schema before MemTable construction:
    - Utf8 -> Utf8View (gated on schema_force_view_types)
    - Timestamp(Second) -> Timestamp(Millisecond) (parquet has no
      logical TIMESTAMP_SECOND, so the data node always promotes)
  Both are zero-copy at runtime -- they only configure the
  StreamingTable's declared schema so producer batches slot in via FFI
  without reinterpretation. Long-term plan: have the data node embed
  its lowered output schema as substrait extension metadata so the
  coordinator skips the throwaway lowering and both mirrors evaporate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* [Analytics Backend / DataFusion] Accept any string variant in scalar UDFs

DataFusion's parquet reader emits Utf8View for string columns when
schema_force_view_types is true (default). Previously every
string-accepting UDF in this plugin canonicalized inputs to Utf8 via
either coerce_types (json family + reusers of CoerceMode::Utf8) or
explicit Signature::Exact lists (sha1, strftime, tostring), which
caused DataFusion to insert a per-batch vectorized cast at every UDF
call site in the partial plan -- O(rows x bytes_per_value) memcpy and
an Arrow buffer allocation each time.

Drop the canonicalization. UDFs now accept Utf8 / LargeUtf8 / Utf8View
natively and dispatch over them via a shared StringArrayView enum
(introduced in udf::json_common). The columnar dispatch happens once
at array-acquisition time; per-row access is a small enum match. No
per-batch cast, no buffer copy.

Touched UDFs:
* coerce_slot in udf::mod, mode CoerceMode::Utf8: now passes the
  observed variant through unchanged.
* json family (7 UDFs): json_append, json_array_length, json_delete,
  json_extend, json_extract, json_keys, json_set -- switch from
  as_utf8_array to StringArrayView.
* Other coerce_args(.., CoerceMode::Utf8) users (6 UDFs): convert_tz,
  rex_extract, rex_extract_multi, rex_offset, mvfind, tonumber --
  bodies switch to StringArrayView. mvfind also fixes its inner list-
  element scan, which previously silently no-matched on Utf8View list
  children.
* UDFs with explicit Signature::Exact lists (3): sha1, strftime,
  tostring -- Signatures gain Utf8View entries; bodies use
  StringArrayView. strftime's coerce_types passes the format variant
  through instead of forcing Utf8.

mvappend was already correct (already dispatched over all three
string variants) and is untouched.

Net diff: ~+150 / -490 LOC across the udf/ tree. Test duplicates
removed where the per-variant accept/reject contract is now covered
centrally by udf::tests::utf8_*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
…o-end (opensearch-project#21731)

* analytics-framework: extend AggregateFunction SPI for state-expanding aggregates

Adds five enum entries for PPL TAKE / FIRST / LAST / LIST / VALUES and an
input-parameterised IntermediateField type resolver. Existing fixed-shape
aggregates (HLL Binary sketch, COUNT Int64 counter) keep their constant
intermediate types via IntermediateTypeResolver.fixed(); state-expanding
aggregates whose FINAL state shape derives from arg 0 use
IntermediateTypeResolver.passThroughArg0() so the planner can resolve the
exchange column type from the actual call's arg type rather than a constant.

Internalises the Arrow → Calcite type mapping (previously in the planner
module's ArrowCalciteTypes) so the SPI is self-contained.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: wire 5 PPL state-expanding aggregates end-to-end

Wires PPL TAKE / FIRST / LAST / LIST / VALUES through the DataFusion backend:

* Rust UDAF for take(field, n) at rust/src/udaf/take.rs. Limit is read from a
  Substrait Literal when present, or from row 0 of arg 1 when Calcite has
  materialised n as a constant Project column. Default n = 10. State is a
  bounded List<elem> emitted from state(); coordinator-side merge_batch
  concatenates and re-truncates. UDAF registered on every SessionContext
  via crate::udaf::register_all.

* Substrait extension entries in opensearch_aggregate_functions.yaml for
  take, first_value, last_value, and array_agg. take's 2-arg shape uses
  distinct any1/any2 generics so isthmus's wildcard binding accepts
  mismatched arg types (state list<elem> + integer n).

* DataFusionFragmentConvertor: LOCAL_TAKE_OP / LOCAL_FIRST_OP / LOCAL_LAST_OP /
  LOCAL_ARRAY_AGG_OP stub SqlAggFunctions plus a pre-emit RelShuttle that
  rewrites PPL aggregation calls (TAKE / FIRST / LAST / LIST / VALUES) onto
  the stubs so isthmus's AggregateFunctionConverter resolves them through
  ADDITIONAL_AGGREGATE_SIGS to the YAML extension names. LIST/VALUES rebuild
  the call's return type as ARRAY<actual-arg0> to override PPL's lossy
  STRING_ARRAY which would otherwise force ARRAY<VARCHAR>.

* SubstraitPlanRewriter.visit(Aggregate): post-emit pass that inlines
  Project-column literals into Aggregate.Measure args. Required because
  Calcite's RelBuilder.aggregate auto-projects literal aggregate-args as
  $f1 columns, leaving the substrait emit with FieldRefs instead of
  Literals.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: distributed planner rewrites for PPL state-expanding aggregates

Three planner-level changes that compose to make TAKE / FIRST / LAST / LIST /
VALUES correct across shards:

1. Move AggregateCallAnnotation out of AggregateCall.rexList onto an
   OpenSearchAggregate side-map keyed by call index. Calcite's
   AggCallBinding.preOperands treats every rexList entry as a leading
   operand for inferReturnType, which corrupts return-type inference for
   PPL operators that read getOperandType(0) (ARG0_ARRAY on TAKE / LIST /
   VALUES would double-wrap). Storing annotations out-of-band on
   OpenSearchAggregate sidesteps the issue without touching PPL. Drops
   the AGG_CALL_ANNOTATION marker class and the annotation-stripping
   logic in stripAnnotations / copyResolved.

2. DistributedAggregateRewriter: drive intermediate-field types through
   the SPI's IntermediateTypeResolver (replacing the static ArrowCalciteTypes
   helper, which is removed). For STATE_EXPANDING + APPROXIMATE engine-native-merge
   aggregates, pin the FINAL aggCall's explicit return type to the StageInputScan
   column type so substrait declares what Rust's derive_schema_from_partial_plan
   produces — PPL's stock ReturnTypes are wrong here (ARG0_ARRAY double-wraps,
   STRING_ARRAY ignores the actual element type).

3. Cross-shard literal-arg forwarding for TAKE's N. OpenSearchAggregateSplitRule
   captures any RexLiteral aggregate-args from the original SINGLE aggregate's
   underlying Project and stashes them on the FINAL OpenSearchAggregate as a
   side-map. DistributedAggregateRewriter (Phase 2b) wraps the FINAL's
   StageInputScan in an OpenSearchProject that re-creates each captured literal
   as a constant column, and rebuilds the FINAL aggCall's argList to
   [stateColIdx, ...litColIdxs]. The convertor's existing SubstraitPlanRewriter
   inliner then emits the literals as Substrait Literal expressions. The
   producer-side PARTIAL stage still consumes N inside its own accumulator;
   without this, FINAL would re-aggregate without N and fall back to the
   default limit.

IT cluster runs with -da:org.apache.calcite... so Calcite's typeMatchesInferred
assertion is silenced — post opensearch-project#21690 the wire schema is derived in Rust, not
Java, and PPL operators with non-idempotent return-type inference would
otherwise trip this assertion on the FINAL side. Production runs without -ea.

CoordinatorReduceIT: ten new tests (single-shard + cross-shard for each of
the five aggregates). LIST/VALUES across-shards remain @AwaitsFix until the
PPL frontend stops declaring STRING_ARRAY for them.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-engine: cross-shard LIST and VALUES via list_merge UDAF

Two stacked issues blocked cross-shard LIST/VALUES:

1. PPL declares LIST/VALUES return type via PPLReturnTypes.STRING_ARRAY which
   forces ARRAY<VARCHAR> regardless of input element type. Fix: repair the
   PARTIAL aggCall return type to ARRAY<actual-arg0> in OpenSearchAggregateSplitRule
   (PARTIAL only — FINAL keeps the original list to satisfy Volcano's parent
   row-type check on transformTo). The corrected type propagates through
   PARTIAL output → StageInputScan → FINAL substrait base_schema.

2. DataFusion's substrait consumer ignores AggregationPhase, so a FINAL
   array_agg(state) gets lowered as a single-pass aggregate that re-wraps each
   shard's list rather than concatenating. Fix: custom Rust UDAFs `list_merge`
   and `list_merge_distinct` whose `update_batch` un-nests each List<elem> row
   into elements (with optional dedup). Same pattern as TAKE's accumulator.
   The convertor's pre-emit RelShuttle detects the FINAL form (arg0 already
   a list type) and routes LIST → list_merge, VALUES → list_merge_distinct.

PARTIAL still uses DataFusion's native array_agg (with INVOCATION_DISTINCT for
VALUES). Cross-shard testListAcrossShards / testValuesAcrossShards pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* analytics-backend-datafusion: relocate state-expanding aggregate rewrites

Addresses two review comments on the state-expanding aggregate (TAKE / FIRST
/ LAST / LIST / VALUES) wiring.

1. Move literal-arg inlining off the post-emit Substrait IR pass and onto
   the isthmus binding layer. Calcite's RelBuilder.aggregate auto-projects
   literal aggregate-args (e.g. TAKE's N) as separate columns and
   AggregateCall.argList only carries column indices, so without inlining
   our Rust UDAFs only see a column reference at construction time. With
   DataFusion's two-stage execution, the Final accumulator dispatches
   through merge_batch — which receives only state columns, not the
   constant — so the limit can never be resolved on the coordinator.

   The fix overrides AggregateFunctionConverter.convert() in createVisitor()
   (next to the existing getSigs() override): after isthmus binds the
   AggregateFunctionInvocation, replace any FieldReference arg pointing to
   a literal Project column with the converted RexLiteral via the supplied
   Function<RexNode, Expression> — same Substrait literal an inlined arg
   would produce, routed through isthmus's standard literal converter.
   SubstraitPlanRewriter.visit(Aggregate) and its simpleStructOffset helper
   are deleted; the post-emit pass now only handles Filter rewrites and
   the PrecisionTimestampLiteral precision fix.

2. Extract the PPL aggregate-name → LOCAL_*_OP rewrite from
   DataFusionFragmentConvertor into a new package-private
   PplAggregateCallRewriter, matching the UntypedNullPreprocessor /
   DatetimeOutputCastRewriter sibling pattern. The if/else-if chain on
   operator name becomes a switch on toUpperCase(Locale.ROOT) and the
   six-way `==` chain that gates already-rewritten calls becomes a
   Set<SqlAggFunction> contains check. The two call sites in
   DataFusionFragmentConvertor now read like the other preprocessor
   invocations.

CoordinatorReduceIT (18 tests), integTestMemtable, and integTestStreaming
all pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
KhishorekumarBS pushed a commit to KhishorekumarBS/OpenSearch that referenced this pull request Jul 3, 2026
…sketch merge + TopK optimization (opensearch-project#22013)

* feat(analytics-engine): rewrite COUNT(DISTINCT) → APPROX_COUNT_DISTINCT via HEP rule + strip aggregate state suffix in coord StreamingTable schema

Two complementary fixes for the analytics-engine cross-shard aggregate path.

(1) Plan-layer rewrite (Java, OpenSearchDistinctCountRule)

PPL `dc(x)` / `distinct_count(x)` parse to `COUNT(DISTINCT x)` at Calcite via
the SQL plugin's `AstExpressionBuilder.visitDistinctCountFunctionCall`. Without
intervention the call lands as `SqlStdOperatorTable.COUNT` with isDistinct=true,
the additive split rule decomposes it (PARTIAL count(distinct) + FINAL SUM-of-counts),
and cross-shard reduce over-counts any value present on more than one shard.

Replace 6d98's `AggregateFunction.resolveOperator` SPI hook (which polluted the
framework enum and rebuilt AggregateCalls with the original COUNT return type, risking
`Aggregate.typeMatchesInferred` mismatches against APPROX_COUNT_DISTINCT's
inferReturnType) with a dedicated HEP rule:

* New `OpenSearchDistinctCountRule` matches plain `LogicalAggregate` containing a
  single-arg `COUNT(DISTINCT x)` and rewrites it to `APPROX_COUNT_DISTINCT(x)`
  (isDistinct=false on the rewritten call). Built via the long-form `AggregateCall.create`
  with `(groupCount, input, type=null)` so Calcite re-infers the return type from
  `APPROX_COUNT_DISTINCT.inferReturnType(...)` — avoids the typeMatchesInferred mismatch
  the SPI rebuild would produce.
* Wired into the existing `PlannerImpl.decomposeAggregates` HEP phase alongside
  `OpenSearchAggregateReduceRule`, before `OpenSearchAggregateRule` marks the aggregate.
  Multi-arg `COUNT(DISTINCT a, b)` doesn't match — falls through to the residual
  `aggCall.isDistinct()` skip in `OpenSearchAggregateSplitRule`.

After this, the aggregate engages the existing
`AggregateFunction.APPROX_COUNT_DISTINCT(Type.APPROXIMATE, intermediateFields=[sketch:Binary, reducer==self])`
registration — capability resolution, structural split, `DistributedAggregateRewriter.overrideExchangeType`,
and the Substrait extension YAML `approx_distinct` alias all light up automatically.

* Drop `AggregateFunction.COUNT.resolveOperator` override + the default `resolveOperator`
  method on the enum.
* Drop `OpenSearchAggregateRule.resolveAggregateCall` + its unused `SqlAggFunction` import.
* Drop `Type.APPROXIMATE` from `OpenSearchAggregateSplitRule.shouldSkipPartialFinalSplit`
  (residual `aggCall.isDistinct()` skip stays for multi-arg fallback). APPROXIMATE now goes
  through the structural PARTIAL/FINAL split.

(2) Wire-layer schema bridge (Rust, derive_schema_from_partial_plan)

Commit `35ce14790c2` (opensearch-project#21690) folded the consumer's StreamingTable schema derivation into Rust
to eliminate per-cell coercion overhead; `coerceToDeclaredSchema` on the Java
`feedToSender` path was deleted in favour of running the producer's substrait through
DataFusion's substrait consumer + physical planner and using its output schema verbatim.

The DataFusion 53.x physical planner emits `AggregateExec(Mode::Partial)` columns with
state-suffixed names (`dc[hll_registers]`, `$f0[sum]`, `count(opt)[count]`), but the
FINAL substrait emitted by `attachPartialAggOnTop` declares the user-facing aliases
(`dc`, `$f0`, `count(opt)`). DataFusion's substrait consumer name-resolves the FINAL
Read against the registered StreamingTable and fails with
`Schema error: No field named dc. Valid fields are input-0.dc[hll_registers]`.

Add `strip_aggregate_state_suffix` after `coerce_inferred_schema` in
`derive_schema_from_partial_plan`. Splits each field name on the first `[` so the
StreamingTable's declared names match what the FINAL substrait expects. Wire data still
flows positionally via Arrow C Data — names don't affect runtime data movement;
`typesMatch` on the Java path is type-only by position.

This bridges the same physical-output ↔ declared-schema gap that pre-35ce14790c2 Java's
`coerceToDeclaredSchema` covered, but at the schema-declaration layer (per design §14.5.1
plan-authoring layer) rather than per-cell on every batch.

Tests:
* New `AggregatePlanShapeTests.testCountDistinct_1shard` and `testCountDistinct_2shard` pin
  the rewrite + structural split shape (1-shard SINGLE; 2-shard
  Aggregate(FINAL,APPROX_COUNT_DISTINCT) over Reducer over Aggregate(PARTIAL,APPROX_COUNT_DISTINCT)).
* `countDistinctCall` and `approxCountDistinctCall` helpers added to `BasePlannerRulesTests`
  for plan-shape construction.
* `testCountDistinctRewrittenToApproxCountDistinct` in `AggregateRuleTests` continues to
  validate the rewrite end-to-end through the planner — now via the HEP rule instead of
  the SPI hook.
* TwoShardAggregationIT 2-shard reduce checks: failures down from 17 → 5, all 5 remaining
  are HLL-specific Binary↔Int64 type mismatches at the FINAL substrait Read boundary
  (`Substrait error: Field 'dc' has a different type (Binary) than the corresponding
  field in the table schema (Int64)`). SUM/COUNT/AVG/MIN/MAX now all pass.
* Existing CoordinatorReduceIT `testDistinctCountAcrossShards` and
  `testDistinctCountCrossShardOverlap` / `testDistinctCountCrossShardOverlapKeyword`
  failures are HLL-specific and tracked separately — distributed sketch-merge requires
  activating the dormant SETUP_FINAL_AGGREGATE / prepareFinalPlan path.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* feat(analytics-engine): distributed HLL sketch-merge for dc()/APPROX_COUNT_DISTINCT

Coordinator merges per-shard HLL sketches via SETUP_FINAL_AGGREGATE +
force_aggregate_mode(Final), instead of gathering all rows then re-aggregating.
Three pieces:

(1) FragmentConversionDriver
  * Coord-side: emit SETUP_FINAL_AGGREGATE on engine-native-merge FINAL stages
    (containsEngineNativeFinalAggregate walks past Project/Sort wrappers).
  * Skip pure-reorder Projects above engine-native-merge FINAL when attaching
    fragments — DataFusion's substrait consumer can't bind their input field
    names against the FINAL aggregate's measure column. Java reduce sink
    consumes by name so the order shift is invisible.
  * Add isPureReorderProject + hasEngineNativeMergeFinalBelow helpers.

(2) Rust agg_mode
  * force_aggregate_mode(Final) now treats AggregateMode::FinalPartitioned as
    Final — the partitioned variant DataFusion picks for grouped aggregates
    consuming hash-repartitioned input. Without this, by-cat HLL stripped the
    only Final and shipped Binary state.
  * wrap_with_user_facing_names rebuilt structurally: walks to the topmost
    AggregateExec, derives expected names from each AggregateFunctionExpr's
    name() / state_fields() (single-state → final alias; multi-state → keep
    state names), and only wraps when the plan is AggregateExec(Partial).
    Replaces the prior name.contains('[') heuristic.

(3) Wire-in
  * Apply wrap_with_user_facing_names on every execute path that produces
    Partial-aggregate output: prepare_partial_plan, prepare_final_plan,
    LocalSession::execute_substrait, query_executor's two execute paths.
    No-op outside Partial mode.

Targeted IT pass: TwoShardAggregationIT all 36 reduce checks (incl.
distinct_count_by_cat), CoordinatorReduceIT 21/21 (incl. testQ10ShapeAcrossShards,
testGroupByCountMultiShard_*).

Signed-off-by: Sandesh Kumar <kusandes@amazon.com>

* feat(analytics-engine): wire reduce_eval into TopK for dc()/APPROX_COUNT_DISTINCT

TopK shard-side oversampling previously failed for engine-native-merge
aggregates (APPROX_COUNT_DISTINCT/HLL) because partial state is Binary
sketch bytes that cannot be sorted directly by cardinality.

Insert reduce_eval("approx_distinct", sketch) Project between the PARTIAL
aggregate and the shard Sort to derive a sortable UInt64 cardinality from
opaque HLL state. A strip Project above Sort removes the extra column
before the wire ships [group, sketch:Binary] to the coord for final merge.

Three coordinated changes:

(1) OpenSearchTopKRewriter (Java)
  * Detect sort collation referencing engine-native-merge measures
  * Insert OpenSearchProject(reduce_eval) below Sort, OpenSearchProject(strip) above
  * Adjust collation to reference the new reduce_eval column index

(2) FragmentConversionDriver (Java)
  * Layered substrait conversion for buried PARTIAL aggregates: scan →
    attachPartialAggOnTop (INITIAL_TO_INTERMEDIATE) → attachFragmentOnTop
    per operator above, so derive_schema_from_partial_plan sees Binary type
  * containsEngineNativePartialAggregate tree-walk for SETUP_PARTIAL_AGGREGATE
  * strip() propagates stripped children through non-OpenSearch nodes
  * DAGBuilder.findFieldStorage walks past non-OpenSearch nodes

(3) agg_mode.rs (Rust)
  * force_aggregate_mode: when a ProjectionExec's child schema changes
    (names OR types), rebuild the projection with remapped Column references
    via remap_column_names — fixes the name/type mismatch between
    the reduce_eval Project and the state-suffixed Partial aggregate output

All ShardBucketOversamplingIT, TwoShardAggregationIT, CoordinatorReduceIT pass.

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* refactor: harden and simplify dc/TopK implementation

- Remove wrap_with_user_facing_names (wire is positional, names irrelevant)
- Replace strip_aggregate_state_suffix with substrait Root.names
- Deduplicate isEngineNativeMerge + REDUCE_EVAL_OP into AggregateFunction SPI
- Add reduceEvalName() — derive UDF name from enum, not hardcoded string
- Unify convert() paths (aggregate-at-top = degenerate buried case)
- Simplify partial_aggregate_schema to delegate to find_partial_input
- Remove DAGBuilder.findFieldStorage (direct cast, TopK only inserts OpenSearchRelNode)
- Remove Sort marker coupling (chain-length > 0 is sufficient)
- Trim bloated comments and remove debug loggers

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* test: add dc combination coverage (mixed TopK + plain grouped merge)

- testMultiAgg_sortByDc_head10: dc + sum grouped, TopK sorted on dc
- dc_count_by_category: dc + count grouped without TopK (2-shard merge)

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* chore: apply spotless formatting

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

* test: add dc/TopK plan shape + combination tests, fix HAVING flakiness

- testRewrite_dcByGroup_splitAndTopK: assert exact plan shape for
  dc + TopK (reduce_eval Project, strip Project, oversampled Sort)
- testRewrite_multiGroupByCount_splitAndTopK: assert PARTIAL/FINAL split
  fires for multi-group-by COUNT (was broken on main — stayed SINGLE)
- testMultiAgg_sortByDc_head10: IT for mixed dc + sum with TopK
- dc_count_by_category: golden-file IT for dc + count grouped (no TopK)
- testCountByGroup_having_sortDesc_head10: lenient assertion to tolerate
  TopK's approximate pruning while still catching double-counting bugs

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>

---------

Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
Signed-off-by: Sandesh Kumar <kusandes@amazon.com>
Co-authored-by: Sandesh Kumar <kusandes@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.

2 participants