[Analytics Engine] Eliminate string/utf8 views & timestamp field coercion across the data-node→coordinator pipeline - #21690
Conversation
PR Reviewer Guide 🔍(Review updated until commit 5b48bff)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 9a0c6b0 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 5b48bff
Suggestions up to commit da94c42
Suggestions up to commit 9510f74
Suggestions up to commit 9510f74
|
|
Persistent review updated to latest commit 9510f74 |
|
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>
|
Persistent review updated to latest commit 5b48bff |
…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>
…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>
…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>
…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>
…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>
…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>
…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>
…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>
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>
…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>
…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>
…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>
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>
…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>
…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>
…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>
…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>
…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>
…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>
Co-Authored-By: Claude Opus 4.7 (1M context)
Description
Producer (data node) emits
Utf8Viewstrings andTimestamp(Millisecond)timestamps. The coordinator's StreamingTable was registered withUtf8andTimestamp(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
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.