viirya commented on code in PR #25491:
URL: https://github.com/apache/datafusion/pull/25491#discussion_r4052500116


##########
datafusion/physical-plan/src/joins/hash_join/exec.rs:
##########
@@ -1625,6 +1660,9 @@ impl ExecutionPlan for HashJoinExec {
         partition: usize,
         context: Arc<TaskContext>,
     ) -> Result<SendableRecordBatchStream> {
+        if let Some(prepared) = &self.prepared_build {

Review Comment:
   This looks redundant next to the `validate` in `build()`, and I expect 
someone will eventually delete it as dead weight. It is not redundant — it 
catches direct field mutation that bypasses the builder, which your own test 
exercises:
   
   ```rust
   let mut mutated = attached.builder().build()?;
   mutated.on = keys;
   ```
   
   Worth a comment saying so, e.g. "fields such as `on` are writable within the 
crate, so re-check here: `build()` cannot be the only gate".



##########
datafusion/physical-plan/src/joins/hash_join/exec/prepared.rs:
##########
@@ -0,0 +1,326 @@
+// 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.
+
+//! Explicit immutable build reuse for embedding executors.
+
+use super::*;
+use arrow::array::{Array, AsArray};
+use datafusion_common::exec_datafusion_err;
+use datafusion_execution::memory_pool::MemoryPool;
+
+/// An immutable, fully prepared broadcast build, independent of any probe 
task.
+///
+/// Created by [`HashJoinExec::prepare_build`]. The embedding executor owns 
cache
+/// identity, admission, single-flight coordination, cancellation and eviction.
+/// This object retains its input buffers and memory reservation until its last
+/// lease is dropped; it never retains an input stream or task context. 
Prepared
+/// builds support fixed-width and UTF-8 build columns, with direct-column keys
+/// and non-spilling INNER joins. Residual conditions belong to each consuming
+/// join; null-aware joins remain unsupported.
+///
+/// Hash-join gathers copy supported build columns into output buffers,
+/// including contiguous selections. Output batches can therefore outlive this
+/// object without retaining unaccounted cached payload. View, dictionary and
+/// nested build columns remain unsupported. UTF-8 and fixed-size binary keys
+/// use hash-table membership filters instead of copying range or IN-list 
values.
+pub struct PreparedHashJoinBuild {

Review Comment:
   This is where an identity token would live if you take up point 1 in the 
main review — something like `snapshot: Option<Arc<str>>`, compared in 
`validate` and never interpreted. Flagging here so the thread hangs off the 
struct rather than only in the summary.



##########
datafusion/physical-plan/src/joins/hash_join/exec.rs:
##########
@@ -3104,18 +3250,37 @@ async fn collect_left_input(
         && !left_values.is_empty()
         && left_values[0].logical_null_count() > 0;
 
+    if prepared {
+        drop(batches);
+        let retained = RecordBatchMemoryCounter::new().count_batch(&batch);
+        let allowance = input_bytes.checked_add(copy_bytes).ok_or_else(|| {
+            datafusion_common::exec_datafusion_err!(
+                "Prepared hash-join payload size overflow"
+            )
+        })?;
+        if retained > allowance {

Review Comment:
   `allowance` is hand-computed from Arrow's current buffer layout and 64-byte 
alignment rounding. I worked through the multi-batch case and the margin looks 
sufficient (roughly 192 bytes of padding budget per batch, and `4*(rows_i+1)` 
summed over two or more batches dominates `4*(total+1)`), so I do not think 
this fires today.
   
   But it binds us to Arrow's allocation behaviour. If a future arrow-rs 
changes capacity rounding — say to power-of-two growth — users hit a hard 
`Internal error` on a perfectly valid input, with a message asking them to 
report a bug.
   
   Would you consider degrading instead of failing?
   
   ```rust
   if retained > allowance {
       debug_assert!(false, "prepared concat exceeded admitted bound");
       reservation.try_grow(retained - allowance)?;   // let the pool decide
   } else {
       reservation.shrink(allowance - retained);
   }
   ```
   
   That keeps the invariant loud in debug builds and in CI, while in release an 
estimate that drifts becomes "we reserved a bit more memory" rather than a 
failed query.



##########
datafusion/physical-plan/src/joins/hash_join/exec/prepared.rs:
##########
@@ -0,0 +1,326 @@
+// 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.
+
+//! Explicit immutable build reuse for embedding executors.
+
+use super::*;
+use arrow::array::{Array, AsArray};
+use datafusion_common::exec_datafusion_err;
+use datafusion_execution::memory_pool::MemoryPool;
+
+/// An immutable, fully prepared broadcast build, independent of any probe 
task.
+///
+/// Created by [`HashJoinExec::prepare_build`]. The embedding executor owns 
cache
+/// identity, admission, single-flight coordination, cancellation and eviction.
+/// This object retains its input buffers and memory reservation until its last
+/// lease is dropped; it never retains an input stream or task context. 
Prepared
+/// builds support fixed-width and UTF-8 build columns, with direct-column keys
+/// and non-spilling INNER joins. Residual conditions belong to each consuming
+/// join; null-aware joins remain unsupported.
+///
+/// Hash-join gathers copy supported build columns into output buffers,
+/// including contiguous selections. Output batches can therefore outlive this
+/// object without retaining unaccounted cached payload. View, dictionary and
+/// nested build columns remain unsupported. UTF-8 and fixed-size binary keys
+/// use hash-table membership filters instead of copying range or IN-list 
values.
+pub struct PreparedHashJoinBuild {
+    build: Arc<JoinBuildData>,
+    keys: Vec<usize>,
+    null_equality: NullEquality,
+}
+
+impl fmt::Debug for PreparedHashJoinBuild {
+    /// Describe immutable metadata without dumping table contents.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PreparedHashJoinBuild")
+            .field("schema", &self.build.batch.schema())
+            .field("keys", &self.keys)
+            .field("rows", &self.num_rows())
+            .field("reserved_bytes", &self.reserved_bytes())
+            .finish()
+    }
+}
+
+impl PreparedHashJoinBuild {
+    /// Return the retained build reservation, excluding all per-probe state.
+    pub fn reserved_bytes(&self) -> usize {
+        self.build.reservation.size()
+    }
+
+    /// Return the complete build row count, including duplicate and null keys.
+    pub fn num_rows(&self) -> usize {
+        self.build.batch.num_rows()
+    }
+
+    /// Create independent mutable state for one consuming join.
+    pub(super) fn probe_data(&self, probe_threads: usize) -> JoinLeftData {
+        JoinLeftData {
+            build: Arc::clone(&self.build),
+            null_aware_mark_scope_map: None,
+            null_value_scope_map: None,
+            visited_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            null_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            probe_completion: ProbeCompletion::new(probe_threads),
+            build_side_has_null: false,
+            _probe_reservation: self.build.reservation.new_empty(),
+        }
+    }
+
+    /// Validate the build descriptor and current execution restrictions 
without
+    /// consuming input or modifying either plan. Cache identity is 
caller-owned.
+    pub(super) fn validate(&self, join: &HashJoinExec) -> Result<()> {
+        let keys = prepared_key_indices(join)?;
+        if join.left.schema() != self.build.batch.schema()
+            || keys != self.keys
+            || join.null_equality != self.null_equality
+        {
+            return plan_err!(
+                "Prepared hash-join build does not match schema, keys or null 
equality"
+            );
+        }
+        if let Some(filter) = &join.dynamic_filter {
+            let filter_keys = filter.filter.children();
+            if filter_keys.len() != join.on.len()
+                || filter_keys
+                    .iter()
+                    .zip(&join.on)
+                    .any(|(filter_key, (_, probe_key))| {
+                        filter_key.as_ref() != probe_key.as_ref()
+                    })
+            {
+                return plan_err!(
+                    "Prepared hash-join dynamic filter keys do not match probe 
keys"
+                );
+            }
+        }
+        Ok(())
+    }
+}
+
+impl HashJoinExec {
+    /// Prepare one immutable build using an embedding executor's durable pool.
+    ///
+    /// The supplied stream must own its native buffers independently of 
producer
+    /// task cleanup. This method consumes only that stream, never `self.left`,
+    /// and reserves retained data, hash buckets and row-index chains against
+    /// `pool`. The caller must keep original producer allocations charged 
until
+    /// its stream releases them. `config` controls ordinary perfect-map and
+    /// dynamic-filter choices. UTF-8 and fixed-size binary keys
+    /// retain hash membership only:
+    /// range bounds and IN-list literals would allocate unaccounted key 
copies.
+    ///
+    /// Validates eligibility and the stream schema before polling. On error or
+    /// future cancellation, all work and reservations are dropped; no 
partially
+    /// prepared object is returned. Concurrent preparation/cache publication 
is
+    /// the caller's responsibility. Bounds and membership are prepared once, 
but
+    /// each consuming join publishes them into its own dynamic filter.

Review Comment:
   Two things about this doc block:
   
   1. The most important caller obligation — that snapshot identity is theirs 
to get right, and that getting it wrong yields silently wrong results — is not 
stated here at all. It only appears in the PR description. Could it lead the 
doc block?
   
   2. "range bounds and IN-list literals would allocate unaccounted key copies" 
reads as though only byte keys copy. `InListExpr::try_new_from_array` 
materialises one `ScalarValue` + `lit()` per **row** (not per distinct value) 
for every type, including `Int64` — it is just bounded there by 
`hash_join_inlist_pushdown_max_size` (128 KB). The real reason to exclude byte 
keys is that their copy volume is unbounded, not that they are the only types 
that copy. Worth stating precisely, or a reader will assume the numeric path 
has no unaccounted allocation.



##########
datafusion/physical-plan/src/joins/hash_join/exec.rs:
##########
@@ -2854,6 +2920,7 @@ async fn collect_left_input(
     null_equality: NullEquality,
     null_aware: Option<NullAwareMode>,
     array_map_created_count: Count,
+    prepared: bool,

Review Comment:
   `collect_left_input` already carries 
`#[expect(clippy::too_many_arguments)]`; this makes it a 13th parameter and 
adds six `if prepared` sites scattered through the hottest function in the hash 
join. Some of them — the validity-mask scratch sizing, the row-index chain 
top-up — are subtle enough that I had to read them twice to confirm they do not 
affect the normal path.
   
   Would you consider pulling the admission arithmetic into its own type, e.g. 
a `PreparedAdmission` holding `concat_values` / `input_bytes` / `copy_bytes` 
with `observe_batch` / `admit_copies` / `settle`, and passing 
`Option<PreparedAdmission>`? Six `if prepared` collapse to three `if let 
Some(..)`, and the accounting math ends up somewhere it can be unit-tested on 
its own — which, given how carefully tuned it is, seems worth having.
   
   Not a blocker, but this function is on every hash join's path and the review 
cost of the current shape is real.



##########
datafusion/physical-plan/src/joins/hash_join/exec/prepared.rs:
##########
@@ -0,0 +1,326 @@
+// 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.
+
+//! Explicit immutable build reuse for embedding executors.
+
+use super::*;
+use arrow::array::{Array, AsArray};
+use datafusion_common::exec_datafusion_err;
+use datafusion_execution::memory_pool::MemoryPool;
+
+/// An immutable, fully prepared broadcast build, independent of any probe 
task.
+///
+/// Created by [`HashJoinExec::prepare_build`]. The embedding executor owns 
cache
+/// identity, admission, single-flight coordination, cancellation and eviction.
+/// This object retains its input buffers and memory reservation until its last
+/// lease is dropped; it never retains an input stream or task context. 
Prepared
+/// builds support fixed-width and UTF-8 build columns, with direct-column keys
+/// and non-spilling INNER joins. Residual conditions belong to each consuming
+/// join; null-aware joins remain unsupported.
+///
+/// Hash-join gathers copy supported build columns into output buffers,
+/// including contiguous selections. Output batches can therefore outlive this
+/// object without retaining unaccounted cached payload. View, dictionary and
+/// nested build columns remain unsupported. UTF-8 and fixed-size binary keys
+/// use hash-table membership filters instead of copying range or IN-list 
values.
+pub struct PreparedHashJoinBuild {
+    build: Arc<JoinBuildData>,
+    keys: Vec<usize>,
+    null_equality: NullEquality,
+}
+
+impl fmt::Debug for PreparedHashJoinBuild {
+    /// Describe immutable metadata without dumping table contents.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PreparedHashJoinBuild")
+            .field("schema", &self.build.batch.schema())
+            .field("keys", &self.keys)
+            .field("rows", &self.num_rows())
+            .field("reserved_bytes", &self.reserved_bytes())
+            .finish()
+    }
+}
+
+impl PreparedHashJoinBuild {
+    /// Return the retained build reservation, excluding all per-probe state.
+    pub fn reserved_bytes(&self) -> usize {
+        self.build.reservation.size()
+    }
+
+    /// Return the complete build row count, including duplicate and null keys.
+    pub fn num_rows(&self) -> usize {
+        self.build.batch.num_rows()
+    }
+
+    /// Create independent mutable state for one consuming join.
+    pub(super) fn probe_data(&self, probe_threads: usize) -> JoinLeftData {
+        JoinLeftData {
+            build: Arc::clone(&self.build),
+            null_aware_mark_scope_map: None,
+            null_value_scope_map: None,
+            visited_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            null_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            probe_completion: ProbeCompletion::new(probe_threads),
+            build_side_has_null: false,
+            _probe_reservation: self.build.reservation.new_empty(),

Review Comment:
   `self.build.reservation.new_empty()` inherits the pool passed to 
`prepare_build` — the caller's long-lived cache pool — not the consuming task's 
pool.
   
   Harmless today: INNER joins allocate no bitmap, so this reservation stays at 
zero. But `probe_data` is exactly where outer-join support would be added, and 
at that point the visited bitmap would be charged to the cache pool instead of 
the task pool, which is the wrong lifetime. Either a comment marking the 
hazard, or having `probe_data` take the consumer's pool now (ignored while it 
is always zero), would keep the interface honest.



##########
datafusion/physical-plan/src/joins/hash_join/exec/prepared.rs:
##########
@@ -0,0 +1,326 @@
+// 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.
+
+//! Explicit immutable build reuse for embedding executors.
+
+use super::*;
+use arrow::array::{Array, AsArray};
+use datafusion_common::exec_datafusion_err;
+use datafusion_execution::memory_pool::MemoryPool;
+
+/// An immutable, fully prepared broadcast build, independent of any probe 
task.
+///
+/// Created by [`HashJoinExec::prepare_build`]. The embedding executor owns 
cache
+/// identity, admission, single-flight coordination, cancellation and eviction.
+/// This object retains its input buffers and memory reservation until its last
+/// lease is dropped; it never retains an input stream or task context. 
Prepared
+/// builds support fixed-width and UTF-8 build columns, with direct-column keys
+/// and non-spilling INNER joins. Residual conditions belong to each consuming
+/// join; null-aware joins remain unsupported.
+///
+/// Hash-join gathers copy supported build columns into output buffers,
+/// including contiguous selections. Output batches can therefore outlive this
+/// object without retaining unaccounted cached payload. View, dictionary and
+/// nested build columns remain unsupported. UTF-8 and fixed-size binary keys
+/// use hash-table membership filters instead of copying range or IN-list 
values.
+pub struct PreparedHashJoinBuild {
+    build: Arc<JoinBuildData>,
+    keys: Vec<usize>,
+    null_equality: NullEquality,
+}
+
+impl fmt::Debug for PreparedHashJoinBuild {
+    /// Describe immutable metadata without dumping table contents.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PreparedHashJoinBuild")
+            .field("schema", &self.build.batch.schema())
+            .field("keys", &self.keys)
+            .field("rows", &self.num_rows())
+            .field("reserved_bytes", &self.reserved_bytes())
+            .finish()
+    }
+}
+
+impl PreparedHashJoinBuild {
+    /// Return the retained build reservation, excluding all per-probe state.
+    pub fn reserved_bytes(&self) -> usize {
+        self.build.reservation.size()
+    }
+
+    /// Return the complete build row count, including duplicate and null keys.
+    pub fn num_rows(&self) -> usize {
+        self.build.batch.num_rows()
+    }
+
+    /// Create independent mutable state for one consuming join.
+    pub(super) fn probe_data(&self, probe_threads: usize) -> JoinLeftData {
+        JoinLeftData {
+            build: Arc::clone(&self.build),
+            null_aware_mark_scope_map: None,
+            null_value_scope_map: None,
+            visited_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            null_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            probe_completion: ProbeCompletion::new(probe_threads),
+            build_side_has_null: false,
+            _probe_reservation: self.build.reservation.new_empty(),
+        }
+    }
+
+    /// Validate the build descriptor and current execution restrictions 
without
+    /// consuming input or modifying either plan. Cache identity is 
caller-owned.
+    pub(super) fn validate(&self, join: &HashJoinExec) -> Result<()> {
+        let keys = prepared_key_indices(join)?;
+        if join.left.schema() != self.build.batch.schema()
+            || keys != self.keys
+            || join.null_equality != self.null_equality
+        {
+            return plan_err!(
+                "Prepared hash-join build does not match schema, keys or null 
equality"
+            );
+        }
+        if let Some(filter) = &join.dynamic_filter {
+            let filter_keys = filter.filter.children();
+            if filter_keys.len() != join.on.len()
+                || filter_keys
+                    .iter()
+                    .zip(&join.on)
+                    .any(|(filter_key, (_, probe_key))| {
+                        filter_key.as_ref() != probe_key.as_ref()
+                    })
+            {
+                return plan_err!(
+                    "Prepared hash-join dynamic filter keys do not match probe 
keys"
+                );
+            }
+        }
+        Ok(())
+    }
+}
+
+impl HashJoinExec {
+    /// Prepare one immutable build using an embedding executor's durable pool.
+    ///
+    /// The supplied stream must own its native buffers independently of 
producer
+    /// task cleanup. This method consumes only that stream, never `self.left`,
+    /// and reserves retained data, hash buckets and row-index chains against
+    /// `pool`. The caller must keep original producer allocations charged 
until
+    /// its stream releases them. `config` controls ordinary perfect-map and
+    /// dynamic-filter choices. UTF-8 and fixed-size binary keys
+    /// retain hash membership only:
+    /// range bounds and IN-list literals would allocate unaccounted key 
copies.
+    ///
+    /// Validates eligibility and the stream schema before polling. On error or
+    /// future cancellation, all work and reservations are dropped; no 
partially
+    /// prepared object is returned. Concurrent preparation/cache publication 
is
+    /// the caller's responsibility. Bounds and membership are prepared once, 
but
+    /// each consuming join publishes them into its own dynamic filter.
+    pub async fn prepare_build(
+        &self,
+        input: SendableRecordBatchStream,
+        pool: Arc<dyn MemoryPool>,
+        config: Arc<ConfigOptions>,
+    ) -> Result<Arc<PreparedHashJoinBuild>> {
+        let keys = prepared_key_indices(self)?;
+        let schema = self.left.schema();
+        if input.schema() != schema {
+            return plan_err!(
+                "Prepared hash-join input schema does not match build schema"
+            );
+        }
+        let byte_keys = keys.iter().any(|&key| {
+            matches!(
+                schema.field(key).data_type(),
+                DataType::Utf8 | DataType::FixedSizeBinary(_)
+            )
+        });
+        // Range accumulation and IN-list publication materialize ScalarValue
+        // copies of byte keys. Hash membership borrows the admitted table 
instead.
+        let config = if byte_keys {
+            let mut config = config.as_ref().clone();
+            config.optimizer.hash_join_inlist_pushdown_max_size = 0;
+            Arc::new(config)
+        } else {
+            config
+        };
+        let metrics_set = ExecutionPlanMetricsSet::new();
+        let metrics = BuildProbeJoinMetrics::new(0, &metrics_set);
+        let count = MetricBuilder::new(&metrics_set)
+            .counter(ARRAY_MAP_CREATED_COUNT_METRIC_NAME, 0);
+        let reservation = 
MemoryConsumer::new("PreparedHashJoinBuild").register(&pool);
+        let data = collect_left_input(
+            self.random_state.random_state().clone(),
+            input,
+            self.on.iter().map(|(left, _)| Arc::clone(left)).collect(),
+            metrics,
+            reservation,
+            false,
+            0,
+            !byte_keys,
+            config,
+            self.null_equality,
+            None,
+            count,
+            true,
+        )
+        .await?;
+        Ok(Arc::new(PreparedHashJoinBuild {
+            build: data.build,
+            keys,
+            null_equality: self.null_equality,
+        }))
+    }
+}
+
+/// Bound copy allocations, including validity, offsets and alignment. Aliased
+/// columns count separately because concatenation materializes each column.
+pub(super) fn prepared_copy_bytes(batch: &RecordBatch) -> Result<usize> {
+    let rows = batch.num_rows();
+    batch.columns().iter().try_fold(0usize, |total, array| {
+        let values = match array.data_type() {
+            DataType::Utf8 => rows
+                .checked_add(1)
+                .and_then(|len| len.checked_mul(4))
+                .and_then(|offsets| 
utf8_value_span(array.as_ref()).checked_add(offsets))
+                // UTF-8 has a third allocation for offsets.
+                .and_then(|bytes| bytes.checked_add(64)),
+            DataType::Null => Some(0),
+            DataType::Boolean => Some(rows.div_ceil(8)),
+            DataType::FixedSizeBinary(width) => usize::try_from(*width)
+                .ok()
+                .and_then(|width| width.checked_mul(rows)),
+            ty => ty
+                .primitive_width()
+                .and_then(|width| width.checked_mul(rows)),
+        };
+        values
+            .and_then(|bytes| bytes.checked_add(rows.div_ceil(8)))
+            .and_then(|bytes| bytes.checked_add(2 * 64))
+            .and_then(|bytes| total.checked_add(bytes))
+            .ok_or_else(|| exec_datafusion_err!("Prepared hash-join copy size 
overflow"))
+    })
+}
+
+/// Include values hidden by nulls but exclude bytes outside a sliced array.
+fn utf8_value_span(array: &dyn Array) -> usize {
+    let offsets = array.as_string::<i32>().value_offsets();
+    (offsets[offsets.len() - 1] - offsets[0]) as usize
+}
+
+/// Add `batch`'s byte-column spans to `totals`, one entry per schema column, 
before
+/// an eventual single-batch concat. Reject offset overflow before allocating
+/// the value buffer; fixed-width columns leave their totals unchanged.
+pub(super) fn check_byte_concat_sizes(
+    batch: &RecordBatch,
+    totals: &mut [usize],
+) -> Result<()> {
+    for (array, total) in batch.columns().iter().zip(totals) {
+        if matches!(array.data_type(), DataType::Utf8) {
+            *total = total.checked_add(utf8_value_span(array.as_ref()))
+                .filter(|&sum| i32::try_from(sum).is_ok())
+                .ok_or_else(|| exec_datafusion_err!(
+                    "Prepared hash-join UTF-8 column exceeds its offset limit; 
a compact build is required"
+                ))?;
+        }
+    }
+    Ok(())
+}
+
+impl HashJoinExecBuilder {
+    /// Attach a fully prepared build to a fresh, compatible join execution.
+    ///
+    /// [`Self::build`] validates compatibility. Attaching resets the build 
future
+    /// and execution metrics. A previously attached task-local dynamic filter
+    /// keeps its expression handle (also referenced by the probe plan), while
+    /// its build-report accumulator is reset. The caller must provide a fresh
+    /// filter expression/probe plan for each independent task.
+    /// Residual filters also remain consumer-local, so compatible INNER joins
+    /// may use different predicates with the same prepared data.
+    /// The resulting join plan retains the build lease. A caller retaining a
+    /// dynamic-filter expression beyond that plan must retain a prepared-build
+    /// lease alongside it, because membership filters can reference build 
data.
+    /// Attach after child-rewriting physical optimizations. The unused left
+    /// subtree is replaced with an empty schema placeholder so plan resets
+    /// preserve it. Probe-only rewrites must retain the attached join's 
`left()`.
+    /// Replacing that child or changing to incompatible join keys fails; other
+    /// incompatible join-mode/type changes fail validation in `build`.

Review Comment:
   Fourteen lines of prose carrying six distinct constraints. One of them is a 
correctness obligation:
   
   > A caller retaining a dynamic-filter expression beyond that plan must 
retain a prepared-build lease alongside it, because membership filters can 
reference build data.
   
   That is real — `PushdownStrategy::InList` holds an `Arc` to the build key 
array and `PushdownStrategy::Map` holds the whole hash map — and it is 
currently line 10 of a paragraph. Could this be restructured, e.g. a `# Caller 
contract` section with the obligations as bullets, separated from the 
descriptive text? As written it is hard for a reader to confirm they have not 
missed one.



##########
datafusion/physical-plan/src/joins/hash_join/exec/prepared.rs:
##########
@@ -0,0 +1,326 @@
+// 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.
+
+//! Explicit immutable build reuse for embedding executors.
+
+use super::*;
+use arrow::array::{Array, AsArray};
+use datafusion_common::exec_datafusion_err;
+use datafusion_execution::memory_pool::MemoryPool;
+
+/// An immutable, fully prepared broadcast build, independent of any probe 
task.
+///
+/// Created by [`HashJoinExec::prepare_build`]. The embedding executor owns 
cache
+/// identity, admission, single-flight coordination, cancellation and eviction.
+/// This object retains its input buffers and memory reservation until its last
+/// lease is dropped; it never retains an input stream or task context. 
Prepared
+/// builds support fixed-width and UTF-8 build columns, with direct-column keys
+/// and non-spilling INNER joins. Residual conditions belong to each consuming
+/// join; null-aware joins remain unsupported.
+///
+/// Hash-join gathers copy supported build columns into output buffers,
+/// including contiguous selections. Output batches can therefore outlive this
+/// object without retaining unaccounted cached payload. View, dictionary and
+/// nested build columns remain unsupported. UTF-8 and fixed-size binary keys
+/// use hash-table membership filters instead of copying range or IN-list 
values.
+pub struct PreparedHashJoinBuild {
+    build: Arc<JoinBuildData>,
+    keys: Vec<usize>,
+    null_equality: NullEquality,
+}
+
+impl fmt::Debug for PreparedHashJoinBuild {
+    /// Describe immutable metadata without dumping table contents.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PreparedHashJoinBuild")
+            .field("schema", &self.build.batch.schema())
+            .field("keys", &self.keys)
+            .field("rows", &self.num_rows())
+            .field("reserved_bytes", &self.reserved_bytes())
+            .finish()
+    }
+}
+
+impl PreparedHashJoinBuild {
+    /// Return the retained build reservation, excluding all per-probe state.
+    pub fn reserved_bytes(&self) -> usize {
+        self.build.reservation.size()
+    }
+
+    /// Return the complete build row count, including duplicate and null keys.
+    pub fn num_rows(&self) -> usize {
+        self.build.batch.num_rows()
+    }
+
+    /// Create independent mutable state for one consuming join.
+    pub(super) fn probe_data(&self, probe_threads: usize) -> JoinLeftData {
+        JoinLeftData {
+            build: Arc::clone(&self.build),
+            null_aware_mark_scope_map: None,
+            null_value_scope_map: None,
+            visited_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            null_indices_bitmap: Mutex::new(BooleanBufferBuilder::new(0)),
+            probe_completion: ProbeCompletion::new(probe_threads),
+            build_side_has_null: false,
+            _probe_reservation: self.build.reservation.new_empty(),
+        }
+    }
+
+    /// Validate the build descriptor and current execution restrictions 
without
+    /// consuming input or modifying either plan. Cache identity is 
caller-owned.
+    pub(super) fn validate(&self, join: &HashJoinExec) -> Result<()> {
+        let keys = prepared_key_indices(join)?;
+        if join.left.schema() != self.build.batch.schema()
+            || keys != self.keys
+            || join.null_equality != self.null_equality
+        {
+            return plan_err!(
+                "Prepared hash-join build does not match schema, keys or null 
equality"
+            );
+        }
+        if let Some(filter) = &join.dynamic_filter {
+            let filter_keys = filter.filter.children();
+            if filter_keys.len() != join.on.len()
+                || filter_keys
+                    .iter()
+                    .zip(&join.on)
+                    .any(|(filter_key, (_, probe_key))| {
+                        filter_key.as_ref() != probe_key.as_ref()
+                    })
+            {
+                return plan_err!(
+                    "Prepared hash-join dynamic filter keys do not match probe 
keys"
+                );
+            }
+        }
+        Ok(())
+    }
+}
+
+impl HashJoinExec {
+    /// Prepare one immutable build using an embedding executor's durable pool.
+    ///
+    /// The supplied stream must own its native buffers independently of 
producer
+    /// task cleanup. This method consumes only that stream, never `self.left`,
+    /// and reserves retained data, hash buckets and row-index chains against
+    /// `pool`. The caller must keep original producer allocations charged 
until
+    /// its stream releases them. `config` controls ordinary perfect-map and
+    /// dynamic-filter choices. UTF-8 and fixed-size binary keys
+    /// retain hash membership only:
+    /// range bounds and IN-list literals would allocate unaccounted key 
copies.
+    ///
+    /// Validates eligibility and the stream schema before polling. On error or
+    /// future cancellation, all work and reservations are dropped; no 
partially
+    /// prepared object is returned. Concurrent preparation/cache publication 
is
+    /// the caller's responsibility. Bounds and membership are prepared once, 
but
+    /// each consuming join publishes them into its own dynamic filter.
+    pub async fn prepare_build(
+        &self,
+        input: SendableRecordBatchStream,
+        pool: Arc<dyn MemoryPool>,
+        config: Arc<ConfigOptions>,
+    ) -> Result<Arc<PreparedHashJoinBuild>> {
+        let keys = prepared_key_indices(self)?;
+        let schema = self.left.schema();
+        if input.schema() != schema {
+            return plan_err!(
+                "Prepared hash-join input schema does not match build schema"
+            );
+        }
+        let byte_keys = keys.iter().any(|&key| {
+            matches!(
+                schema.field(key).data_type(),
+                DataType::Utf8 | DataType::FixedSizeBinary(_)
+            )
+        });
+        // Range accumulation and IN-list publication materialize ScalarValue
+        // copies of byte keys. Hash membership borrows the admitted table 
instead.
+        let config = if byte_keys {
+            let mut config = config.as_ref().clone();
+            config.optimizer.hash_join_inlist_pushdown_max_size = 0;
+            Arc::new(config)
+        } else {
+            config
+        };
+        let metrics_set = ExecutionPlanMetricsSet::new();
+        let metrics = BuildProbeJoinMetrics::new(0, &metrics_set);
+        let count = MetricBuilder::new(&metrics_set)
+            .counter(ARRAY_MAP_CREATED_COUNT_METRIC_NAME, 0);
+        let reservation = 
MemoryConsumer::new("PreparedHashJoinBuild").register(&pool);
+        let data = collect_left_input(
+            self.random_state.random_state().clone(),
+            input,
+            self.on.iter().map(|(left, _)| Arc::clone(left)).collect(),
+            metrics,
+            reservation,
+            false,
+            0,
+            !byte_keys,
+            config,
+            self.null_equality,
+            None,
+            count,
+            true,
+        )
+        .await?;
+        Ok(Arc::new(PreparedHashJoinBuild {
+            build: data.build,
+            keys,
+            null_equality: self.null_equality,
+        }))
+    }
+}
+
+/// Bound copy allocations, including validity, offsets and alignment. Aliased
+/// columns count separately because concatenation materializes each column.
+pub(super) fn prepared_copy_bytes(batch: &RecordBatch) -> Result<usize> {
+    let rows = batch.num_rows();
+    batch.columns().iter().try_fold(0usize, |total, array| {
+        let values = match array.data_type() {
+            DataType::Utf8 => rows
+                .checked_add(1)
+                .and_then(|len| len.checked_mul(4))
+                .and_then(|offsets| 
utf8_value_span(array.as_ref()).checked_add(offsets))
+                // UTF-8 has a third allocation for offsets.
+                .and_then(|bytes| bytes.checked_add(64)),
+            DataType::Null => Some(0),
+            DataType::Boolean => Some(rows.div_ceil(8)),
+            DataType::FixedSizeBinary(width) => usize::try_from(*width)
+                .ok()
+                .and_then(|width| width.checked_mul(rows)),
+            ty => ty
+                .primitive_width()
+                .and_then(|width| width.checked_mul(rows)),
+        };
+        values
+            .and_then(|bytes| bytes.checked_add(rows.div_ceil(8)))
+            .and_then(|bytes| bytes.checked_add(2 * 64))
+            .and_then(|bytes| total.checked_add(bytes))
+            .ok_or_else(|| exec_datafusion_err!("Prepared hash-join copy size 
overflow"))
+    })
+}
+
+/// Include values hidden by nulls but exclude bytes outside a sliced array.
+fn utf8_value_span(array: &dyn Array) -> usize {
+    let offsets = array.as_string::<i32>().value_offsets();
+    (offsets[offsets.len() - 1] - offsets[0]) as usize
+}
+
+/// Add `batch`'s byte-column spans to `totals`, one entry per schema column, 
before
+/// an eventual single-batch concat. Reject offset overflow before allocating
+/// the value buffer; fixed-width columns leave their totals unchanged.
+pub(super) fn check_byte_concat_sizes(
+    batch: &RecordBatch,
+    totals: &mut [usize],
+) -> Result<()> {
+    for (array, total) in batch.columns().iter().zip(totals) {
+        if matches!(array.data_type(), DataType::Utf8) {
+            *total = total.checked_add(utf8_value_span(array.as_ref()))
+                .filter(|&sum| i32::try_from(sum).is_ok())
+                .ok_or_else(|| exec_datafusion_err!(
+                    "Prepared hash-join UTF-8 column exceeds its offset limit; 
a compact build is required"
+                ))?;
+        }
+    }
+    Ok(())
+}
+
+impl HashJoinExecBuilder {
+    /// Attach a fully prepared build to a fresh, compatible join execution.
+    ///
+    /// [`Self::build`] validates compatibility. Attaching resets the build 
future
+    /// and execution metrics. A previously attached task-local dynamic filter
+    /// keeps its expression handle (also referenced by the probe plan), while
+    /// its build-report accumulator is reset. The caller must provide a fresh
+    /// filter expression/probe plan for each independent task.
+    /// Residual filters also remain consumer-local, so compatible INNER joins
+    /// may use different predicates with the same prepared data.
+    /// The resulting join plan retains the build lease. A caller retaining a
+    /// dynamic-filter expression beyond that plan must retain a prepared-build
+    /// lease alongside it, because membership filters can reference build 
data.
+    /// Attach after child-rewriting physical optimizations. The unused left
+    /// subtree is replaced with an empty schema placeholder so plan resets
+    /// preserve it. Probe-only rewrites must retain the attached join's 
`left()`.
+    /// Replacing that child or changing to incompatible join keys fails; other
+    /// incompatible join-mode/type changes fail validation in `build`.
+    pub fn with_prepared_build(mut self, prepared: Arc<PreparedHashJoinBuild>) 
-> Self {
+        // This removes the ignored child's ordering/equivalences and preserves
+        // its identity through plan resets.
+        self.exec.left = 
Arc::new(crate::empty::EmptyExec::new(self.exec.left.schema()));
+        self.reset_prepared_runtime_state();
+        self.exec.prepared_build = Some(prepared);
+        self.preserve_properties = false;
+        self
+    }
+}
+
+/// Validate the narrow prepared-build contract and return ordered build key
+/// indices. Reads schemas/expressions only; invalid columns are rejected 
before
+/// any array access. Matching probe types preserve hashing and equality 
semantics.
+/// INNER joins need no shared build-match bitmap. Residual predicates are
+/// evaluated by each consumer after hash lookup.
+fn prepared_key_indices(join: &HashJoinExec) -> Result<Vec<usize>> {
+    if join.join_type != JoinType::Inner
+        || join.mode != PartitionMode::CollectLeft
+        || join.null_aware
+    {
+        return plan_err!("Prepared hash-join builds require a CollectLeft 
INNER join");
+    }
+    let left = join.left.schema();
+    let right = join.right.schema();
+    if left.fields().iter().any(|field| {
+        let ty = field.data_type();
+        ty.primitive_width().is_none()
+            && !matches!(
+                ty,
+                DataType::Null
+                    | DataType::Boolean
+                    | DataType::FixedSizeBinary(_)
+                    | DataType::Utf8
+            )
+    }) {
+        return plan_err!(
+            "Prepared hash-join builds require fixed-width or UTF-8 build 
columns"
+        );
+    }

Review Comment:
   This allowlist and the match arms in `prepared_copy_bytes` cover exactly the 
same set — I checked each against `DataType::primitive_width`. Nothing enforces 
that, though. Adding `LargeUtf8` here without touching `prepared_copy_bytes` 
would silently undercount memory rather than fail to compile. A cross-reference 
comment in both directions would make the coupling visible.



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