// Copyright (c) 2023-2026 ParadeDB, Inc.
//
// This file is part of ParadeDB - Postgres for Search and Analytics
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
//! DataFusion `ExecutionPlan` implementations for scanning `pg_search` indexes.
//!
//! See the [JoinScan README](../../postgres/customscan/joinscan/README.md) for
//! how `PgSearchScanPlan` integrates with the JoinScan physical plan and
//! dynamic filters.
//!
//! This module provides the `PgSearchScanPlan`, which scans `pg_search` index segments as a
//! single lazily-claimed partition: segments are claimed dynamically from `ParallelScanState`
//! in parallel execution, or chained end-to-end when serial.
use std::pin::Pin;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use arrow_array::RecordBatch;
use arrow_schema::{DataType, SchemaRef, SortOptions};
use datafusion::common::stats::{ColumnStatistics, Precision};
use datafusion::common::{DataFusionError, Result, Statistics};
use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr, PhysicalSortExpr};
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
use datafusion::physical_plan::expressions::{Column, DynamicFilterPhysicalExpr};
use datafusion::physical_plan::filter_pushdown::{
ChildPushdownResult, FilterPushdownPhase, FilterPushdownPropagation, PushedDown,
};
use datafusion::physical_plan::metrics::{
BaselineMetrics, ExecutionPlanMetricsSet, MetricBuilder, MetricsSet, RecordOutput,
};
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
};
use datafusion_distributed::{
DesiredTaskCountEvent, DesiredTaskCountEventResponse, ScaleUpLeafNodeEvent,
ScaleUpLeafNodeEventResponse,
};
use datafusion_proto::physical_plan::{
DefaultPhysicalExtensionCodec, PhysicalPlanDecodeContext, PhysicalProtoConverterExtension,
};
use futures::Stream;
use pgrx::pg_sys;
use tantivy::Score;
use crate::index::fast_fields_helper::FFHelper;
use crate::index::fast_fields_helper::WhichFastField;
use crate::index::mvcc::MvccSatisfies;
use crate::index::reader::index::SearchIndexReader;
use crate::index::stats::segments_for_partition;
use crate::postgres::customscan::explain::ExplainFormat;
use crate::postgres::customscan::parallel::segment_view;
use crate::postgres::heap::VisibilityChecker;
use crate::postgres::options::{SortByDirection, SortByField};
use crate::postgres::rel::PgSearchRelation;
use crate::postgres::ParallelScanState;
use crate::query::SearchQueryInput;
use crate::scan::filter_passthrough_exec::FilterPassthroughExec;
use crate::scan::late_materialization::DeferredField;
use crate::scan::pre_filter::{collect_filters, try_dynamic_filter_pushdown, PreFilter};
use crate::scan::range_partitioning::{RangePartitioning, RangeSplitPoints};
use crate::scan::Scanner;
/// A wrapper that implements Send + Sync unconditionally.
/// UNSAFE: Only use this when you guarantee single-threaded access or manual synchronization.
/// This is safe in pg_search because Postgres extensions run single-threaded.
#[derive(Clone)]
pub(crate) struct UnsafeSendSync(pub T);
unsafe impl Send for UnsafeSendSync {}
unsafe impl Sync for UnsafeSendSync {}
/// Ingredients needed to construct a Scanner for deferred search.
#[derive(Clone)]
pub struct ScannerConfig {
pub which_fast_fields: Vec,
pub heap_relid: u32,
pub batch_size_hint: Option,
/// `need_scores` the index reader was opened with. Carried so a leader-dispatched worker
/// re-opens its reader with the same scoring behavior (the reader itself can't travel).
pub score_needed: bool,
/// Mode of the scan (e.g. Standard vs Tagged with optional lifted local query).
pub scan_mode: crate::scan::ScanMode,
}
/// State for a scan partition.
///
/// Uses `Arc` so the same FFHelper can be shared across multiple partitions.
///
/// Lazily claims segments from `ParallelScanState`. `source_idx = Some(i)` claims from source
/// `i`'s pool for MPP sources; `None` uses the single-counter `checkout_segment_for_source(0)`
/// for basescan and non-MPP parallel joins.
#[derive(Clone)]
pub struct ScanState {
pub source_idx: Option,
pub planner_estimated_rows: u64,
pub scanner_config: ScannerConfig,
pub ffhelper: Arc,
pub visibility: Box,
pub reader: SearchIndexReader,
}
/// Execution state for a single `PgSearchScanPlan`.
///
/// Under distributed execution, a task-specialized variant exposes one local partition.
/// Its `assigned_partition` maps that local partition back to the global partition whose
/// state and range bounds it must consume exactly once.
#[derive(Default, Clone)]
pub enum ExecutionState {
/// In Shared mode, we optionally load-balance via ParallelScanState.
/// The state is consumed at most once by this plan or one of its task variants.
Shared {
parallel_state: Option>,
scan_state: Box>,
},
/// In RangePartitioned mode, we static-partition using RangePartitioning.
/// A task variant maps its sole local partition to one range before consuming the state.
RangePartitioned {
range_boundaries: RangePartitioning,
scan_state: Box>,
},
/// The state has been consumed by a call to execute().
Consumed,
/// Uninitialized placeholder state.
#[default]
Uninitialized,
}
/// A DataFusion `ExecutionPlan` for scanning `pg_search` index segments.
///
/// Before task specialization, this plan's `output_partitioning` declares the planner-selected
/// global partition count. Each specialized variant then exposes one local partition while
/// retaining the global partition count and its `assigned_partition`, so local `execute(0)`
/// consumes exactly the assigned slice of the workload.
pub struct PgSearchScanPlan {
/// State consumed exactly once by this plan or one of its task-specialized clones.
///
/// We use a Mutex to allow taking ownership of the scanner during `execute()`.
/// We wrap the state in `UnsafeSendSync` to satisfy `ExecutionPlan`'s `Send` + `Sync`
/// requirements. This is safe because we are running in a single-threaded
/// environment (Postgres), which also means that the duration for which we
/// hold this Mutex does not impact performance.
state: Mutex,
/// Estimated row count, computed once at construction.
/// Stored separately so `partition_statistics` is deterministic, even after
/// the state has been consumed.
planner_estimated_rows: u64,
/// Number of segments this plan will process, derived at construction time
/// from ParallelScanState or the reader, and kept around for EXPLAIN after
/// the state is consumed.
segment_count: usize,
/// Number of partitions in the scan before task specialization. A specialized variant's
/// `output_partitioning` is always one, so this count is serialized separately and used to
/// rebuild the original range boundaries when the variant is decoded on its worker.
global_partition_count: usize,
properties: Arc,
resolved_query: SearchQueryInput,
/// Dynamic filters pushed down from parent operators (e.g. Top K threshold
/// from SortExec, join-key bounds from HashJoinExec). Each batch produced
/// by the scanner is filtered against all of these expressions so that rows
/// which cannot contribute to the final result are pruned early.
dynamic_filters: Vec>,
/// Metrics for EXPLAIN ANALYZE.
metrics: ExecutionPlanMetricsSet,
deferred_fields: Vec,
/// Shared FFHelper for deferred lookup and deferred visibility.
///
/// A scan may participate in late materialization, deferred visibility, or both.
/// Callers decide whether they should use it by checking the deferred metadata,
/// and cloning the Arc is cheap.
ffhelper: Option>,
pub indexrelid: u32,
pub table_alias: String,
/// The JoinScan source identity when visibility is deferred.
deferred_ctid_plan_position: Option,
/// Sort order preserved across `with_filter_pushdown` rebuilds so the
/// rebuilt plan keeps its equivalence properties.
sort_order: Option,
range_split_points: Option,
/// Global partition selected for a task-specialized variant. When present, this plan
/// exposes one local partition and maps `execute(0)` back to this global partition.
pub(crate) assigned_partition: Option,
pub(crate) scan_mode: crate::scan::ScanMode,
}
impl Clone for PgSearchScanPlan {
fn clone(&self) -> Self {
let state_guard = self.state.lock().unwrap();
let new_state = state_guard.clone();
Self {
state: Mutex::new(new_state),
planner_estimated_rows: self.planner_estimated_rows,
segment_count: self.segment_count,
global_partition_count: self.global_partition_count,
properties: Arc::clone(&self.properties),
resolved_query: self.resolved_query.clone(),
dynamic_filters: self.dynamic_filters.clone(),
metrics: self.metrics.clone(),
deferred_fields: self.deferred_fields.clone(),
ffhelper: self.ffhelper.clone(),
indexrelid: self.indexrelid,
table_alias: self.table_alias.clone(),
deferred_ctid_plan_position: self.deferred_ctid_plan_position,
sort_order: self.sort_order.clone(),
range_split_points: self.range_split_points.clone(),
assigned_partition: self.assigned_partition,
scan_mode: self.scan_mode.clone(),
}
}
}
impl std::fmt::Debug for PgSearchScanPlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PgSearchScanPlan")
.field("properties", &self.properties)
.finish()
}
}
impl PgSearchScanPlan {
/// Creates a new PgSearchScanPlan with pre-opened segments.
///
/// # Arguments
///
/// * `state` - The pre-opened scan state (or None for tests)
/// * `schema` - Arrow schema for the output
/// * `resolved_query` - The filter-combined, param-solved query the readers were opened
/// with. Used for EXPLAIN and shipped on dispatch.
/// * `sort_order` - Optional sort order declaration for equivalence properties
/// * `partition_count` - Planner-selected number of global partitions. Non-range scans cap
/// this at their segment count; range scans may expose more partitions than segments.
#[allow(clippy::too_many_arguments)]
pub fn new(
state: Option,
schema: SchemaRef,
resolved_query: SearchQueryInput,
sort_order: Option<&SortByField>,
deferred_fields: Vec,
ffhelper: Option>,
indexrelid: u32,
deferred_ctid_plan_position: Option,
partition_count: usize,
parallel_state: Option<*mut crate::postgres::ParallelScanState>,
range_split_points: Option,
) -> Self {
let needs_ffhelper = !deferred_fields.is_empty() || deferred_ctid_plan_position.is_some();
if needs_ffhelper && ffhelper.is_none() {
panic!("deferred lookup/visibility requires an FFHelper, but ffhelper is None");
}
// The split points seat only so many partitions; a task past them would be empty.
let partition_count = range_split_points
.as_ref()
.map_or(partition_count, |points| {
points.partitions_for(partition_count)
});
// Output partitioning tells datafusion-distributed how many tasks this leaf can naturally split into.
// If state is None, execute() will return an EmptyStream for this single partition.
let range_boundaries = range_split_points
.as_ref()
.map(|s| s.build(partition_count));
let partitioning =
declared_partitioning(&schema, partition_count, range_boundaries.as_ref());
let eq_properties = build_equivalence_properties(schema, sort_order);
let properties = Arc::new(PlanProperties::new(
eq_properties,
partitioning,
EmissionType::Incremental,
Boundedness::Bounded,
));
let planner_estimated_rows = state
.as_ref()
.map(|s| s.planner_estimated_rows)
.unwrap_or(0);
let segment_count = state
.as_ref()
.map(|s| match parallel_state {
Some(ps) => unsafe { (*ps).source_segment_count(s.source_idx.unwrap_or(0)) },
None => s.reader.segment_ids().len(),
})
.unwrap_or(0);
if range_split_points.is_none() {
// A partition count exceeding the segment count indicates a bug in
// pg_search_scan_desired_task_count, which should cap the tasks
// to the segment count when range partitioning is off.
assert!(
partition_count <= segment_count.max(1),
"partition_count {} exceeds segment_count {}",
partition_count,
segment_count
);
}
let scan_mode = state
.as_ref()
.map(|s| s.scanner_config.scan_mode.clone())
.unwrap_or_else(|| crate::scan::ScanMode::standard(resolved_query.clone()));
let exec_state = match state {
Some(s) => {
if let Some(boundaries) = range_boundaries {
ExecutionState::RangePartitioned {
range_boundaries: boundaries,
scan_state: Box::new(UnsafeSendSync(s)),
}
} else {
ExecutionState::Shared {
parallel_state: parallel_state.map(UnsafeSendSync),
scan_state: Box::new(UnsafeSendSync(s)),
}
}
}
None => ExecutionState::Uninitialized,
};
Self {
state: Mutex::new(exec_state),
planner_estimated_rows,
segment_count,
global_partition_count: partition_count,
properties,
resolved_query,
dynamic_filters: Vec::new(),
metrics: ExecutionPlanMetricsSet::new(),
deferred_fields,
ffhelper,
indexrelid,
table_alias: String::new(),
deferred_ctid_plan_position,
sort_order: sort_order.cloned(),
range_split_points,
assigned_partition: None,
scan_mode,
}
}
pub fn with_table_alias(mut self, table_alias: impl Into) -> Self {
self.table_alias = table_alias.into();
self
}
/// Returns a new copy of this plan resized to support exactly `target_partitions`.
///
/// This allows a `TaskEstimator` or distributed planner to override the natural
/// partition count of this plan when limited cluster resources prevent executing
/// the original quantity.
///
/// - For `Shared` mode, simply modifies the exposed metadata.
/// - For `RangePartitioned` mode, it asks the internal `range_split_points` to
/// down-sample the partitioning bounds so the new partition count has roughly
/// uniformly distributed boundaries. The count never exceeds what the points seat.
pub(crate) fn repartition(&self, target_partitions: usize) -> Result> {
if self.assigned_partition.is_some() {
return Err(DataFusionError::Internal(
"Cannot repartition a task-specialized PgSearchScanPlan".into(),
));
}
let target_partitions = self
.range_split_points
.as_ref()
.map_or(target_partitions, |points| {
points.partitions_for(target_partitions)
});
let state_guard = self
.state
.lock()
.map_err(|e| DataFusionError::Internal(format!("lock PgSearchScanPlan state: {e}")))?;
let new_state = match &*state_guard {
ExecutionState::Shared {
parallel_state,
scan_state,
} => ExecutionState::Shared {
parallel_state: parallel_state.clone(),
scan_state: scan_state.clone(),
},
ExecutionState::RangePartitioned { scan_state, .. } => {
ExecutionState::RangePartitioned {
range_boundaries: self
.range_split_points
.as_ref()
.unwrap()
.build(target_partitions),
scan_state: Box::new(UnsafeSendSync(scan_state.0.clone())),
}
}
_ => {
return Err(DataFusionError::Internal(
"Cannot repartition uninitialized or consumed plan".into(),
))
}
};
let range_boundaries = match &new_state {
ExecutionState::RangePartitioned {
range_boundaries, ..
} => Some(range_boundaries),
_ => None,
};
let partitioning = declared_partitioning(
self.properties.eq_properties.schema(),
target_partitions,
range_boundaries,
);
let new_properties = Arc::new(
PlanProperties::clone(self.properties.as_ref()).with_partitioning(partitioning),
);
Ok(self.with_overrides(
new_state,
new_properties,
self.dynamic_filters.clone(),
target_partitions,
))
}
fn with_overrides(
&self,
state: ExecutionState,
properties: Arc,
dynamic_filters: Vec>,
global_partition_count: usize,
) -> Arc {
Arc::new(Self {
state: Mutex::new(state),
planner_estimated_rows: self.planner_estimated_rows,
segment_count: self.segment_count,
global_partition_count,
properties,
resolved_query: self.resolved_query.clone(),
dynamic_filters,
metrics: self.metrics.clone(),
deferred_fields: self.deferred_fields.clone(),
ffhelper: self.ffhelper.clone(),
indexrelid: self.indexrelid,
table_alias: self.table_alias.clone(),
deferred_ctid_plan_position: self.deferred_ctid_plan_position,
sort_order: self.sort_order.clone(),
range_split_points: self.range_split_points.clone(),
assigned_partition: self.assigned_partition,
scan_mode: self.scan_mode.clone(),
})
}
/// Returns a variant of this plan configured to execute assigned partition `assigned`.
///
/// The variant declares `Partitioning::UnknownPartitioning(1)` so DataFusion treats
/// it as a single-partition plan, while `assigned_partition` records which partition's
/// workload (range bounds or parallel state assignment) this variant executes.
pub(crate) fn with_assigned_partition(&self, assigned: usize) -> Arc {
assert!(
self.assigned_partition.is_none(),
"PgSearchScanPlan is already task-specialized"
);
assert!(
assigned < self.global_partition_count,
"assigned partition {assigned} is outside global partition count {}",
self.global_partition_count
);
let mut variant = self.clone();
variant.assigned_partition = Some(assigned);
let new_properties = Arc::new(
PlanProperties::clone(variant.properties.as_ref())
.with_partitioning(Partitioning::UnknownPartitioning(1)),
);
variant.properties = new_properties;
Arc::new(variant)
}
/// Late-bind the shared `ParallelScanState` into this scan's execution state (#5667).
///
/// Under the plan-first MPP launch the leader builds its plan before the DSM exists, so
/// `Shared` scans start with `parallel_state: None`. Once the DSM is created and populated,
/// the launch stamps the leader's pointer in here — before the first `execute()`, which is
/// the only reader of the field. `RangePartitioned` and `Uninitialized` scans never consult
/// the pointer, so they are left untouched.
pub(crate) fn set_parallel_state(&self, ps: *mut ParallelScanState) {
let mut state_guard = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let ExecutionState::Shared { parallel_state, .. } = &mut *state_guard {
*parallel_state = Some(UnsafeSendSync(ps));
}
}
pub fn has_deferred_fields(&self) -> bool {
!self.deferred_fields.is_empty()
}
pub fn ffhelper(&self) -> Option> {
self.ffhelper.clone()
}
pub fn deferred_ctid_plan_position(&self) -> Option {
self.deferred_ctid_plan_position
}
/// Serialize this scan into a transport-neutral descriptor for leader dispatch.
///
/// Only the recipe and the reader-rebuild inputs travel; the live `ScanState` (tantivy
/// readers, visibility checkers) is process-local and gets rebuilt on the receiving worker
/// from its own `ParallelScanState`. `scan_mode` carries the filter-combined,
/// param-solved query the reader was opened with, so the receiver needs no `ExprContext`.
///
/// Installed dynamic filters travel as proto expression nodes stamped with their
/// `expression_id`; decoding the fragment with a deduplicating proto converter re-shares
/// each filter's inner state with the operator that updates it (see
/// `deserialize_physical_plan_with_runtime`).
pub(crate) fn encode_for_dispatch(
&self,
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result> {
let state_guard = self
.state
.lock()
.map_err(|e| DataFusionError::Internal(format!("lock PgSearchScanPlan state: {e}")))?;
let state = match &*state_guard {
ExecutionState::Shared { scan_state, .. } => scan_state,
ExecutionState::RangePartitioned { scan_state, .. } => scan_state,
_ => {
return Err(DataFusionError::Internal(
"PgSearchScan dispatch: partition already consumed or uninitialized".into(),
));
}
};
let (source_idx, planner_estimated_rows, scanner_config) = (
state.0.source_idx,
state.0.planner_estimated_rows,
state.0.scanner_config.clone(),
);
let schema = self.properties.eq_properties.schema().clone();
let schema_proto: datafusion_proto::protobuf::Schema =
schema.as_ref().try_into().map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: schema encode: {e}"))
})?;
// Dynamic filters self-serialize (columns/literals/comparisons only — no pg_search
// extension exprs), so the default codec suffices.
let codec = DefaultPhysicalExtensionCodec {};
let dynamic_filters = self
.dynamic_filters
.iter()
.map(|f| {
let node = proto_converter.physical_expr_to_proto(f, &codec)?;
Ok(prost::Message::encode_to_vec(&node))
})
.collect::>>()?;
let descriptor = ScanDispatchDescriptor {
schema_proto: prost::Message::encode_to_vec(&schema_proto),
dynamic_filters,
score_needed: scanner_config.score_needed,
sort_order: self.sort_order.clone(),
indexrelid: self.indexrelid,
table_alias: self.table_alias.clone(),
deferred_fields: self.deferred_fields.clone(),
deferred_ctid_plan_position: self.deferred_ctid_plan_position,
which_fast_fields: scanner_config.which_fast_fields,
heap_relid: scanner_config.heap_relid,
batch_size_hint: scanner_config.batch_size_hint,
source_idx,
planner_estimated_rows,
global_partition_count: self.global_partition_count,
range_split_points: self.range_split_points.clone(),
assigned_partition: self.assigned_partition,
scan_mode: scanner_config.scan_mode,
check_visibility: state.0.visibility.checks_visibility(),
};
serde_json::to_vec(&descriptor).map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: serialize: {e}"))
})
}
/// Rebuild a scan from a dispatch descriptor, injecting the receiving worker's runtime
/// state. Mirrors the tail of `PgSearchTableProvider::scan_inner`: open the index reader
/// under the worker's MVCC view, build the fast-field helper + visibility checker, and wrap
/// a single lazy partition that claims segments at runtime from `parallel_state`.
///
/// Dynamic filters decode through `proto_converter` — the fragment-wide deduplicating
/// deserializer — so the rebuilt instances share inner state with the copies decoded
/// inside the operators that update them (hash-join bounds, aggregate group filters).
pub(crate) fn decode_for_dispatch(
buf: &[u8],
parallel_state: Option<*mut ParallelScanState>,
expr_context: Option<*mut pg_sys::ExprContext>,
ctx: &TaskContext,
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result> {
let descriptor: ScanDispatchDescriptor = serde_json::from_slice(buf).map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: deserialize: {e}"))
})?;
let schema_proto = ::decode(
descriptor.schema_proto.as_slice(),
)
.map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: schema decode: {e}"))
})?;
let schema: SchemaRef = Arc::new((&schema_proto).try_into().map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: schema parse: {e}"))
})?);
let codec = DefaultPhysicalExtensionCodec {};
let decode_ctx = PhysicalPlanDecodeContext::new(ctx, &codec);
let dynamic_filters = descriptor
.dynamic_filters
.iter()
.map(|bytes| {
let node =
::decode(
bytes.as_slice(),
)
.map_err(|e| {
DataFusionError::Internal(format!(
"PgSearchScan dispatch: dynamic filter decode: {e}"
))
})?;
proto_converter.proto_to_physical_expr(&node, schema.as_ref(), &decode_ctx)
})
.collect::>>()?;
let index_rel = PgSearchRelation::open(pg_sys::Oid::from(descriptor.indexrelid));
let heap_rel = PgSearchRelation::open(pg_sys::Oid::from(descriptor.heap_relid));
// MVCC view: an MPP source (source_idx Some) reads its per-source frozen segment
// list from `ParallelScanState`; a standard parallel scan (source_idx None) reads
// the worker's full segment list. Mirrors the MVCC dispatch in `scan_inner`.
let mvcc = match (descriptor.source_idx, parallel_state) {
(None, Some(ps)) => MvccSatisfies::ParallelWorker(unsafe { segment_view(ps) }),
(Some(idx), Some(ps)) => {
MvccSatisfies::ParallelWorker(unsafe { (*ps).segment_view_for_source(idx) })
}
(_, None) => MvccSatisfies::Snapshot,
};
let query = descriptor.scan_mode.query().clone();
let needs_tokenizer = descriptor.scan_mode.needs_tokenizer();
let reader = SearchIndexReader::open_with_context(
&index_rel,
query.clone(),
descriptor.score_needed,
mvcc,
expr_context.and_then(std::ptr::NonNull::new),
// TODO: MPP is currently disabled when a scan requires parameter solving: see
// https://github.com/paradedb/paradedb/issues/5445.
None,
needs_tokenizer,
)
.map_err(|e| {
DataFusionError::Internal(format!("PgSearchScan dispatch: open reader: {e}"))
})?;
let ffhelper = Arc::new(FFHelper::with_fields(
&reader,
&descriptor.which_fast_fields,
));
let snapshot = unsafe { pg_sys::GetActiveSnapshot() };
let visibility = VisibilityChecker::with_rel_and_snap(&heap_rel, snapshot)
.with_check_visibility(descriptor.check_visibility);
let scanner_config = ScannerConfig {
which_fast_fields: descriptor.which_fast_fields,
heap_relid: descriptor.heap_relid,
batch_size_hint: descriptor.batch_size_hint,
score_needed: descriptor.score_needed,
scan_mode: descriptor.scan_mode,
};
let state = ScanState {
source_idx: descriptor.source_idx,
planner_estimated_rows: descriptor.planner_estimated_rows,
scanner_config,
ffhelper: Arc::clone(&ffhelper),
visibility: Box::new(visibility) as Box,
reader: reader.clone(),
};
let deferred = descriptor.deferred_fields;
let deferred_ctid_plan_position = descriptor.deferred_ctid_plan_position;
let ffhelper_arg = if deferred.is_empty() && deferred_ctid_plan_position.is_none() {
None
} else if deferred.is_empty() {
Some(ffhelper)
} else {
let width = deferred
.iter()
.map(|d| d.canonical.ff_index + 1)
.max()
.unwrap_or(0);
let mut which: Vec = vec![WhichFastField::Junk(String::new()); width];
for d in &deferred {
if let Some(ref rb) = d.rebuild {
which[d.canonical.ff_index] =
WhichFastField::Named(rb.field_name.clone(), rb.field_type);
}
}
Some(Arc::new(FFHelper::with_fields(&reader, &which)))
};
let mut plan = PgSearchScanPlan::new(
Some(state),
schema,
query,
descriptor.sort_order.as_ref(),
deferred,
ffhelper_arg,
descriptor.indexrelid,
descriptor.deferred_ctid_plan_position,
descriptor.global_partition_count,
parallel_state,
descriptor.range_split_points,
)
.with_table_alias(descriptor.table_alias);
plan.dynamic_filters = dynamic_filters;
let final_plan = if let Some(assigned) = descriptor.assigned_partition {
plan.with_assigned_partition(assigned)
} else {
Arc::new(plan)
};
Ok(final_plan)
}
}
/// Transport-neutral description of a `PgSearchScanPlan` for leader dispatch. Carries the
/// recipe plus the inputs needed to re-open the reader on the receiving worker; the live tantivy
/// state is rebuilt there from the worker's own `ParallelScanState`.
#[derive(serde::Serialize, serde::Deserialize)]
struct ScanDispatchDescriptor {
/// Arrow schema, `datafusion_proto::protobuf::Schema`-encoded (arrow schema isn't serde).
schema_proto: Vec,
/// Installed dynamic filters (join-key bounds, top-k thresholds), each a prost-encoded
/// `PhysicalExprNode`. Their `expr_id` lets a deduplicating decode re-share one instance
/// with the operator that updates it.
dynamic_filters: Vec>,
score_needed: bool,
sort_order: Option,
indexrelid: u32,
#[serde(default)]
table_alias: String,
deferred_fields: Vec,
deferred_ctid_plan_position: Option,
which_fast_fields: Vec,
heap_relid: u32,
batch_size_hint: Option,
/// `Some(i)` for an MPP source (claims from source `i`'s pool); `None` for
/// single-counter checkout (basescan and non-MPP parallel joins). All-sources position.
source_idx: Option,
planner_estimated_rows: u64,
/// Number of partitions before task specialization. This remains global even when an
/// assigned variant advertises one local output partition to DataFusion.
global_partition_count: usize,
range_split_points: Option,
/// Whether the scan checks the heap for snapshot visibility. Decided on the
/// leader; see `PgSearchTableProvider::scan_inner`.
check_visibility: bool,
assigned_partition: Option,
scan_mode: crate::scan::ScanMode,
}
/// The output partitioning a scan declares to DataFusion.
///
/// `Partitioning::Range` is declared only when the boundaries cover exactly
/// `partition_count` partitions and translate faithfully to DataFusion's model.
/// Otherwise `UnknownPartitioning` preserves the requested count.
fn declared_partitioning(
schema: &SchemaRef,
partition_count: usize,
range_boundaries: Option<&RangePartitioning>,
) -> Partitioning {
if partition_count > 1 {
if let Some(boundaries) = range_boundaries {
if boundaries.split_points.len() + 1 == partition_count {
if let Some(partitioning) = boundaries.to_datafusion(schema) {
return partitioning;
}
}
}
}
Partitioning::UnknownPartitioning(partition_count)
}
/// Build `EquivalenceProperties` with the specified sort ordering.
///
/// If `sort_order` is `Some`, the returned properties will declare that the
/// data is sorted by the specified field in the specified direction.
/// If `sort_order` is `None`, returns empty equivalence properties.
fn build_equivalence_properties(
schema: SchemaRef,
sort_order: Option<&SortByField>,
) -> EquivalenceProperties {
let mut eq_properties = EquivalenceProperties::new(schema.clone());
if let Some(sort_field) = sort_order {
// Find the column index for the sort field
let field_name = sort_field.field_name.as_ref();
if let Some((col_idx, _)) = schema.column_with_name(field_name) {
let sort_options = SortOptions {
descending: matches!(sort_field.direction, SortByDirection::Desc),
// Tantivy's sort behavior:
// - ASC: nulls sort first
// - DESC: nulls sort last
nulls_first: matches!(sort_field.direction, SortByDirection::Asc),
};
let sort_expr = PhysicalSortExpr {
expr: Arc::new(Column::new(field_name, col_idx)),
options: sort_options,
};
// Add the ordering to the equivalence properties
eq_properties.add_ordering(std::iter::once(sort_expr));
}
}
eq_properties
}
/// Translate a `tantivy::query::StrategyTag` back into the human-readable
/// strategy name surfaced in `EXPLAIN ANALYZE` output.
fn strategy_name(strategy: tantivy::query::StrategyTag) -> &'static str {
use tantivy::query::StrategyTag;
match strategy {
StrategyTag::None => "none",
StrategyTag::Gallop => "gallop",
StrategyTag::Linear => "linear",
StrategyTag::Bitset => "bitset_from_postings",
StrategyTag::Automaton => "automaton",
StrategyTag::Empty => "empty",
}
}
impl DisplayAs for PgSearchScanPlan {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"PgSearchScan: table={}, segments={}",
self.table_alias, self.segment_count
)?;
if let Some(range_split_points) = &self.range_split_points {
if let Some(assigned) = self.assigned_partition {
let partitioning = range_split_points.build(self.global_partition_count);
let lower = if assigned > 0 && assigned - 1 < partitioning.split_points.len() {
partitioning.split_points[assigned - 1]
.plain_display()
.to_string()
} else {
"-∞".to_string()
};
let upper = if assigned < partitioning.split_points.len() {
partitioning.split_points[assigned]
.plain_display()
.to_string()
} else {
"∞".to_string()
};
write!(
f,
", partition={}[{}..{})",
range_split_points.partition_by.as_ref(),
lower,
upper
)?;
} else {
write!(
f,
", partition_by={}",
range_split_points.partition_by.as_ref()
)?;
}
}
// Only a projected deferred column leaves the scan as ordinals; a string column the
// plan did not defer is decoded here whatever the setting says.
let schema = self.properties.eq_properties.schema();
let scan_fetched: Vec<&str> = self
.deferred_fields
.iter()
.filter(|d| {
d.fetch_at_scan
&& schema
.column_with_name(&d.name)
.is_some_and(|(_, f)| matches!(f.data_type(), DataType::Union(_, _)))
})
.map(|d| d.name.as_str())
.collect();
if !scan_fetched.is_empty() {
write!(f, ", fetch=[{}]", scan_fetched.join(", "))?;
}
if !self.dynamic_filters.is_empty() {
write!(f, ", dynamic_filters={}", self.dynamic_filters.len())?;
}
// A deferred check is already named by the `VisibilityFilterExec` or
// `SegmentedTopKExec` that runs it. A scan that checks itself has no such
// witness, so only that case is marked.
if self.deferred_ctid_plan_position.is_none() {
write!(f, ", visibility=eager")?;
}
match &self.scan_mode {
crate::scan::ScanMode::Standard { .. } => {
write!(f, ", query={}", self.resolved_query.explain_format())?;
}
crate::scan::ScanMode::Tagged {
base_query,
local_queries,
} => {
let has_base = !matches!(**base_query, SearchQueryInput::All);
if has_base {
write!(f, ", query={}", base_query.explain_format())?;
} else if local_queries.is_empty() {
write!(f, ", query=\"all\"")?;
}
for tq in local_queries {
write!(f, ", tag_{}={}", tq.tag_idx.0, tq.query.explain_format())?;
}
}
}
Ok(())
}
}
impl ExecutionPlan for PgSearchScanPlan {
fn name(&self) -> &str {
"PgSearchScan"
}
fn properties(&self) -> &Arc {
&self.properties
}
fn partition_statistics(&self, partition: Option) -> Result> {
let local_partition_count = self.properties.output_partitioning().partition_count();
if let Some(partition) = partition {
if partition >= local_partition_count {
return Err(DataFusionError::Internal(format!(
"Partition {} out of range (have {} partitions)",
partition, local_partition_count
)));
}
}
// `None` means the whole visible plan. For a specialized variant, that whole plan is its
// one assigned global partition; for the original plan it is all global partitions.
let global_partition = self.assigned_partition.or(partition);
let num_rows = match global_partition {
None => Precision::Inexact(self.planner_estimated_rows as usize),
Some(_) => Precision::Inexact(
self.planner_estimated_rows as usize / self.global_partition_count.max(1),
),
};
let column_statistics = self
.properties
.eq_properties
.schema()
.fields
.iter()
.map(|_| ColumnStatistics::default())
.collect();
Ok(Arc::new(Statistics {
num_rows,
total_byte_size: Precision::Absent,
column_statistics,
}))
}
fn children(&self) -> Vec<&Arc> {
vec![]
}
fn apply_expressions(
&self,
f: &mut dyn FnMut(
&Arc,
) -> Result,
) -> Result {
datafusion::physical_plan::apply_expression_roots(&self.dynamic_filters, f)
}
fn with_new_children(
self: Arc,
_children: Vec>,
) -> Result> {
Ok(self)
}
fn execute(
&self,
partition: usize,
_context: Arc,
) -> Result {
#[cfg(debug_assertions)]
if crate::gucs::mpp_test_panic_in_worker() {
pgrx::error!("artificial panic to test worker error propagation");
}
let mut state_guard = self.state.lock().map_err(|e| {
DataFusionError::Internal(format!("Failed to lock PgSearchScanPlan state: {e}"))
})?;
if partition >= self.properties.output_partitioning().partition_count() {
return Err(DataFusionError::Internal(format!(
"Partition {} out of range (have {} partitions)",
partition,
self.properties.output_partitioning().partition_count()
)));
}
let target_partition = self.assigned_partition.unwrap_or(partition);
let state = std::mem::replace(&mut *state_guard, ExecutionState::Consumed);
// Handle state transitions for execution.
let (scan_state, parallel_state, range_boundaries) = match state {
ExecutionState::Shared {
parallel_state,
scan_state,
} => (scan_state.0, parallel_state.map(|p| p.0), None),
ExecutionState::RangePartitioned {
range_boundaries,
scan_state,
} => {
// The plan never declares more partitions than its split points seat.
if target_partition > range_boundaries.split_points.len() {
return Err(DataFusionError::Internal(format!(
"partition {target_partition} lies past the {} split points",
range_boundaries.split_points.len()
)));
}
(scan_state.0, None, Some(range_boundaries))
}
ExecutionState::Consumed => {
return Err(DataFusionError::Internal(format!(
"PgSearchScanPlan partition {target_partition} executed more than once"
)));
}
ExecutionState::Uninitialized => {
let schema = self.properties.eq_properties.schema().clone();
return Ok(Box::pin(unsafe {
UnsafeSendStream::new(futures::stream::empty(), schema)
}));
}
};
let ScanState {
source_idx,
planner_estimated_rows,
scanner_config,
ffhelper,
mut visibility,
reader,
} = scan_state;
let has_dynamic_filters = !self.dynamic_filters.is_empty();
let rows_scanned = has_dynamic_filters
.then(|| MetricBuilder::new(&self.metrics).counter("rows_scanned", target_partition));
let rows_pruned = has_dynamic_filters
.then(|| MetricBuilder::new(&self.metrics).counter("rows_pruned", target_partition));
let baseline_metrics = BaselineMetrics::new(&self.metrics, target_partition);
let plan_metrics = self.metrics.clone();
let schema = self.properties.eq_properties.schema().clone();
let score_column_schema_idx: Option = schema
.column_with_name(&WhichFastField::Score.name())
.map(|(idx, _)| idx);
let dynamic_filters = self.dynamic_filters.clone();
let scan_fetched_fields: Vec = self
.deferred_fields
.iter()
.filter(|d| d.fetch_at_scan)
.map(|d| d.name.clone())
.collect();
let stream_gen = async_stream::try_stream! {
// Create a local copy of the reader if the query changed
let mut reader = match &range_boundaries {
Some(rb) => reader.and_query_input(&rb.partition_bounds(target_partition)),
None => reader,
};
// Optimized Search Integration:
// We initialize the search here, inside the stream, because for HashJoin
// this block is evaluated lazily during the first `poll_next`, which happens
// AFTER the build side has completed and dynamic filters are published.
let mut dynamic_filters = dynamic_filters.clone();
let strategy_sink = Arc::new(AtomicU8::new(0));
let pushed = if !dynamic_filters.is_empty() {
try_dynamic_filter_pushdown(
&mut reader,
&mut dynamic_filters,
Some(strategy_sink.clone()),
)
} else {
false
};
let search_results = if let Some(range_boundaries) = &range_boundaries {
// Range partitioned mode has no shared scan state: each partition searches the
// segments its bounds can reach, per their `.stats`. The query above still
// filters the rows, so a segment kept in doubt costs time, not correctness.
let segment_ids =
segments_for_partition(&reader, range_boundaries, target_partition);
reader.search_segments(segment_ids.into_iter())
} else {
// Standard mode delegates to the parallel state if present
match parallel_state {
Some(ps) => reader.search_lazy(ps, source_idx, planner_estimated_rows),
// No shared scan state even though the plan may carry per-source claim
// markers: the serial fallback (size gate, short launch). The plan was
// built while MPP was eligible but executes as a plain serial scan, so
// search everything. Two takers are legitimate here: the leader's serial
// fallback, and a parallel-safe scan replicated whole into a PG worker
// (each worker runs the full serial plan, so a full search is correct).
// MPP-dispatched fragments cannot land here: their decode always injects
// the state, and the worker entrypoint errors when the DSM lacks it.
None => reader.search(),
}
};
let need_scores = scanner_config
.which_fast_fields
.iter()
.any(|wff| matches!(wff, WhichFastField::Score));
let mut scanner = Scanner::new(
search_results,
scanner_config.batch_size_hint,
scanner_config.which_fast_fields,
scanner_config.heap_relid,
);
if let crate::scan::ScanMode::Tagged { local_queries, .. } = &scanner_config.scan_mode {
for tq in local_queries {
let weight = reader
.compile_match_weight(&tq.query, need_scores)
.map_err(|e| DataFusionError::Internal(format!(
"Failed to compile match weight for tag {}: {e}",
tq.tag_name
)))?;
scanner.add_tagged_query(tq.tag_name.clone(), weight);
}
}
let df_batch_size = crate::gucs::dynamic_filter_batch_size();
if df_batch_size > 0 {
scanner.set_batch_size(df_batch_size as usize);
}
if !scan_fetched_fields.is_empty() {
scanner.fetch_ordinals_in_scan(&scan_fetched_fields);
}
let mut pushdown_metric_recorded = false;
loop {
let timer = baseline_metrics.elapsed_compute().timer();
let (pre_filters, score_threshold) =
build_filters(&dynamic_filters, &schema, score_column_schema_idx);
let pre_filters_wrapper = if pre_filters.is_empty() {
None
} else {
Some(crate::scan::pre_filter::PreFilters {
filters: &pre_filters,
schema: &schema,
})
};
if scanner.can_pushdown_score_threshold() {
scanner.set_score_threshold(score_threshold);
}
let next_batch = scanner.next(
&ffhelper,
&mut visibility,
pre_filters_wrapper.as_ref(),
);
timer.done();
if pushed && !pushdown_metric_recorded {
let tag = strategy_sink.load(Ordering::Relaxed);
if tag > 0 {
let strategy = tantivy::query::StrategyTag::try_from(tag)
.unwrap_or(tantivy::query::StrategyTag::None);
let metric_name = if matches!(strategy, tantivy::query::StrategyTag::None) {
"dynamic_filter_pushdown".to_string()
} else {
format!("dynamic_filter_pushdown_{}", strategy_name(strategy))
};
MetricBuilder::new(&plan_metrics)
.counter(metric_name, target_partition)
.add(1);
pushdown_metric_recorded = true;
}
}
match next_batch {
Some(batch) => {
let record_batch = batch.to_record_batch(&schema);
yield record_batch.record_output(&baseline_metrics);
}
None => {
if pushed && !pushdown_metric_recorded {
let tag = strategy_sink.load(Ordering::Relaxed);
let strategy = tantivy::query::StrategyTag::try_from(tag)
.unwrap_or(tantivy::query::StrategyTag::None);
let metric_name = if matches!(strategy, tantivy::query::StrategyTag::None) {
"dynamic_filter_pushdown".to_string()
} else {
format!("dynamic_filter_pushdown_{}", strategy_name(strategy))
};
MetricBuilder::new(&plan_metrics)
.counter(metric_name, target_partition)
.add(1);
}
// Flush pre-materialization filter stats from Scanner.
if let Some(ref counter) = rows_scanned {
counter.add(scanner.pre_filter_rows_scanned);
}
if let Some(ref counter) = rows_pruned {
counter.add(scanner.pre_filter_rows_pruned);
}
break;
}
}
}
baseline_metrics.done();
};
// SAFETY: pg_search operates in a single-threaded Tokio executor within Postgres,
// so it is safe to wrap !Send types for use within DataFusion.
let stream = unsafe {
UnsafeSendStream::new(stream_gen, self.properties.eq_properties.schema().clone())
};
Ok(Box::pin(stream))
}
fn metrics(&self) -> Option {
Some(self.metrics.clone_inner())
}
fn handle_child_pushdown_result(
&self,
phase: FilterPushdownPhase,
child_pushdown_result: ChildPushdownResult,
_config: &datafusion::common::config::ConfigOptions,
) -> Result>> {
// Only handle dynamic filters in the Post phase (Top K pushdown happens here).
if !matches!(phase, FilterPushdownPhase::Post) {
return Ok(FilterPushdownPropagation::if_all(child_pushdown_result));
}
// Collect all DynamicFilterPhysicalExpr instances from the parent filters.
// Multiple sources may push dynamic filters (e.g. Top K from SortExec,
// join-key bounds from HashJoinExec). We accept and apply all of them.
//
// The pushdown pass can potentially run more than once. Producers assume
// pushed-down filters remain installed between passes and may not re-push
// already-pushed filters on subsequent passes. To handle this, we merge and
// dedupe the filter list on each pass.
//
// Dedupe by `expression_id`, not pointer identity: remapping a filter's
// columns on its way down the tree (`DynamicFilterPhysicalExpr::
// with_new_children`) mints a fresh wrapper per pass, but wrappers of the
// same logical filter share an id. On a match, keep the incoming wrapper —
// its column remap reflects the current tree shape.
let mut dynamic_filters = self.dynamic_filters.clone();
let mut filters = Vec::with_capacity(child_pushdown_result.parent_filters.len());
let mut saw_dynamic = false;
let mut changed = false;
for filter_result in &child_pushdown_result.parent_filters {
if filter_result.filter.is::() {
saw_dynamic = true;
let incoming = &filter_result.filter;
let id = incoming.expression_id();
match dynamic_filters.iter_mut().find(|f| f.expression_id() == id) {
Some(slot) if !Arc::ptr_eq(incoming, slot) => {
*slot = Arc::clone(incoming);
changed = true;
}
Some(_) => {}
None => {
dynamic_filters.push(Arc::clone(incoming));
changed = true;
}
};
filters.push(PushedDown::Yes);
} else {
filters.push(filter_result.any());
}
}
if changed {
// Transfer state from the old plan to the new one.
let state = std::mem::take(&mut *self.state.lock().map_err(|e| {
DataFusionError::Internal(format!(
"Failed to lock PgSearchScanPlan state during filter pushdown: {e}"
))
})?);
let new_plan = self.with_overrides(
state,
self.properties.clone(),
dynamic_filters,
self.global_partition_count,
);
Ok(
FilterPushdownPropagation::with_parent_pushdown_result(filters)
.with_updated_node(new_plan as Arc),
)
} else if saw_dynamic {
// Every delivered dynamic filter was already installed by an earlier
// pass; acknowledge them without rebuilding the node.
Ok(FilterPushdownPropagation::with_parent_pushdown_result(
filters,
))
} else {
Ok(FilterPushdownPropagation::if_all(child_pushdown_result))
}
}
}
/// Evaluate the current dynamic filter expressions and convert them into
/// [`PreFilter`]s that the `Scanner` can apply before column materialization.
///
/// While doing that, we also attempt to extract a top-k score threshold if one exists.
/// We process the threshold-containing expression as we do the rest of the expressions.
/// The threshold-containing expression may be top-level, so we need to allow for
/// the rest of the expression to be applied.
///
/// This is called on every `poll_next` (or loop iteration) so that tightening thresholds (e.g.
/// from Top K) are picked up immediately.
///
/// Only filter predicates that can be lowered to fast-field or term-ordinal
/// comparisons are retained. Anything else (unsupported types, non-comparison
/// operators) is silently dropped — the parent operator is still responsible
/// for enforcing the full predicate, so correctness is not affected.
fn build_filters(
dynamic_filters: &[Arc],
schema: &SchemaRef,
score_col_schema_idx: Option,
) -> (Vec, Option) {
let mut filters = Vec::new();
let mut score_threshold = None;
for df in dynamic_filters {
if let Some(dynamic) = df.downcast_ref::() {
if let Ok(current_expr) = dynamic.current() {
collect_filters(
¤t_expr,
schema,
&mut filters,
score_col_schema_idx,
&mut score_threshold,
);
}
} else {
collect_filters(
df,
schema,
&mut filters,
score_col_schema_idx,
&mut score_threshold,
);
}
}
(filters, score_threshold)
}
/// A wrapper that unsafely implements Send for a Stream.
///
/// This is used to wrap `ScanStream` which is !Send because it contains Tantivy and Postgres
/// state that is not Send. This is safe because pg_search operates in a single-threaded
/// Tokio executor within Postgres, and these objects will never cross thread boundaries.
pub(crate) struct UnsafeSendStream {
stream: T,
schema: SchemaRef,
}
impl UnsafeSendStream {
pub(crate) unsafe fn new(stream: T, schema: SchemaRef) -> Self {
Self { stream, schema }
}
}
unsafe impl Send for UnsafeSendStream {}
impl Stream for UnsafeSendStream {
type Item = T::Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll