jonathanc-n commented on code in PR #23738:
URL: https://github.com/apache/datafusion/pull/23738#discussion_r3660402849


##########
datafusion/sqllogictest/test_files/asof_join.slt:
##########
@@ -0,0 +1,201 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+statement ok
+CREATE TABLE asof_left(id INT, grp TEXT, ts INT) AS VALUES
+  (1, 'A', 1),
+  (2, 'A', 4),
+  (3, 'A', 7),
+  (4, 'B', 2),
+  (5, 'B', 8),
+  (6, NULL, 3),
+  (7, 'A', NULL);
+
+statement ok
+CREATE TABLE asof_right(grp TEXT, ts INT, val TEXT) AS VALUES

Review Comment:
   nit: should we make the tests predominantly test with time series data as 
that is what AsOF join mainly uses. 



##########
datafusion/sqllogictest/test_files/asof_join.slt:
##########
@@ -0,0 +1,201 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+statement ok
+CREATE TABLE asof_left(id INT, grp TEXT, ts INT) AS VALUES
+  (1, 'A', 1),
+  (2, 'A', 4),
+  (3, 'A', 7),
+  (4, 'B', 2),
+  (5, 'B', 8),
+  (6, NULL, 3),
+  (7, 'A', NULL);
+
+statement ok
+CREATE TABLE asof_right(grp TEXT, ts INT, val TEXT) AS VALUES
+  ('A', 2, 'a2'),
+  ('A', 4, 'a4'),
+  ('A', 6, 'a6'),
+  ('B', 1, 'b1'),
+  ('B', 6, 'b6'),
+  (NULL, 2, 'null-group'),
+  ('A', NULL, 'null-ts');
+
+# Inclusive predecessor per equality group. This also verifies unmatched left
+# rows and NULL behavior for equality keys and ordered expressions.
+query IIIT
+SELECT l.id, l.ts, r.ts, r.val
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts >= r.ts)
+ON l.grp = r.grp
+ORDER BY l.id;
+----
+1 1 NULL NULL
+2 4 4 a4
+3 7 6 a6
+4 2 1 b1
+5 8 6 b6
+6 3 NULL NULL
+7 NULL NULL NULL
+
+# Strict predecessor per equality group.
+query IIIT
+SELECT l.id, l.ts, r.ts, r.val
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts > r.ts)
+ON l.grp = r.grp
+ORDER BY l.id;
+----
+1 1 NULL NULL
+2 4 2 a2
+3 7 6 a6
+4 2 1 b1
+5 8 6 b6
+6 3 NULL NULL
+7 NULL NULL NULL
+
+# Inclusive successor per equality group.
+query IIIT
+SELECT l.id, l.ts, r.ts, r.val
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts <= r.ts)
+ON l.grp = r.grp
+ORDER BY l.id;
+----
+1 1 2 a2
+2 4 4 a4
+3 7 NULL NULL
+4 2 6 b6
+5 8 NULL NULL
+6 3 NULL NULL
+7 NULL NULL NULL
+
+# Strict successor per equality group.
+query IIIT
+SELECT l.id, l.ts, r.ts, r.val
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts < r.ts)
+ON l.grp = r.grp
+ORDER BY l.id;
+----
+1 1 2 a2
+2 4 6 a6
+3 7 NULL NULL
+4 2 6 b6
+5 8 NULL NULL
+6 3 NULL NULL
+7 NULL NULL NULL
+
+# USING exposes one unqualified equality key.
+query TIIT
+SELECT grp, l.id, r.ts, r.val
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts >= r.ts)
+USING (grp)
+ORDER BY l.id;
+----
+A 1 NULL NULL
+A 2 4 a4
+A 3 6 a6
+B 4 1 b1
+B 5 6 b6
+NULL 6 NULL NULL
+A 7 NULL NULL
+
+# Both qualified equality keys remain addressable.
+query ITT
+SELECT l.id, l.grp, r.grp
+FROM asof_left l
+ASOF JOIN asof_right r
+MATCH_CONDITION (l.ts >= r.ts)
+USING (grp)
+ORDER BY l.id;
+----
+1 A NULL
+2 A A
+3 A A
+4 B B
+5 B B
+6 NULL NULL
+7 A NULL
+
+# Equality keys are optional.
+query IT
+SELECT l.id, r.label
+FROM (VALUES (1, 1), (2, 5), (3, CAST(NULL AS INT))) AS l(id, ts)
+ASOF JOIN (VALUES (2, 'r2'), (4, 'r4')) AS r(ts, label)
+MATCH_CONDITION (l.ts >= r.ts)
+ORDER BY l.id;
+----
+1 NULL
+2 r4
+3 NULL
+
+query TT

Review Comment:
   We could try to add a test for multi partitioned right side to test the 
CoalescePartitionsExec



##########
datafusion/physical-plan/src/joins/asof_join.rs:
##########
@@ -0,0 +1,1855 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Broadcast, left-preserving ASOF join execution.
+//!
+//! An ASOF join emits exactly one output row for every left row. Within an
+//! optional equality-key group, it selects the closest right row that 
satisfies
+//! one ordered comparison:
+//!
+//! ```text
+//! left.ts >= right.ts  => greatest eligible right.ts
+//! left.ts <= right.ts  => smallest eligible right.ts
+//! ```
+//!
+//! The right input is collected and shared by all output partitions. The left
+//! input remains partitioned, and each partition performs an independent
+//! monotonic scan over the ordered right input:
+//!
+//! ```text
+//! AsOfJoinExec
+//!   SortExec(left equality keys, left match key)
+//!     RepartitionExec(RoundRobinBatch)
+//!       left
+//!   SortExec(right equality keys, right match key)
+//!     CoalescePartitionsExec
+//!       right
+//! ```
+//!
+//! Both inputs must be ordered by their equality keys followed by the match
+//! key. For `<` and `<=`, the match ordering is reversed so all directions use
+//! the same forward-only state machine. Each left partition owns its cursors,
+//! equality-group state, and current candidate, while the collected right
+//! batches are immutable and shared.
+//!
+//! This mode preserves probe-side parallelism when there are no equality keys
+//! or when equality keys have low cardinality or skew. It retains the complete
+//! right input in the memory pool and may scan it once per left partition, so 
a
+//! repartitioned streaming mode remains a useful future alternative for large
+//! right inputs.
+
+use std::cmp::Ordering;
+use std::collections::{HashMap, HashSet};
+use std::fmt::Formatter;
+use std::sync::Arc;
+
+use arrow::array::{Array, ArrayRef, RecordBatch, new_null_array};
+use arrow::compute::{SortOptions, interleave};
+use arrow::datatypes::{Schema, SchemaRef};
+use datafusion_common::config::ConfigOptions;
+use datafusion_common::stats::Precision;
+use datafusion_common::utils::memory::RecordBatchMemoryCounter;
+use datafusion_common::utils::{
+    compare_rows, get_row_at_idx, normalize_float_zero_scalar,
+};
+use datafusion_common::{
+    ColumnStatistics, JoinType, Result, ScalarValue, Statistics,
+    assert_eq_or_internal_err, internal_err, plan_err,
+};
+use datafusion_execution::TaskContext;
+use datafusion_execution::memory_pool::{MemoryConsumer, MemoryReservation};
+use datafusion_expr::Operator;
+use datafusion_physical_expr::PhysicalSortExpr;
+use datafusion_physical_expr::expressions::Column as PhysicalColumn;
+use datafusion_physical_expr::projection::ProjectionMapping;
+use datafusion_physical_expr::utils::collect_columns;
+use datafusion_physical_expr_common::physical_expr::{
+    PhysicalExprRef, fmt_sql, is_volatile,
+};
+use datafusion_physical_expr_common::sort_expr::{LexOrdering, 
OrderingRequirements};
+use futures::{StreamExt, TryStreamExt, future::poll_fn, stream};
+
+use crate::execution_plan::{Boundedness, EmissionType};
+use crate::filter_pushdown::{
+    ChildFilterDescription, ChildPushdownResult, FilterDescription, 
FilterPushdownPhase,
+    FilterPushdownPropagation,
+};
+use crate::joins::utils::{JoinOn, OnceAsync, build_join_schema};
+use crate::memory::MemoryStream;
+use crate::metrics::{
+    BaselineMetrics, Count, ExecutionPlanMetricsSet, Gauge, MetricBuilder,
+    MetricCategory, MetricsSet, RecordOutput, Time,
+};
+use crate::statistics::{ChildStats, StatisticsArgs};
+use crate::stream::RecordBatchStreamAdapter;
+use crate::{
+    DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, 
ExecutionPlanProperties,
+    InputDistributionRequirements, PlanProperties, SendableRecordBatchStream,
+    check_if_same_properties,
+};
+
+/// Physical ordered comparison for an ASOF join.
+#[derive(Debug, Clone)]
+pub struct AsOfMatchExpr {
+    /// Expression evaluated against the left input.
+    pub left: PhysicalExprRef,
+    /// Ordered comparison operator.
+    pub op: Operator,
+    /// Expression evaluated against the right input.
+    pub right: PhysicalExprRef,
+}
+
+impl AsOfMatchExpr {
+    /// Creates a physical ASOF match expression.
+    pub fn new(left: PhysicalExprRef, op: Operator, right: PhysicalExprRef) -> 
Self {
+        Self { left, op, right }
+    }
+}
+
+/// A broadcast sort-merge ASOF join that emits one row for every left row.
+#[derive(Debug)]
+pub struct AsOfJoinExec {
+    left: Arc<dyn ExecutionPlan>,
+    right: Arc<dyn ExecutionPlan>,
+    on: JoinOn,
+    match_condition: AsOfMatchExpr,
+    right_output_indices: Vec<usize>,
+    schema: SchemaRef,
+    metrics: ExecutionPlanMetricsSet,
+    left_ordering: LexOrdering,
+    right_ordering: LexOrdering,
+    right_fut: OnceAsync<BroadcastRightInput>,
+    cache: Arc<PlanProperties>,
+}
+
+impl AsOfJoinExec {
+    /// Creates a bounded ASOF join over sorted inputs.
+    pub fn try_new(
+        left: Arc<dyn ExecutionPlan>,
+        right: Arc<dyn ExecutionPlan>,
+        on: JoinOn,
+        match_condition: AsOfMatchExpr,
+        right_output_indices: Vec<usize>,
+    ) -> Result<Self> {
+        if !matches!(
+            match_condition.op,
+            Operator::Lt | Operator::LtEq | Operator::Gt | Operator::GtEq
+        ) {
+            return plan_err!(
+                "AsOfJoinExec requires <, <=, >, or >=, found {}",
+                match_condition.op
+            );
+        }
+        if left.boundedness().is_unbounded() || 
right.boundedness().is_unbounded() {
+            return plan_err!("AsOfJoinExec requires bounded inputs");
+        }
+        if is_volatile(&match_condition.left) || 
is_volatile(&match_condition.right) {
+            return plan_err!("AsOfJoinExec match expression must be 
deterministic");
+        }
+        if on
+            .iter()
+            .any(|(left, right)| is_volatile(left) || is_volatile(right))
+        {
+            return plan_err!("AsOfJoinExec equality expressions must be 
deterministic");
+        }
+
+        let left_schema = left.schema();
+        let right_schema = right.schema();
+        validate_expr_side(&match_condition.left, &left_schema, "left match")?;
+        validate_expr_side(&match_condition.right, &right_schema, "right 
match")?;
+        for (left_expr, right_expr) in &on {
+            validate_expr_side(left_expr, &left_schema, "left equality")?;
+            validate_expr_side(right_expr, &right_schema, "right equality")?;
+            let left_type = left_expr.data_type(&left_schema)?;
+            let right_type = right_expr.data_type(&right_schema)?;
+            if left_type != right_type {
+                return plan_err!(
+                    "AsOfJoinExec equality expression types differ: 
{left_type} and {right_type}"
+                );
+            }
+            if !datafusion_expr::utils::can_hash(&left_type) {
+                return plan_err!(
+                    "AsOfJoinExec equality expressions have unsupported hash 
type {left_type}"
+                );
+            }
+        }
+        let left_match_type = match_condition.left.data_type(&left_schema)?;
+        let right_match_type = match_condition.right.data_type(&right_schema)?;
+        if left_match_type != right_match_type {
+            return plan_err!(
+                "AsOfJoinExec match expression types differ: {left_match_type} 
and {right_match_type}"
+            );
+        }
+        if let Some(index) = right_output_indices
+            .iter()
+            .find(|index| **index >= right_schema.fields().len())
+        {
+            return plan_err!(
+                "AsOfJoinExec right output index {index} is outside schema 
with {} fields",
+                right_schema.fields().len()
+            );
+        }
+        if !right_output_indices
+            .windows(2)
+            .all(|pair| pair[0] < pair[1])
+        {
+            return plan_err!(
+                "AsOfJoinExec right output indices must be strictly increasing"
+            );
+        }
+
+        let schema =
+            build_output_schema(&left_schema, &right_schema, 
&right_output_indices);
+        let descending = matches!(match_condition.op, Operator::Lt | 
Operator::LtEq);
+        let equality_options = SortOptions {
+            descending: false,
+            nulls_first: true,
+        };
+        let match_options = SortOptions {
+            descending,
+            nulls_first: true,
+        };
+        let mut left_sort_exprs = on
+            .iter()
+            .map(|(left, _)| PhysicalSortExpr {
+                expr: Arc::clone(left),
+                options: equality_options,
+            })
+            .collect::<Vec<_>>();
+        left_sort_exprs.push(PhysicalSortExpr {
+            expr: Arc::clone(&match_condition.left),
+            options: match_options,
+        });
+        let mut right_sort_exprs = on
+            .iter()
+            .map(|(_, right)| PhysicalSortExpr {
+                expr: Arc::clone(right),
+                options: equality_options,
+            })
+            .collect::<Vec<_>>();
+        right_sort_exprs.push(PhysicalSortExpr {
+            expr: Arc::clone(&match_condition.right),
+            options: match_options,
+        });
+        let left_ordering = LexOrdering::new(left_sort_exprs).ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!(
+                "ASOF left ordering must not be empty"
+            )
+        })?;
+        let right_ordering = LexOrdering::new(right_sort_exprs).ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!(
+                "ASOF right ordering must not be empty"
+            )
+        })?;
+        let cache = Arc::new(Self::compute_properties(&left, &schema)?);
+
+        Ok(Self {
+            left,
+            right,
+            on,
+            match_condition,
+            right_output_indices,
+            schema,
+            metrics: ExecutionPlanMetricsSet::new(),
+            left_ordering,
+            right_ordering,
+            right_fut: Default::default(),
+            cache,
+        })
+    }
+
+    fn compute_properties(
+        left: &Arc<dyn ExecutionPlan>,
+        schema: &SchemaRef,
+    ) -> Result<PlanProperties> {
+        let left_schema = left.schema();
+        let mapping = ProjectionMapping::try_new(
+            left_schema
+                .fields()
+                .iter()
+                .enumerate()
+                .map(|(index, field)| {
+                    (
+                        Arc::new(PhysicalColumn::new(field.name(), index))
+                            as PhysicalExprRef,
+                        field.name().to_string(),
+                    )
+                }),
+            &left_schema,
+        )?;
+        let input_eq_properties = left.equivalence_properties();
+        let eq_properties = input_eq_properties.project(&mapping, 
Arc::clone(schema));
+        let output_partitioning = left
+            .output_partitioning()
+            .project(&mapping, input_eq_properties);
+        Ok(PlanProperties::new(
+            eq_properties,
+            output_partitioning,
+            EmissionType::Incremental,
+            Boundedness::Bounded,
+        ))
+    }
+
+    /// Equality expressions.
+    pub fn on(&self) -> &JoinOn {
+        &self.on
+    }
+
+    /// Ordered match expression.
+    pub fn match_condition(&self) -> &AsOfMatchExpr {
+        &self.match_condition
+    }
+
+    /// Indices of right input columns emitted after the left columns.
+    pub fn right_output_indices(&self) -> &[usize] {
+        &self.right_output_indices
+    }
+
+    /// Left input.
+    pub fn left(&self) -> &Arc<dyn ExecutionPlan> {
+        &self.left
+    }
+
+    /// Right input.
+    pub fn right(&self) -> &Arc<dyn ExecutionPlan> {
+        &self.right
+    }
+}
+
+fn build_output_schema(
+    left: &SchemaRef,
+    right: &SchemaRef,
+    right_output_indices: &[usize],
+) -> SchemaRef {
+    let full_schema = build_join_schema(left, right, &JoinType::Left).0;
+    let left_len = left.fields().len();
+    let fields = full_schema
+        .fields()
+        .iter()
+        .take(left_len)
+        .cloned()
+        .chain(
+            right_output_indices
+                .iter()
+                .map(|index| Arc::clone(&full_schema.fields()[left_len + 
*index])),
+        )
+        .collect::<Vec<_>>();
+    Arc::new(Schema::new_with_metadata(
+        fields,
+        full_schema.metadata().clone(),
+    ))
+}
+
+impl DisplayAs for AsOfJoinExec {
+    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter<'_>) -> 
std::fmt::Result {
+        let on = self
+            .on
+            .iter()
+            .map(|(left, right)| {
+                format!("({} = {})", fmt_sql(left.as_ref()), 
fmt_sql(right.as_ref()))
+            })
+            .collect::<Vec<_>>()
+            .join(", ");
+        let match_condition = format!(
+            "{} {} {}",
+            fmt_sql(self.match_condition.left.as_ref()),
+            self.match_condition.op,
+            fmt_sql(self.match_condition.right.as_ref())
+        );
+        match t {
+            DisplayFormatType::Default | DisplayFormatType::Verbose => write!(
+                f,
+                "{}: on=[{}], match=[{}]",
+                Self::static_name(),
+                on,
+                match_condition
+            ),
+            DisplayFormatType::TreeRender => {
+                writeln!(f, "on={on}")?;
+                writeln!(f, "match={match_condition}")
+            }
+        }
+    }
+}
+
+impl ExecutionPlan for AsOfJoinExec {
+    fn name(&self) -> &'static str {
+        "AsOfJoinExec"
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        &self.cache
+    }
+
+    fn required_input_distribution(&self) -> Vec<Distribution> {
+        self.input_distribution_requirements().into_per_child()
+    }
+
+    fn input_distribution_requirements(&self) -> InputDistributionRequirements 
{
+        InputDistributionRequirements::new(vec![
+            Distribution::UnspecifiedDistribution,
+            Distribution::SinglePartition,
+        ])
+    }
+
+    fn required_input_ordering(&self) -> Vec<Option<OrderingRequirements>> {
+        vec![
+            Some(OrderingRequirements::from(self.left_ordering.clone())),
+            Some(OrderingRequirements::from(self.right_ordering.clone())),
+        ]
+    }
+
+    fn maintains_input_order(&self) -> Vec<bool> {
+        vec![true, false]
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.left, &self.right]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        check_if_same_properties!(self, children);
+        match &children[..] {
+            [left, right] => Ok(Arc::new(Self::try_new(
+                Arc::clone(left),
+                Arc::clone(right),
+                self.on.clone(),
+                self.match_condition.clone(),
+                self.right_output_indices.clone(),
+            )?)),
+            _ => internal_err!("AsOfJoinExec requires two children"),
+        }
+    }
+
+    fn with_new_children_and_same_properties(
+        self: Arc<Self>,
+        mut children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        assert_eq_or_internal_err!(
+            children.len(),
+            2,
+            "AsOfJoinExec requires two children"
+        );
+        let left = children.remove(0);
+        let right = children.remove(0);
+        Ok(Arc::new(Self {
+            left,
+            right,
+            on: self.on.clone(),
+            match_condition: self.match_condition.clone(),
+            right_output_indices: self.right_output_indices.clone(),
+            schema: Arc::clone(&self.schema),
+            metrics: ExecutionPlanMetricsSet::new(),
+            left_ordering: self.left_ordering.clone(),
+            right_ordering: self.right_ordering.clone(),
+            right_fut: Default::default(),
+            cache: Arc::clone(&self.cache),
+        }))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        let right_partitions = 
self.right.output_partitioning().partition_count();
+        assert_eq_or_internal_err!(
+            right_partitions,
+            1,
+            "AsOfJoinExec requires one right partition, found 
{right_partitions}"
+        );
+        let left_stream = self.left.execute(partition, Arc::clone(&context))?;
+        let metrics = AsOfJoinMetrics::new(partition, &self.metrics);
+        let build_metrics = metrics.clone();
+        let right_fut = self.right_fut.try_once(|| {
+            let right_stream = self.right.execute(0, Arc::clone(&context))?;
+            let reservation =
+                
MemoryConsumer::new("AsOfJoinInput").register(context.memory_pool());
+            Ok(collect_right_input(
+                right_stream,
+                reservation,
+                build_metrics,
+            ))
+        })?;
+        let (left_keys, right_keys) = self.on.iter().cloned().unzip();
+        let output_schema = Arc::clone(&self.schema);
+        let stream_schema = Arc::clone(&output_schema);
+        let left_match = Arc::clone(&self.match_condition.left);
+        let right_match = Arc::clone(&self.match_condition.right);
+        let match_op = self.match_condition.op;
+        let right_output_indices = self.right_output_indices.clone();
+        let batch_size = context.session_config().batch_size();
+        let stream = stream::once(async move {
+            let mut right_fut = right_fut;
+            let right_input = poll_fn(|cx| right_fut.get_shared(cx)).await?;
+            let right_stream = right_input.stream()?;
+            let state = AsOfJoinStreamState::new(
+                Arc::clone(&stream_schema),
+                InputCursor::new(left_stream, left_keys, left_match),
+                InputCursor::new(right_stream, right_keys, right_match),
+                match_op,
+                right_output_indices,
+                batch_size,
+                metrics,
+            );
+            let stream = stream::try_unfold(
+                (state, right_input),
+                |(mut state, right_input)| async {
+                    match state.next_batch().await? {
+                        Some(batch) => Ok(Some((batch, (state, right_input)))),
+                        None => Ok(None),
+                    }
+                },
+            );
+            Ok::<SendableRecordBatchStream, 
datafusion_common::DataFusionError>(Box::pin(
+                RecordBatchStreamAdapter::new(stream_schema, stream),
+            ))
+        })
+        .try_flatten();
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            output_schema,
+            stream,
+        )))
+    }
+
+    fn metrics(&self) -> Option<MetricsSet> {
+        Some(self.metrics.clone_inner())
+    }
+
+    fn child_stats_requests(&self, partition: Option<usize>) -> 
Vec<ChildStats> {
+        vec![ChildStats::At(partition), ChildStats::Skip]
+    }
+
+    fn statistics_from_inputs(
+        &self,
+        input_stats: &[Arc<Statistics>],
+        _args: &StatisticsArgs,
+    ) -> Result<Arc<Statistics>> {
+        let left = &input_stats[0];
+        let mut column_statistics = left.column_statistics.clone();
+        column_statistics.truncate(self.left.schema().fields().len());
+        column_statistics.resize_with(
+            self.left.schema().fields().len(),
+            ColumnStatistics::new_unknown,
+        );
+        column_statistics.extend(
+            self.right_output_indices
+                .iter()
+                .map(|_| ColumnStatistics::new_unknown()),
+        );
+        Ok(Arc::new(Statistics {
+            num_rows: left.num_rows,
+            total_byte_size: Precision::Absent,
+            column_statistics,
+        }))
+    }
+
+    fn gather_filters_for_pushdown(
+        &self,
+        _phase: FilterPushdownPhase,
+        parent_filters: Vec<PhysicalExprRef>,
+        _config: &ConfigOptions,
+    ) -> Result<FilterDescription> {
+        let left_indices = 
(0..self.left.schema().fields().len()).collect::<HashSet<_>>();
+        let left = ChildFilterDescription::from_child_with_allowed_indices(
+            &parent_filters,
+            left_indices,
+            &self.left,
+        )?;
+        let right = ChildFilterDescription::all_unsupported(&parent_filters);
+        Ok(FilterDescription::new().with_child(left).with_child(right))
+    }
+
+    fn handle_child_pushdown_result(
+        &self,
+        _phase: FilterPushdownPhase,
+        child_pushdown_result: ChildPushdownResult,
+        _config: &ConfigOptions,
+    ) -> Result<FilterPushdownPropagation<Arc<dyn ExecutionPlan>>> {
+        Ok(FilterPushdownPropagation::if_any(child_pushdown_result))
+    }
+
+    #[cfg(feature = "proto")]
+    fn try_to_proto(
+        &self,
+        ctx: &crate::proto::ExecutionPlanEncodeCtx<'_>,
+    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
+        use datafusion_proto_models::protobuf;
+
+        let left = ctx.encode_child(self.left())?;
+        let right = ctx.encode_child(self.right())?;
+        let on = self
+            .on()
+            .iter()
+            .map(|(left, right)| {
+                Ok(protobuf::JoinOn {
+                    left: Some(ctx.encode_expr(left)?),
+                    right: Some(ctx.encode_expr(right)?),
+                })
+            })
+            .collect::<Result<Vec<_>>>()?;
+        let match_operator = match self.match_condition().op {
+            Operator::Lt => protobuf::AsOfMatchOperator::Lt,
+            Operator::LtEq => protobuf::AsOfMatchOperator::LtEq,
+            Operator::Gt => protobuf::AsOfMatchOperator::Gt,
+            Operator::GtEq => protobuf::AsOfMatchOperator::GtEq,
+            op => {
+                return internal_err!(
+                    "AsOfJoinExec cannot serialize unsupported match operator 
{op}"
+                );
+            }
+        };
+
+        Ok(Some(protobuf::PhysicalPlanNode {
+            physical_plan_type: Some(
+                
protobuf::physical_plan_node::PhysicalPlanType::AsOfJoin(Box::new(
+                    protobuf::AsOfJoinExecNode {
+                        left: Some(Box::new(left)),
+                        right: Some(Box::new(right)),
+                        on,
+                        left_match_expr: Some(
+                            ctx.encode_expr(&self.match_condition().left)?,
+                        ),
+                        right_match_expr: Some(
+                            ctx.encode_expr(&self.match_condition().right)?,
+                        ),
+                        match_operator: match_operator.into(),
+                        right_output_indices: self
+                            .right_output_indices()
+                            .iter()
+                            .map(|index| *index as u32)
+                            .collect(),
+                    },
+                )),
+            ),
+        }))
+    }
+}
+
+#[cfg(feature = "proto")]
+impl AsOfJoinExec {
+    /// Reconstruct an [`AsOfJoinExec`] from its protobuf representation.
+    pub fn try_from_proto(
+        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
+        ctx: &crate::proto::ExecutionPlanDecodeCtx<'_>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        use datafusion_proto_models::protobuf;
+
+        let asof_join = crate::expect_plan_variant!(
+            node,
+            protobuf::physical_plan_node::PhysicalPlanType::AsOfJoin,
+            "AsOfJoinExec",
+        );
+        let left =
+            ctx.decode_required_child(asof_join.left.as_deref(), 
"AsOfJoinExec", "left")?;
+        let right = ctx.decode_required_child(
+            asof_join.right.as_deref(),
+            "AsOfJoinExec",
+            "right",
+        )?;
+        let left_schema = left.schema();
+        let right_schema = right.schema();
+        let on = asof_join
+            .on
+            .iter()
+            .map(|pair| {
+                let left = ctx.decode_required_expr(
+                    pair.left.as_ref(),
+                    left_schema.as_ref(),
+                    "AsOfJoinExec",
+                    "on.left",
+                )?;
+                let right = ctx.decode_required_expr(
+                    pair.right.as_ref(),
+                    right_schema.as_ref(),
+                    "AsOfJoinExec",
+                    "on.right",
+                )?;
+                Ok((left, right))
+            })
+            .collect::<Result<_>>()?;
+        let left_match = ctx.decode_required_expr(
+            asof_join.left_match_expr.as_ref(),
+            left_schema.as_ref(),
+            "AsOfJoinExec",
+            "left_match_expr",
+        )?;
+        let right_match = ctx.decode_required_expr(
+            asof_join.right_match_expr.as_ref(),
+            right_schema.as_ref(),
+            "AsOfJoinExec",
+            "right_match_expr",
+        )?;
+        let match_operator = protobuf::AsOfMatchOperator::try_from(
+            asof_join.match_operator,
+        )
+        .map_err(|_| {
+            datafusion_common::internal_datafusion_err!(
+                "AsOfJoinExec: unknown AsOfMatchOperator {}",
+                asof_join.match_operator
+            )
+        })?;
+        let op = match match_operator {
+            protobuf::AsOfMatchOperator::Lt => Operator::Lt,
+            protobuf::AsOfMatchOperator::LtEq => Operator::LtEq,
+            protobuf::AsOfMatchOperator::Gt => Operator::Gt,
+            protobuf::AsOfMatchOperator::GtEq => Operator::GtEq,
+            protobuf::AsOfMatchOperator::Unspecified => {
+                return internal_err!("AsOfJoinExec match operator must be 
specified");
+            }
+        };
+
+        Ok(Arc::new(Self::try_new(
+            left,
+            right,
+            on,
+            AsOfMatchExpr::new(left_match, op, right_match),
+            asof_join
+                .right_output_indices
+                .iter()
+                .map(|index| *index as usize)
+                .collect(),
+        )?))
+    }
+}
+
+struct BroadcastRightInput {
+    schema: SchemaRef,
+    batches: Vec<RecordBatch>,
+    _reservation: MemoryReservation,
+}
+
+impl BroadcastRightInput {
+    fn stream(&self) -> Result<SendableRecordBatchStream> {
+        Ok(Box::pin(MemoryStream::try_new(
+            self.batches.clone(),
+            Arc::clone(&self.schema),
+            None,
+        )?))
+    }
+}
+
+async fn collect_right_input(
+    input: SendableRecordBatchStream,
+    reservation: MemoryReservation,
+    metrics: AsOfJoinMetrics,
+) -> Result<BroadcastRightInput> {
+    let schema = input.schema();
+    let mut memory_counter = RecordBatchMemoryCounter::new();
+    let batches = input
+        .try_fold(Vec::new(), |mut batches, batch| {
+            let batch_size = memory_counter.count_batch(&batch);
+            futures::future::ready(reservation.try_grow(batch_size).map(|_| {
+                metrics.build_mem_used.add(batch_size);
+                metrics.build_input_batches.add(1);
+                metrics.build_input_rows.add(batch.num_rows());
+                batches.push(batch);
+                batches
+            }))
+        })
+        .await?;
+    Ok(BroadcastRightInput {
+        schema,
+        batches,
+        _reservation: reservation,
+    })
+}
+
+#[derive(Clone)]
+struct Candidate {
+    batch: Arc<RecordBatch>,
+    row: usize,
+    group: Vec<ScalarValue>,
+}
+
+struct InputCursor {
+    stream: SendableRecordBatchStream,
+    key_exprs: Vec<PhysicalExprRef>,
+    match_expr: PhysicalExprRef,
+    batch: Option<Arc<RecordBatch>>,
+    key_arrays: Vec<ArrayRef>,
+    match_array: Option<ArrayRef>,
+    row: usize,
+    eof: bool,
+}
+
+impl InputCursor {
+    fn new(
+        stream: SendableRecordBatchStream,
+        key_exprs: Vec<PhysicalExprRef>,
+        match_expr: PhysicalExprRef,
+    ) -> Self {
+        Self {
+            stream,
+            key_exprs,
+            match_expr,
+            batch: None,
+            key_arrays: vec![],
+            match_array: None,
+            row: 0,
+            eof: false,
+        }
+    }
+
+    async fn ensure_row(&mut self, elapsed_compute: &Time) -> Result<bool> {
+        loop {
+            if let Some(batch) = &self.batch
+                && self.row < batch.num_rows()
+            {
+                return Ok(true);
+            }
+            self.batch = None;
+            self.key_arrays.clear();
+            self.match_array = None;
+            self.row = 0;
+            if self.eof {
+                return Ok(false);
+            }
+            let Some(batch) = self.stream.next().await.transpose()? else {
+                self.eof = true;
+                return Ok(false);
+            };
+            if batch.num_rows() == 0 {
+                continue;
+            }
+            let batch = Arc::new(batch);
+            let _timer = elapsed_compute.timer();
+            self.key_arrays = self
+                .key_exprs
+                .iter()
+                .map(|expr| 
expr.evaluate(&batch)?.into_array(batch.num_rows()))
+                .collect::<Result<_>>()?;
+            self.match_array = Some(
+                self.match_expr
+                    .evaluate(&batch)?
+                    .into_array(batch.num_rows())?,
+            );
+            self.batch = Some(batch);
+        }
+    }
+
+    fn group(&self) -> Result<Vec<ScalarValue>> {
+        get_row_at_idx(&self.key_arrays, self.row)
+            .map(|row| 
row.into_iter().map(normalize_float_zero_scalar).collect())
+    }
+
+    fn match_value(&self) -> Result<ScalarValue> {
+        let array = self.match_array.as_ref().ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!("ASOF match array is 
missing")
+        })?;
+        ScalarValue::try_from_array(array, 
self.row).map(normalize_float_zero_scalar)
+    }
+
+    fn batch_row(&self) -> Result<(Arc<RecordBatch>, usize)> {
+        let batch = self.batch.as_ref().ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!("ASOF input batch is 
missing")
+        })?;
+        Ok((Arc::clone(batch), self.row))
+    }
+
+    fn advance(&mut self) {
+        self.row += 1;
+    }
+}
+
+#[derive(Clone)]
+struct AsOfJoinMetrics {
+    baseline: BaselineMetrics,
+    matched_rows: Count,
+    unmatched_left_rows: Count,
+    build_input_batches: Count,
+    build_input_rows: Count,
+    build_mem_used: Gauge,
+}
+
+impl AsOfJoinMetrics {
+    fn new(partition: usize, metrics: &ExecutionPlanMetricsSet) -> Self {
+        Self {
+            baseline: BaselineMetrics::new(metrics, partition),
+            matched_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("matched_rows", partition),
+            unmatched_left_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("unmatched_left_rows", partition),
+            build_input_batches: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("build_input_batches", partition),
+            build_input_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("build_input_rows", partition),
+            build_mem_used: MetricBuilder::new(metrics)
+                .peak_memory_usage("build_mem_used", partition),
+        }
+    }
+}
+
+#[derive(Default)]
+struct PendingRows {
+    sources: Vec<Arc<RecordBatch>>,
+    source_by_ptr: HashMap<usize, usize>,
+    indices: Vec<Option<(usize, usize)>>,
+}
+
+impl PendingRows {
+    fn len(&self) -> usize {
+        self.indices.len()
+    }
+
+    fn is_empty(&self) -> bool {
+        self.indices.is_empty()
+    }
+
+    fn push(&mut self, batch: Arc<RecordBatch>, row: usize) {
+        let ptr = Arc::as_ptr(&batch) as usize;
+        let source = *self.source_by_ptr.entry(ptr).or_insert_with(|| {
+            let source = self.sources.len();
+            self.sources.push(batch);
+            source
+        });
+        self.indices.push(Some((source, row)));
+    }
+
+    fn push_null(&mut self) {
+        self.indices.push(None);
+    }
+
+    fn materialize_column(
+        &self,
+        source_column: usize,
+        data_type: &arrow::datatypes::DataType,
+    ) -> Result<ArrayRef> {
+        if self.indices.is_empty() {
+            return internal_err!("ASOF output materialization has no pending 
rows");
+        }
+
+        if self.sources.len() == 1
+            && self.indices.iter().all(Option::is_some)
+            && let Some((0, first_row)) = self.indices[0]
+            && self
+                .indices
+                .iter()
+                .enumerate()
+                .all(|(offset, index)| *index == Some((0, first_row + offset)))
+        {
+            return Ok(self.sources[0]
+                .column(source_column)
+                .slice(first_row, self.indices.len()));
+        }
+
+        let has_null = self.indices.iter().any(Option::is_none);
+        let null_array = has_null.then(|| new_null_array(data_type, 1));
+        let mut source_arrays: Vec<&dyn Array> =
+            Vec::with_capacity(self.sources.len() + usize::from(has_null));
+        if let Some(null_array) = &null_array {
+            source_arrays.push(null_array.as_ref());
+        }
+        source_arrays.extend(
+            self.sources
+                .iter()
+                .map(|batch| batch.column(source_column).as_ref()),
+        );
+        let source_offset = usize::from(has_null);
+        let interleave_indices = self
+            .indices
+            .iter()
+            .map(|index| match index {
+                Some((source, row)) => (source + source_offset, *row),
+                None => (0, 0),
+            })
+            .collect::<Vec<_>>();
+        interleave(&source_arrays, &interleave_indices).map_err(Into::into)
+    }
+
+    fn clear(&mut self) {
+        self.sources.clear();
+        self.source_by_ptr.clear();
+        self.indices.clear();
+    }
+}
+
+struct AsOfJoinStreamState {
+    schema: SchemaRef,
+    left: InputCursor,
+    right: InputCursor,
+    op: Operator,
+    right_output_indices: Vec<usize>,
+    candidate: Option<Candidate>,
+    group_sort_options: Vec<SortOptions>,
+    pending_left: PendingRows,
+    pending_right: PendingRows,
+    batch_size: usize,
+    metrics: AsOfJoinMetrics,
+}
+
+impl AsOfJoinStreamState {
+    fn new(
+        schema: SchemaRef,
+        left: InputCursor,
+        right: InputCursor,
+        op: Operator,
+        right_output_indices: Vec<usize>,
+        batch_size: usize,
+        metrics: AsOfJoinMetrics,
+    ) -> Self {
+        let group_sort_options = vec![
+            SortOptions {
+                descending: false,
+                nulls_first: true,
+            };
+            left.key_exprs.len()
+        ];
+        Self {
+            pending_left: PendingRows::default(),
+            pending_right: PendingRows::default(),
+            schema,
+            left,
+            right,
+            op,
+            right_output_indices,
+            candidate: None,
+            group_sort_options,
+            batch_size: batch_size.max(1),
+            metrics,
+        }
+    }
+
+    async fn next_batch(&mut self) -> Result<Option<RecordBatch>> {
+        loop {
+            if self.pending_left.len() >= self.batch_size {
+                return self.flush().map(Some);
+            }
+            if !self
+                .left
+                .ensure_row(self.metrics.baseline.elapsed_compute())
+                .await?
+            {
+                if !self.pending_left.is_empty() {
+                    return self.flush().map(Some);
+                }
+                self.metrics.baseline.done();
+                return Ok(None);
+            }
+
+            let (left_group, left_match) = {
+                let _timer = self.metrics.baseline.elapsed_compute().timer();
+                (self.left.group()?, self.left.match_value()?)
+            };
+            if left_match.is_null() || 
left_group.iter().any(ScalarValue::is_null) {
+                self.candidate = None;
+                self.push_current_left(None)?;
+                self.left.advance();
+                continue;
+            }
+            let candidate_is_other_group = if let Some(candidate) = 
&self.candidate {
+                let _timer = self.metrics.baseline.elapsed_compute().timer();
+                compare_rows(&candidate.group, &left_group, 
&self.group_sort_options)?
+                    != Ordering::Equal
+            } else {
+                false
+            };
+            if candidate_is_other_group {
+                self.candidate = None;
+            }
+
+            loop {
+                if !self
+                    .right
+                    .ensure_row(self.metrics.baseline.elapsed_compute())
+                    .await?
+                {
+                    break;
+                }
+                let action = {
+                    let _timer = 
self.metrics.baseline.elapsed_compute().timer();
+                    let right_group = self.right.group()?;

Review Comment:
   For every left row and right row examined which materializes a 
Vec<ScalarValues> for row comparison instead of comparing arrays directly. 
Instead of using `get_row_at_idx` + `compare_rows`, we can use the 
JoinKeyComparator that SMJ already uses .
   
   



##########
datafusion/physical-plan/src/joins/asof_join.rs:
##########
@@ -0,0 +1,1855 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Broadcast, left-preserving ASOF join execution.
+//!
+//! An ASOF join emits exactly one output row for every left row. Within an
+//! optional equality-key group, it selects the closest right row that 
satisfies
+//! one ordered comparison:
+//!
+//! ```text
+//! left.ts >= right.ts  => greatest eligible right.ts
+//! left.ts <= right.ts  => smallest eligible right.ts
+//! ```
+//!
+//! The right input is collected and shared by all output partitions. The left
+//! input remains partitioned, and each partition performs an independent
+//! monotonic scan over the ordered right input:
+//!
+//! ```text
+//! AsOfJoinExec
+//!   SortExec(left equality keys, left match key)
+//!     RepartitionExec(RoundRobinBatch)
+//!       left
+//!   SortExec(right equality keys, right match key)
+//!     CoalescePartitionsExec
+//!       right
+//! ```
+//!
+//! Both inputs must be ordered by their equality keys followed by the match
+//! key. For `<` and `<=`, the match ordering is reversed so all directions use
+//! the same forward-only state machine. Each left partition owns its cursors,
+//! equality-group state, and current candidate, while the collected right
+//! batches are immutable and shared.
+//!
+//! This mode preserves probe-side parallelism when there are no equality keys
+//! or when equality keys have low cardinality or skew. It retains the complete
+//! right input in the memory pool and may scan it once per left partition, so 
a
+//! repartitioned streaming mode remains a useful future alternative for large
+//! right inputs.
+
+use std::cmp::Ordering;
+use std::collections::{HashMap, HashSet};
+use std::fmt::Formatter;
+use std::sync::Arc;
+
+use arrow::array::{Array, ArrayRef, RecordBatch, new_null_array};
+use arrow::compute::{SortOptions, interleave};
+use arrow::datatypes::{Schema, SchemaRef};
+use datafusion_common::config::ConfigOptions;
+use datafusion_common::stats::Precision;
+use datafusion_common::utils::memory::RecordBatchMemoryCounter;
+use datafusion_common::utils::{
+    compare_rows, get_row_at_idx, normalize_float_zero_scalar,
+};
+use datafusion_common::{
+    ColumnStatistics, JoinType, Result, ScalarValue, Statistics,
+    assert_eq_or_internal_err, internal_err, plan_err,
+};
+use datafusion_execution::TaskContext;
+use datafusion_execution::memory_pool::{MemoryConsumer, MemoryReservation};
+use datafusion_expr::Operator;
+use datafusion_physical_expr::PhysicalSortExpr;
+use datafusion_physical_expr::expressions::Column as PhysicalColumn;
+use datafusion_physical_expr::projection::ProjectionMapping;
+use datafusion_physical_expr::utils::collect_columns;
+use datafusion_physical_expr_common::physical_expr::{
+    PhysicalExprRef, fmt_sql, is_volatile,
+};
+use datafusion_physical_expr_common::sort_expr::{LexOrdering, 
OrderingRequirements};
+use futures::{StreamExt, TryStreamExt, future::poll_fn, stream};
+
+use crate::execution_plan::{Boundedness, EmissionType};
+use crate::filter_pushdown::{
+    ChildFilterDescription, ChildPushdownResult, FilterDescription, 
FilterPushdownPhase,
+    FilterPushdownPropagation,
+};
+use crate::joins::utils::{JoinOn, OnceAsync, build_join_schema};
+use crate::memory::MemoryStream;
+use crate::metrics::{
+    BaselineMetrics, Count, ExecutionPlanMetricsSet, Gauge, MetricBuilder,
+    MetricCategory, MetricsSet, RecordOutput, Time,
+};
+use crate::statistics::{ChildStats, StatisticsArgs};
+use crate::stream::RecordBatchStreamAdapter;
+use crate::{
+    DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, 
ExecutionPlanProperties,
+    InputDistributionRequirements, PlanProperties, SendableRecordBatchStream,
+    check_if_same_properties,
+};
+
+/// Physical ordered comparison for an ASOF join.
+#[derive(Debug, Clone)]
+pub struct AsOfMatchExpr {
+    /// Expression evaluated against the left input.
+    pub left: PhysicalExprRef,
+    /// Ordered comparison operator.
+    pub op: Operator,
+    /// Expression evaluated against the right input.
+    pub right: PhysicalExprRef,
+}
+
+impl AsOfMatchExpr {
+    /// Creates a physical ASOF match expression.
+    pub fn new(left: PhysicalExprRef, op: Operator, right: PhysicalExprRef) -> 
Self {
+        Self { left, op, right }
+    }
+}
+
+/// A broadcast sort-merge ASOF join that emits one row for every left row.
+#[derive(Debug)]
+pub struct AsOfJoinExec {
+    left: Arc<dyn ExecutionPlan>,
+    right: Arc<dyn ExecutionPlan>,
+    on: JoinOn,
+    match_condition: AsOfMatchExpr,
+    right_output_indices: Vec<usize>,
+    schema: SchemaRef,
+    metrics: ExecutionPlanMetricsSet,
+    left_ordering: LexOrdering,
+    right_ordering: LexOrdering,
+    right_fut: OnceAsync<BroadcastRightInput>,
+    cache: Arc<PlanProperties>,
+}
+
+impl AsOfJoinExec {
+    /// Creates a bounded ASOF join over sorted inputs.
+    pub fn try_new(
+        left: Arc<dyn ExecutionPlan>,
+        right: Arc<dyn ExecutionPlan>,
+        on: JoinOn,
+        match_condition: AsOfMatchExpr,
+        right_output_indices: Vec<usize>,
+    ) -> Result<Self> {
+        if !matches!(
+            match_condition.op,
+            Operator::Lt | Operator::LtEq | Operator::Gt | Operator::GtEq
+        ) {
+            return plan_err!(
+                "AsOfJoinExec requires <, <=, >, or >=, found {}",
+                match_condition.op
+            );
+        }
+        if left.boundedness().is_unbounded() || 
right.boundedness().is_unbounded() {
+            return plan_err!("AsOfJoinExec requires bounded inputs");
+        }
+        if is_volatile(&match_condition.left) || 
is_volatile(&match_condition.right) {
+            return plan_err!("AsOfJoinExec match expression must be 
deterministic");
+        }
+        if on
+            .iter()
+            .any(|(left, right)| is_volatile(left) || is_volatile(right))
+        {
+            return plan_err!("AsOfJoinExec equality expressions must be 
deterministic");
+        }
+
+        let left_schema = left.schema();
+        let right_schema = right.schema();
+        validate_expr_side(&match_condition.left, &left_schema, "left match")?;
+        validate_expr_side(&match_condition.right, &right_schema, "right 
match")?;
+        for (left_expr, right_expr) in &on {
+            validate_expr_side(left_expr, &left_schema, "left equality")?;
+            validate_expr_side(right_expr, &right_schema, "right equality")?;
+            let left_type = left_expr.data_type(&left_schema)?;
+            let right_type = right_expr.data_type(&right_schema)?;
+            if left_type != right_type {
+                return plan_err!(
+                    "AsOfJoinExec equality expression types differ: 
{left_type} and {right_type}"
+                );
+            }
+            if !datafusion_expr::utils::can_hash(&left_type) {
+                return plan_err!(
+                    "AsOfJoinExec equality expressions have unsupported hash 
type {left_type}"
+                );
+            }
+        }
+        let left_match_type = match_condition.left.data_type(&left_schema)?;
+        let right_match_type = match_condition.right.data_type(&right_schema)?;
+        if left_match_type != right_match_type {
+            return plan_err!(
+                "AsOfJoinExec match expression types differ: {left_match_type} 
and {right_match_type}"
+            );
+        }
+        if let Some(index) = right_output_indices
+            .iter()
+            .find(|index| **index >= right_schema.fields().len())
+        {
+            return plan_err!(
+                "AsOfJoinExec right output index {index} is outside schema 
with {} fields",
+                right_schema.fields().len()
+            );
+        }
+        if !right_output_indices
+            .windows(2)
+            .all(|pair| pair[0] < pair[1])
+        {
+            return plan_err!(
+                "AsOfJoinExec right output indices must be strictly increasing"
+            );
+        }
+
+        let schema =
+            build_output_schema(&left_schema, &right_schema, 
&right_output_indices);
+        let descending = matches!(match_condition.op, Operator::Lt | 
Operator::LtEq);
+        let equality_options = SortOptions {
+            descending: false,
+            nulls_first: true,
+        };
+        let match_options = SortOptions {
+            descending,
+            nulls_first: true,
+        };
+        let mut left_sort_exprs = on
+            .iter()
+            .map(|(left, _)| PhysicalSortExpr {
+                expr: Arc::clone(left),
+                options: equality_options,
+            })
+            .collect::<Vec<_>>();
+        left_sort_exprs.push(PhysicalSortExpr {
+            expr: Arc::clone(&match_condition.left),
+            options: match_options,
+        });
+        let mut right_sort_exprs = on
+            .iter()
+            .map(|(_, right)| PhysicalSortExpr {
+                expr: Arc::clone(right),
+                options: equality_options,
+            })
+            .collect::<Vec<_>>();
+        right_sort_exprs.push(PhysicalSortExpr {
+            expr: Arc::clone(&match_condition.right),
+            options: match_options,
+        });
+        let left_ordering = LexOrdering::new(left_sort_exprs).ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!(
+                "ASOF left ordering must not be empty"
+            )
+        })?;
+        let right_ordering = LexOrdering::new(right_sort_exprs).ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!(
+                "ASOF right ordering must not be empty"
+            )
+        })?;
+        let cache = Arc::new(Self::compute_properties(&left, &schema)?);
+
+        Ok(Self {
+            left,
+            right,
+            on,
+            match_condition,
+            right_output_indices,
+            schema,
+            metrics: ExecutionPlanMetricsSet::new(),
+            left_ordering,
+            right_ordering,
+            right_fut: Default::default(),
+            cache,
+        })
+    }
+
+    fn compute_properties(
+        left: &Arc<dyn ExecutionPlan>,
+        schema: &SchemaRef,
+    ) -> Result<PlanProperties> {
+        let left_schema = left.schema();
+        let mapping = ProjectionMapping::try_new(
+            left_schema
+                .fields()
+                .iter()
+                .enumerate()
+                .map(|(index, field)| {
+                    (
+                        Arc::new(PhysicalColumn::new(field.name(), index))
+                            as PhysicalExprRef,
+                        field.name().to_string(),
+                    )
+                }),
+            &left_schema,
+        )?;
+        let input_eq_properties = left.equivalence_properties();
+        let eq_properties = input_eq_properties.project(&mapping, 
Arc::clone(schema));
+        let output_partitioning = left
+            .output_partitioning()
+            .project(&mapping, input_eq_properties);
+        Ok(PlanProperties::new(
+            eq_properties,
+            output_partitioning,
+            EmissionType::Incremental,
+            Boundedness::Bounded,
+        ))
+    }
+
+    /// Equality expressions.
+    pub fn on(&self) -> &JoinOn {
+        &self.on
+    }
+
+    /// Ordered match expression.
+    pub fn match_condition(&self) -> &AsOfMatchExpr {
+        &self.match_condition
+    }
+
+    /// Indices of right input columns emitted after the left columns.
+    pub fn right_output_indices(&self) -> &[usize] {
+        &self.right_output_indices
+    }
+
+    /// Left input.
+    pub fn left(&self) -> &Arc<dyn ExecutionPlan> {
+        &self.left
+    }
+
+    /// Right input.
+    pub fn right(&self) -> &Arc<dyn ExecutionPlan> {
+        &self.right
+    }
+}
+
+fn build_output_schema(
+    left: &SchemaRef,
+    right: &SchemaRef,
+    right_output_indices: &[usize],
+) -> SchemaRef {
+    let full_schema = build_join_schema(left, right, &JoinType::Left).0;
+    let left_len = left.fields().len();
+    let fields = full_schema
+        .fields()
+        .iter()
+        .take(left_len)
+        .cloned()
+        .chain(
+            right_output_indices
+                .iter()
+                .map(|index| Arc::clone(&full_schema.fields()[left_len + 
*index])),
+        )
+        .collect::<Vec<_>>();
+    Arc::new(Schema::new_with_metadata(
+        fields,
+        full_schema.metadata().clone(),
+    ))
+}
+
+impl DisplayAs for AsOfJoinExec {
+    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter<'_>) -> 
std::fmt::Result {
+        let on = self
+            .on
+            .iter()
+            .map(|(left, right)| {
+                format!("({} = {})", fmt_sql(left.as_ref()), 
fmt_sql(right.as_ref()))
+            })
+            .collect::<Vec<_>>()
+            .join(", ");
+        let match_condition = format!(
+            "{} {} {}",
+            fmt_sql(self.match_condition.left.as_ref()),
+            self.match_condition.op,
+            fmt_sql(self.match_condition.right.as_ref())
+        );
+        match t {
+            DisplayFormatType::Default | DisplayFormatType::Verbose => write!(
+                f,
+                "{}: on=[{}], match=[{}]",
+                Self::static_name(),
+                on,
+                match_condition
+            ),
+            DisplayFormatType::TreeRender => {
+                writeln!(f, "on={on}")?;
+                writeln!(f, "match={match_condition}")
+            }
+        }
+    }
+}
+
+impl ExecutionPlan for AsOfJoinExec {
+    fn name(&self) -> &'static str {
+        "AsOfJoinExec"
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        &self.cache
+    }
+
+    fn required_input_distribution(&self) -> Vec<Distribution> {
+        self.input_distribution_requirements().into_per_child()
+    }
+
+    fn input_distribution_requirements(&self) -> InputDistributionRequirements 
{
+        InputDistributionRequirements::new(vec![
+            Distribution::UnspecifiedDistribution,
+            Distribution::SinglePartition,
+        ])
+    }
+
+    fn required_input_ordering(&self) -> Vec<Option<OrderingRequirements>> {
+        vec![
+            Some(OrderingRequirements::from(self.left_ordering.clone())),
+            Some(OrderingRequirements::from(self.right_ordering.clone())),
+        ]
+    }
+
+    fn maintains_input_order(&self) -> Vec<bool> {
+        vec![true, false]
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.left, &self.right]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        check_if_same_properties!(self, children);
+        match &children[..] {
+            [left, right] => Ok(Arc::new(Self::try_new(
+                Arc::clone(left),
+                Arc::clone(right),
+                self.on.clone(),
+                self.match_condition.clone(),
+                self.right_output_indices.clone(),
+            )?)),
+            _ => internal_err!("AsOfJoinExec requires two children"),
+        }
+    }
+
+    fn with_new_children_and_same_properties(
+        self: Arc<Self>,
+        mut children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        assert_eq_or_internal_err!(
+            children.len(),
+            2,
+            "AsOfJoinExec requires two children"
+        );
+        let left = children.remove(0);
+        let right = children.remove(0);
+        Ok(Arc::new(Self {
+            left,
+            right,
+            on: self.on.clone(),
+            match_condition: self.match_condition.clone(),
+            right_output_indices: self.right_output_indices.clone(),
+            schema: Arc::clone(&self.schema),
+            metrics: ExecutionPlanMetricsSet::new(),
+            left_ordering: self.left_ordering.clone(),
+            right_ordering: self.right_ordering.clone(),
+            right_fut: Default::default(),
+            cache: Arc::clone(&self.cache),
+        }))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        let right_partitions = 
self.right.output_partitioning().partition_count();
+        assert_eq_or_internal_err!(
+            right_partitions,
+            1,
+            "AsOfJoinExec requires one right partition, found 
{right_partitions}"
+        );
+        let left_stream = self.left.execute(partition, Arc::clone(&context))?;
+        let metrics = AsOfJoinMetrics::new(partition, &self.metrics);
+        let build_metrics = metrics.clone();
+        let right_fut = self.right_fut.try_once(|| {
+            let right_stream = self.right.execute(0, Arc::clone(&context))?;
+            let reservation =
+                
MemoryConsumer::new("AsOfJoinInput").register(context.memory_pool());
+            Ok(collect_right_input(
+                right_stream,
+                reservation,
+                build_metrics,
+            ))
+        })?;
+        let (left_keys, right_keys) = self.on.iter().cloned().unzip();
+        let output_schema = Arc::clone(&self.schema);
+        let stream_schema = Arc::clone(&output_schema);
+        let left_match = Arc::clone(&self.match_condition.left);
+        let right_match = Arc::clone(&self.match_condition.right);
+        let match_op = self.match_condition.op;
+        let right_output_indices = self.right_output_indices.clone();
+        let batch_size = context.session_config().batch_size();
+        let stream = stream::once(async move {
+            let mut right_fut = right_fut;
+            let right_input = poll_fn(|cx| right_fut.get_shared(cx)).await?;
+            let right_stream = right_input.stream()?;
+            let state = AsOfJoinStreamState::new(
+                Arc::clone(&stream_schema),
+                InputCursor::new(left_stream, left_keys, left_match),
+                InputCursor::new(right_stream, right_keys, right_match),
+                match_op,
+                right_output_indices,
+                batch_size,
+                metrics,
+            );
+            let stream = stream::try_unfold(
+                (state, right_input),
+                |(mut state, right_input)| async {
+                    match state.next_batch().await? {
+                        Some(batch) => Ok(Some((batch, (state, right_input)))),
+                        None => Ok(None),
+                    }
+                },
+            );
+            Ok::<SendableRecordBatchStream, 
datafusion_common::DataFusionError>(Box::pin(
+                RecordBatchStreamAdapter::new(stream_schema, stream),
+            ))
+        })
+        .try_flatten();
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            output_schema,
+            stream,
+        )))
+    }
+
+    fn metrics(&self) -> Option<MetricsSet> {
+        Some(self.metrics.clone_inner())
+    }
+
+    fn child_stats_requests(&self, partition: Option<usize>) -> 
Vec<ChildStats> {
+        vec![ChildStats::At(partition), ChildStats::Skip]
+    }
+
+    fn statistics_from_inputs(
+        &self,
+        input_stats: &[Arc<Statistics>],
+        _args: &StatisticsArgs,
+    ) -> Result<Arc<Statistics>> {
+        let left = &input_stats[0];
+        let mut column_statistics = left.column_statistics.clone();
+        column_statistics.truncate(self.left.schema().fields().len());
+        column_statistics.resize_with(
+            self.left.schema().fields().len(),
+            ColumnStatistics::new_unknown,
+        );
+        column_statistics.extend(
+            self.right_output_indices
+                .iter()
+                .map(|_| ColumnStatistics::new_unknown()),
+        );
+        Ok(Arc::new(Statistics {
+            num_rows: left.num_rows,
+            total_byte_size: Precision::Absent,
+            column_statistics,
+        }))
+    }
+
+    fn gather_filters_for_pushdown(
+        &self,
+        _phase: FilterPushdownPhase,
+        parent_filters: Vec<PhysicalExprRef>,
+        _config: &ConfigOptions,
+    ) -> Result<FilterDescription> {
+        let left_indices = 
(0..self.left.schema().fields().len()).collect::<HashSet<_>>();
+        let left = ChildFilterDescription::from_child_with_allowed_indices(
+            &parent_filters,
+            left_indices,
+            &self.left,
+        )?;
+        let right = ChildFilterDescription::all_unsupported(&parent_filters);
+        Ok(FilterDescription::new().with_child(left).with_child(right))
+    }
+
+    fn handle_child_pushdown_result(
+        &self,
+        _phase: FilterPushdownPhase,
+        child_pushdown_result: ChildPushdownResult,
+        _config: &ConfigOptions,
+    ) -> Result<FilterPushdownPropagation<Arc<dyn ExecutionPlan>>> {
+        Ok(FilterPushdownPropagation::if_any(child_pushdown_result))
+    }
+
+    #[cfg(feature = "proto")]
+    fn try_to_proto(
+        &self,
+        ctx: &crate::proto::ExecutionPlanEncodeCtx<'_>,
+    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
+        use datafusion_proto_models::protobuf;
+
+        let left = ctx.encode_child(self.left())?;
+        let right = ctx.encode_child(self.right())?;
+        let on = self
+            .on()
+            .iter()
+            .map(|(left, right)| {
+                Ok(protobuf::JoinOn {
+                    left: Some(ctx.encode_expr(left)?),
+                    right: Some(ctx.encode_expr(right)?),
+                })
+            })
+            .collect::<Result<Vec<_>>>()?;
+        let match_operator = match self.match_condition().op {
+            Operator::Lt => protobuf::AsOfMatchOperator::Lt,
+            Operator::LtEq => protobuf::AsOfMatchOperator::LtEq,
+            Operator::Gt => protobuf::AsOfMatchOperator::Gt,
+            Operator::GtEq => protobuf::AsOfMatchOperator::GtEq,
+            op => {
+                return internal_err!(
+                    "AsOfJoinExec cannot serialize unsupported match operator 
{op}"
+                );
+            }
+        };
+
+        Ok(Some(protobuf::PhysicalPlanNode {
+            physical_plan_type: Some(
+                
protobuf::physical_plan_node::PhysicalPlanType::AsOfJoin(Box::new(
+                    protobuf::AsOfJoinExecNode {
+                        left: Some(Box::new(left)),
+                        right: Some(Box::new(right)),
+                        on,
+                        left_match_expr: Some(
+                            ctx.encode_expr(&self.match_condition().left)?,
+                        ),
+                        right_match_expr: Some(
+                            ctx.encode_expr(&self.match_condition().right)?,
+                        ),
+                        match_operator: match_operator.into(),
+                        right_output_indices: self
+                            .right_output_indices()
+                            .iter()
+                            .map(|index| *index as u32)
+                            .collect(),
+                    },
+                )),
+            ),
+        }))
+    }
+}
+
+#[cfg(feature = "proto")]
+impl AsOfJoinExec {
+    /// Reconstruct an [`AsOfJoinExec`] from its protobuf representation.
+    pub fn try_from_proto(
+        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
+        ctx: &crate::proto::ExecutionPlanDecodeCtx<'_>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        use datafusion_proto_models::protobuf;
+
+        let asof_join = crate::expect_plan_variant!(
+            node,
+            protobuf::physical_plan_node::PhysicalPlanType::AsOfJoin,
+            "AsOfJoinExec",
+        );
+        let left =
+            ctx.decode_required_child(asof_join.left.as_deref(), 
"AsOfJoinExec", "left")?;
+        let right = ctx.decode_required_child(
+            asof_join.right.as_deref(),
+            "AsOfJoinExec",
+            "right",
+        )?;
+        let left_schema = left.schema();
+        let right_schema = right.schema();
+        let on = asof_join
+            .on
+            .iter()
+            .map(|pair| {
+                let left = ctx.decode_required_expr(
+                    pair.left.as_ref(),
+                    left_schema.as_ref(),
+                    "AsOfJoinExec",
+                    "on.left",
+                )?;
+                let right = ctx.decode_required_expr(
+                    pair.right.as_ref(),
+                    right_schema.as_ref(),
+                    "AsOfJoinExec",
+                    "on.right",
+                )?;
+                Ok((left, right))
+            })
+            .collect::<Result<_>>()?;
+        let left_match = ctx.decode_required_expr(
+            asof_join.left_match_expr.as_ref(),
+            left_schema.as_ref(),
+            "AsOfJoinExec",
+            "left_match_expr",
+        )?;
+        let right_match = ctx.decode_required_expr(
+            asof_join.right_match_expr.as_ref(),
+            right_schema.as_ref(),
+            "AsOfJoinExec",
+            "right_match_expr",
+        )?;
+        let match_operator = protobuf::AsOfMatchOperator::try_from(
+            asof_join.match_operator,
+        )
+        .map_err(|_| {
+            datafusion_common::internal_datafusion_err!(
+                "AsOfJoinExec: unknown AsOfMatchOperator {}",
+                asof_join.match_operator
+            )
+        })?;
+        let op = match match_operator {
+            protobuf::AsOfMatchOperator::Lt => Operator::Lt,
+            protobuf::AsOfMatchOperator::LtEq => Operator::LtEq,
+            protobuf::AsOfMatchOperator::Gt => Operator::Gt,
+            protobuf::AsOfMatchOperator::GtEq => Operator::GtEq,
+            protobuf::AsOfMatchOperator::Unspecified => {
+                return internal_err!("AsOfJoinExec match operator must be 
specified");
+            }
+        };
+
+        Ok(Arc::new(Self::try_new(
+            left,
+            right,
+            on,
+            AsOfMatchExpr::new(left_match, op, right_match),
+            asof_join
+                .right_output_indices
+                .iter()
+                .map(|index| *index as usize)
+                .collect(),
+        )?))
+    }
+}
+
+struct BroadcastRightInput {
+    schema: SchemaRef,
+    batches: Vec<RecordBatch>,
+    _reservation: MemoryReservation,
+}
+
+impl BroadcastRightInput {
+    fn stream(&self) -> Result<SendableRecordBatchStream> {
+        Ok(Box::pin(MemoryStream::try_new(
+            self.batches.clone(),
+            Arc::clone(&self.schema),
+            None,
+        )?))
+    }
+}
+
+async fn collect_right_input(
+    input: SendableRecordBatchStream,
+    reservation: MemoryReservation,
+    metrics: AsOfJoinMetrics,
+) -> Result<BroadcastRightInput> {
+    let schema = input.schema();
+    let mut memory_counter = RecordBatchMemoryCounter::new();
+    let batches = input
+        .try_fold(Vec::new(), |mut batches, batch| {
+            let batch_size = memory_counter.count_batch(&batch);
+            futures::future::ready(reservation.try_grow(batch_size).map(|_| {
+                metrics.build_mem_used.add(batch_size);
+                metrics.build_input_batches.add(1);
+                metrics.build_input_rows.add(batch.num_rows());
+                batches.push(batch);
+                batches
+            }))
+        })
+        .await?;
+    Ok(BroadcastRightInput {
+        schema,
+        batches,
+        _reservation: reservation,
+    })
+}
+
+#[derive(Clone)]
+struct Candidate {
+    batch: Arc<RecordBatch>,
+    row: usize,
+    group: Vec<ScalarValue>,
+}
+
+struct InputCursor {
+    stream: SendableRecordBatchStream,
+    key_exprs: Vec<PhysicalExprRef>,
+    match_expr: PhysicalExprRef,
+    batch: Option<Arc<RecordBatch>>,
+    key_arrays: Vec<ArrayRef>,
+    match_array: Option<ArrayRef>,
+    row: usize,
+    eof: bool,
+}
+
+impl InputCursor {
+    fn new(
+        stream: SendableRecordBatchStream,
+        key_exprs: Vec<PhysicalExprRef>,
+        match_expr: PhysicalExprRef,
+    ) -> Self {
+        Self {
+            stream,
+            key_exprs,
+            match_expr,
+            batch: None,
+            key_arrays: vec![],
+            match_array: None,
+            row: 0,
+            eof: false,
+        }
+    }
+
+    async fn ensure_row(&mut self, elapsed_compute: &Time) -> Result<bool> {
+        loop {
+            if let Some(batch) = &self.batch
+                && self.row < batch.num_rows()
+            {
+                return Ok(true);
+            }
+            self.batch = None;
+            self.key_arrays.clear();
+            self.match_array = None;
+            self.row = 0;
+            if self.eof {
+                return Ok(false);
+            }
+            let Some(batch) = self.stream.next().await.transpose()? else {
+                self.eof = true;
+                return Ok(false);
+            };
+            if batch.num_rows() == 0 {
+                continue;
+            }
+            let batch = Arc::new(batch);
+            let _timer = elapsed_compute.timer();
+            self.key_arrays = self
+                .key_exprs
+                .iter()
+                .map(|expr| 
expr.evaluate(&batch)?.into_array(batch.num_rows()))
+                .collect::<Result<_>>()?;
+            self.match_array = Some(
+                self.match_expr
+                    .evaluate(&batch)?
+                    .into_array(batch.num_rows())?,
+            );
+            self.batch = Some(batch);
+        }
+    }
+
+    fn group(&self) -> Result<Vec<ScalarValue>> {
+        get_row_at_idx(&self.key_arrays, self.row)
+            .map(|row| 
row.into_iter().map(normalize_float_zero_scalar).collect())
+    }
+
+    fn match_value(&self) -> Result<ScalarValue> {
+        let array = self.match_array.as_ref().ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!("ASOF match array is 
missing")
+        })?;
+        ScalarValue::try_from_array(array, 
self.row).map(normalize_float_zero_scalar)
+    }
+
+    fn batch_row(&self) -> Result<(Arc<RecordBatch>, usize)> {
+        let batch = self.batch.as_ref().ok_or_else(|| {
+            datafusion_common::internal_datafusion_err!("ASOF input batch is 
missing")
+        })?;
+        Ok((Arc::clone(batch), self.row))
+    }
+
+    fn advance(&mut self) {
+        self.row += 1;
+    }
+}
+
+#[derive(Clone)]
+struct AsOfJoinMetrics {
+    baseline: BaselineMetrics,
+    matched_rows: Count,
+    unmatched_left_rows: Count,
+    build_input_batches: Count,
+    build_input_rows: Count,
+    build_mem_used: Gauge,
+}
+
+impl AsOfJoinMetrics {
+    fn new(partition: usize, metrics: &ExecutionPlanMetricsSet) -> Self {
+        Self {
+            baseline: BaselineMetrics::new(metrics, partition),
+            matched_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("matched_rows", partition),
+            unmatched_left_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("unmatched_left_rows", partition),
+            build_input_batches: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("build_input_batches", partition),
+            build_input_rows: MetricBuilder::new(metrics)
+                .with_category(MetricCategory::Rows)
+                .counter("build_input_rows", partition),
+            build_mem_used: MetricBuilder::new(metrics)
+                .peak_memory_usage("build_mem_used", partition),
+        }
+    }
+}
+
+#[derive(Default)]
+struct PendingRows {
+    sources: Vec<Arc<RecordBatch>>,
+    source_by_ptr: HashMap<usize, usize>,
+    indices: Vec<Option<(usize, usize)>>,
+}
+
+impl PendingRows {
+    fn len(&self) -> usize {
+        self.indices.len()
+    }
+
+    fn is_empty(&self) -> bool {
+        self.indices.is_empty()
+    }
+
+    fn push(&mut self, batch: Arc<RecordBatch>, row: usize) {
+        let ptr = Arc::as_ptr(&batch) as usize;
+        let source = *self.source_by_ptr.entry(ptr).or_insert_with(|| {
+            let source = self.sources.len();
+            self.sources.push(batch);
+            source
+        });
+        self.indices.push(Some((source, row)));
+    }
+
+    fn push_null(&mut self) {
+        self.indices.push(None);
+    }
+
+    fn materialize_column(
+        &self,
+        source_column: usize,
+        data_type: &arrow::datatypes::DataType,
+    ) -> Result<ArrayRef> {
+        if self.indices.is_empty() {
+            return internal_err!("ASOF output materialization has no pending 
rows");
+        }
+
+        if self.sources.len() == 1
+            && self.indices.iter().all(Option::is_some)
+            && let Some((0, first_row)) = self.indices[0]
+            && self
+                .indices
+                .iter()
+                .enumerate()
+                .all(|(offset, index)| *index == Some((0, first_row + offset)))
+        {
+            return Ok(self.sources[0]
+                .column(source_column)
+                .slice(first_row, self.indices.len()));
+        }
+
+        let has_null = self.indices.iter().any(Option::is_none);
+        let null_array = has_null.then(|| new_null_array(data_type, 1));
+        let mut source_arrays: Vec<&dyn Array> =
+            Vec::with_capacity(self.sources.len() + usize::from(has_null));
+        if let Some(null_array) = &null_array {
+            source_arrays.push(null_array.as_ref());
+        }
+        source_arrays.extend(
+            self.sources
+                .iter()
+                .map(|batch| batch.column(source_column).as_ref()),
+        );
+        let source_offset = usize::from(has_null);
+        let interleave_indices = self
+            .indices
+            .iter()
+            .map(|index| match index {
+                Some((source, row)) => (source + source_offset, *row),
+                None => (0, 0),
+            })
+            .collect::<Vec<_>>();
+        interleave(&source_arrays, &interleave_indices).map_err(Into::into)
+    }
+
+    fn clear(&mut self) {
+        self.sources.clear();
+        self.source_by_ptr.clear();
+        self.indices.clear();
+    }
+}
+
+struct AsOfJoinStreamState {
+    schema: SchemaRef,
+    left: InputCursor,
+    right: InputCursor,
+    op: Operator,
+    right_output_indices: Vec<usize>,
+    candidate: Option<Candidate>,
+    group_sort_options: Vec<SortOptions>,
+    pending_left: PendingRows,
+    pending_right: PendingRows,
+    batch_size: usize,
+    metrics: AsOfJoinMetrics,
+}
+
+impl AsOfJoinStreamState {
+    fn new(
+        schema: SchemaRef,
+        left: InputCursor,
+        right: InputCursor,
+        op: Operator,
+        right_output_indices: Vec<usize>,
+        batch_size: usize,
+        metrics: AsOfJoinMetrics,
+    ) -> Self {
+        let group_sort_options = vec![
+            SortOptions {
+                descending: false,
+                nulls_first: true,
+            };
+            left.key_exprs.len()
+        ];
+        Self {
+            pending_left: PendingRows::default(),
+            pending_right: PendingRows::default(),
+            schema,
+            left,
+            right,
+            op,
+            right_output_indices,
+            candidate: None,
+            group_sort_options,
+            batch_size: batch_size.max(1),
+            metrics,
+        }
+    }
+
+    async fn next_batch(&mut self) -> Result<Option<RecordBatch>> {
+        loop {
+            if self.pending_left.len() >= self.batch_size {
+                return self.flush().map(Some);
+            }
+            if !self
+                .left
+                .ensure_row(self.metrics.baseline.elapsed_compute())
+                .await?
+            {
+                if !self.pending_left.is_empty() {
+                    return self.flush().map(Some);
+                }
+                self.metrics.baseline.done();
+                return Ok(None);
+            }
+
+            let (left_group, left_match) = {
+                let _timer = self.metrics.baseline.elapsed_compute().timer();
+                (self.left.group()?, self.left.match_value()?)
+            };
+            if left_match.is_null() || 
left_group.iter().any(ScalarValue::is_null) {
+                self.candidate = None;
+                self.push_current_left(None)?;
+                self.left.advance();
+                continue;
+            }
+            let candidate_is_other_group = if let Some(candidate) = 
&self.candidate {
+                let _timer = self.metrics.baseline.elapsed_compute().timer();
+                compare_rows(&candidate.group, &left_group, 
&self.group_sort_options)?
+                    != Ordering::Equal
+            } else {
+                false
+            };
+            if candidate_is_other_group {
+                self.candidate = None;
+            }
+
+            loop {
+                if !self
+                    .right
+                    .ensure_row(self.metrics.baseline.elapsed_compute())
+                    .await?
+                {
+                    break;
+                }
+                let action = {
+                    let _timer = 
self.metrics.baseline.elapsed_compute().timer();
+                    let right_group = self.right.group()?;
+                    if right_group.iter().any(ScalarValue::is_null) {
+                        RightAction::Advance
+                    } else {
+                        match compare_rows(
+                            &right_group,
+                            &left_group,
+                            &self.group_sort_options,
+                        )? {
+                            Ordering::Less => RightAction::Advance,
+                            Ordering::Greater => RightAction::Stop,
+                            Ordering::Equal => {
+                                let right_match = self.right.match_value()?;
+                                if right_match.is_null() {
+                                    RightAction::Advance
+                                } else if is_eligible(self.op, &left_match, 
&right_match)?
+                                {
+                                    let (batch, row) = self.right.batch_row()?;
+                                    RightAction::Candidate(Candidate {
+                                        batch,
+                                        row,
+                                        group: right_group,
+                                    })
+                                } else {
+                                    RightAction::Stop
+                                }
+                            }
+                        }
+                    }
+                };
+                match action {
+                    RightAction::Advance => self.right.advance(),
+                    RightAction::Candidate(candidate) => {
+                        self.candidate = Some(candidate);
+                        self.right.advance();
+                    }
+                    RightAction::Stop => break,
+                }
+            }
+
+            self.push_current_left(self.candidate.clone())?;
+            self.left.advance();
+        }
+    }
+
+    fn push_current_left(&mut self, candidate: Option<Candidate>) -> 
Result<()> {
+        let _timer = self.metrics.baseline.elapsed_compute().timer();
+        let (left_batch, left_row) = self.left.batch_row()?;
+        self.pending_left.push(left_batch, left_row);
+        match candidate {
+            Some(candidate) => {
+                if !self.right_output_indices.is_empty() {
+                    self.pending_right.push(candidate.batch, candidate.row);
+                }
+                self.metrics.matched_rows.add(1);
+            }
+            None => {
+                if !self.right_output_indices.is_empty() {
+                    self.pending_right.push_null();
+                }
+                self.metrics.unmatched_left_rows.add(1);
+            }
+        }
+        Ok(())
+    }
+
+    fn flush(&mut self) -> Result<RecordBatch> {
+        let _timer = self.metrics.baseline.elapsed_compute().timer();
+        let left_len = self.schema.fields().len() - 
self.right_output_indices.len();
+        let mut arrays = Vec::with_capacity(self.schema.fields().len());
+        for index in 0..left_len {
+            arrays.push(
+                self.pending_left
+                    .materialize_column(index, 
self.schema.field(index).data_type())?,
+            );
+        }
+        for (offset, source_index) in 
self.right_output_indices.iter().enumerate() {
+            arrays.push(self.pending_right.materialize_column(
+                *source_index,
+                self.schema.field(left_len + offset).data_type(),
+            )?);
+        }
+        self.pending_left.clear();
+        self.pending_right.clear();
+        let batch = RecordBatch::try_new(Arc::clone(&self.schema), arrays)?;
+        (&batch).record_output(&self.metrics.baseline);
+        Ok(batch)
+    }
+}
+
+fn validate_expr_side(expr: &PhysicalExprRef, schema: &Schema, name: &str) -> 
Result<()> {
+    let columns = collect_columns(expr);
+    if columns.is_empty() {
+        return plan_err!("AsOfJoinExec {name} expression must reference its 
input");
+    }
+    if let Some(column) = columns.iter().find(|column| {
+        schema
+            .fields()
+            .get(column.index())
+            .is_none_or(|field| field.name() != column.name())
+    }) {
+        return plan_err!(
+            "AsOfJoinExec {name} expression references column {column} outside 
its input"
+        );
+    }
+    Ok(())
+}
+
+enum RightAction {
+    Advance,
+    Candidate(Candidate),
+    Stop,
+}
+
+fn is_eligible(op: Operator, left: &ScalarValue, right: &ScalarValue) -> 
Result<bool> {
+    let ordering = right.try_cmp(left)?;
+    Ok(match op {
+        Operator::Gt => ordering == Ordering::Less,
+        Operator::GtEq => ordering != Ordering::Greater,
+        Operator::Lt => ordering == Ordering::Greater,
+        Operator::LtEq => ordering != Ordering::Less,
+        _ => false,

Review Comment:
   nit: this should be unreachable!



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to