Skip to content

Commit 209e49e

Browse files
authored
[branch-54] Add datafusion.execution.enable_file_stream_work_stealing config (#23296)
## Which issue does this PR close? - Backport of #23294 to `branch-54`. - Closes #23293 for the 54 release line. ## Rationale for this change `FileStream` sibling work-stealing (`WorkSource::Shared`) seeds one shared work queue from every file group and lets whichever output partition goes idle first steal the next unopened file (or byte-range morsel). This assumes all output partitions of a scan are polled concurrently in one process. Executors that run each output partition as an isolated task in a separate process — Ballista and datafusion-distributed — never poll the sibling partitions. The single polled partition drains the whole shared queue and reads files belonging to other partitions, so every isolated task reads the entire input and the scan output is inflated by the partition count. This is a correctness bug for those executors, not just a performance one. The existing escape hatches (`preserve_order`, `partitioned_by_file_group`) are plan-level flags on `FileScanConfig`, not something a distributed executor can set centrally through the session config, and a plain repartitioned scan does not set `partitioned_by_file_group`. There is no session-level off switch, unlike `datafusion.optimizer.enable_dynamic_filter_pushdown`, which exists precisely so consumers that cannot support runtime cross-partition state can disable it. ## What changes are included in this PR? This is a backport of #23294. The changes are identical in intent; two conflicts were resolved for `branch-54`, which does not yet carry the `output_partitioning` field on `FileScanConfig` or the `enable_migration_aggregate` config that exist on `main`: - Add `datafusion.execution.enable_file_stream_work_stealing` (default `true`). When `false`, `FileScanConfig::create_sibling_state` returns `None`, so each partition falls back to `WorkSource::Local` and reads only its own file group. - Thread `&ConfigOptions` into `DataSource::create_sibling_state` so the flag is read from the session config at `execute` time. As a session config value it round-trips through `datafusion-proto` with no proto schema change. - Regenerate `configs.md` and add the setting to `information_schema.slt`. - Add the regression test `morsel_disabled_work_stealing_keeps_files_local` using the file's standard morsel snapshot harness. ## Are these changes tested? Yes. The `file_stream` test suite (including the new `morsel_disabled_work_stealing_keeps_files_local`) passes, and `cargo clippy` is clean. The existing sibling work-stealing tests continue to pass with the default. ## Are there any user-facing changes? A new session config, `datafusion.execution.enable_file_stream_work_stealing` (default `true`), so existing behavior is unchanged. `DataSource::create_sibling_state` gains a `&ConfigOptions` parameter (an API change for anyone implementing the `DataSource` trait directly).
1 parent 60bc550 commit 209e49e

6 files changed

Lines changed: 99 additions & 10 deletions

File tree

datafusion/common/src/config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,19 @@ config_namespace! {
639639
/// Should DataFusion keep the columns used for partition_by in the output RecordBatches
640640
pub keep_partition_by_columns: bool, default = false
641641

642+
/// When `true` (the default), DataFusion's built-in file scans
643+
/// dynamically rebalance files across partitions at query execution
644+
/// time: a partition that goes idle reads files (or byte-range morsels)
645+
/// originally assigned to a sibling partition, which keeps all
646+
/// partitions busy in a single process.
647+
///
648+
/// Executors that depend on the plan-time partition assignment — such as
649+
/// Ballista and datafusion-distributed, which run each partition as an
650+
/// isolated task and never poll the siblings — should set this to
651+
/// `false` so each partition reads only its own file group and no
652+
/// runtime reassignment occurs.
653+
pub enable_file_stream_work_stealing: bool, default = true
654+
642655
/// Aggregation ratio (number of distinct groups / number of input rows)
643656
/// threshold for skipping partial aggregation. If the value is greater
644657
/// then partial aggregation will skip aggregation for further input

datafusion/datasource/src/file_scan_config/mod.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ pub struct FileScanConfig {
159159
/// DataFusion may attempt to read each partition of files
160160
/// concurrently, however files *within* a partition will be read
161161
/// sequentially, one after the next.
162+
///
163+
/// Note that when `datafusion.execution.enable_file_stream_work_stealing`
164+
/// is enabled (the default), files may be reassigned to a different
165+
/// partition at runtime unless `preserve_order` or
166+
/// `partitioned_by_file_group` is set, so a file is not guaranteed to be
167+
/// read by the partition it is grouped under here.
162168
pub file_groups: Vec<FileGroup>,
163169
/// Table constraints
164170
pub constraints: Constraints,
@@ -1038,10 +1044,18 @@ impl DataSource for FileScanConfig {
10381044
/// during one execution.
10391045
///
10401046
/// This returns `None` when sibling streams must not share work, such as
1041-
/// when file order must be preserved or the file groups define the output
1042-
/// partitioning needed for the rest of the plan
1043-
fn create_sibling_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
1044-
if self.preserve_order || self.partitioned_by_file_group {
1047+
/// when file order must be preserved, the file groups define the output
1048+
/// partitioning needed for the rest of the plan, or work stealing is
1049+
/// disabled via
1050+
/// `datafusion.execution.enable_file_stream_work_stealing`.
1051+
fn create_sibling_state(
1052+
&self,
1053+
config: &ConfigOptions,
1054+
) -> Option<Arc<dyn Any + Send + Sync>> {
1055+
if self.preserve_order
1056+
|| self.partitioned_by_file_group
1057+
|| !config.execution.enable_file_stream_work_stealing
1058+
{
10451059
return None;
10461060
}
10471061

datafusion/datasource/src/file_stream/mod.rs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ mod tests {
182182
use arrow::array::{AsArray, RecordBatch};
183183
use arrow::datatypes::{DataType, Field, Int32Type, Schema};
184184
use datafusion_common::DataFusionError;
185+
use datafusion_common::config::ConfigOptions;
185186
use datafusion_common::error::Result;
186187
use datafusion_execution::object_store::ObjectStoreUrl;
187188
use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
@@ -1131,6 +1132,40 @@ mod tests {
11311132
Ok(())
11321133
}
11331134

1135+
/// Verifies that disabling `enable_file_stream_work_stealing` keeps each
1136+
/// stream's files local, so a sibling cannot steal them at runtime.
1137+
///
1138+
/// Covers <https://github.com/apache/datafusion/issues/23293>: executors
1139+
/// that run each output partition as an isolated task in a separate process
1140+
/// (Ballista, datafusion-distributed) poll only their own partition, so the
1141+
/// shared work queue would let that one partition drain files belonging to
1142+
/// its siblings. Disabling the flag falls back to per-partition file groups.
1143+
#[tokio::test]
1144+
async fn morsel_disabled_work_stealing_keeps_files_local() -> Result<()> {
1145+
// same fixture as `morsel_shared_files_can_be_stolen`, but with work
1146+
// stealing disabled via config
1147+
let test = two_partition_morsel_test()
1148+
.with_enable_file_stream_work_stealing(false)
1149+
.with_file_stream_events(false);
1150+
1151+
// Even though Partition 1 is polled first, it cannot steal the three
1152+
// files assigned to Partition 0; each partition reads only its own.
1153+
insta::assert_snapshot!(test.run().await.unwrap(), @r"
1154+
----- Partition 0 -----
1155+
Batch: 101
1156+
Batch: 102
1157+
Batch: 103
1158+
Done
1159+
----- Partition 1 -----
1160+
Batch: 201
1161+
Done
1162+
----- File Stream Events -----
1163+
(omitted due to with_file_stream_events(false))
1164+
");
1165+
1166+
Ok(())
1167+
}
1168+
11341169
/// Verifies that an empty sibling can immediately steal shared files when
11351170
/// it is polled before the stream that originally owned them.
11361171
#[tokio::test]
@@ -1216,7 +1251,7 @@ mod tests {
12161251
let unlimited_config = test.test_config();
12171252
let limited_config = test.clone().with_limit(1).test_config();
12181253
let shared_work_source = limited_config
1219-
.create_sibling_state()
1254+
.create_sibling_state(&ConfigOptions::default())
12201255
.and_then(|state| state.as_ref().downcast_ref::<SharedWorkSource>().cloned())
12211256
.expect("shared work source");
12221257
let limited_metrics = ExecutionPlanMetricsSet::new();
@@ -1332,6 +1367,7 @@ mod tests {
13321367
partition_files: BTreeMap<PartitionId, Vec<String>>,
13331368
preserve_order: bool,
13341369
partitioned_by_file_group: bool,
1370+
enable_file_stream_work_stealing: bool,
13351371
file_stream_events: bool,
13361372
build_streams_on_first_read: bool,
13371373
reads: Vec<PartitionId>,
@@ -1346,6 +1382,7 @@ mod tests {
13461382
partition_files: BTreeMap::new(),
13471383
preserve_order: false,
13481384
partitioned_by_file_group: false,
1385+
enable_file_stream_work_stealing: true,
13491386
file_stream_events: true,
13501387
build_streams_on_first_read: false,
13511388
reads: vec![],
@@ -1391,6 +1428,14 @@ mod tests {
13911428
self
13921429
}
13931430

1431+
/// Sets `datafusion.execution.enable_file_stream_work_stealing`. When
1432+
/// disabled, each stream keeps its own files local instead of sharing a
1433+
/// work queue with its siblings.
1434+
fn with_enable_file_stream_work_stealing(mut self, enable: bool) -> Self {
1435+
self.enable_file_stream_work_stealing = enable;
1436+
self
1437+
}
1438+
13941439
/// Controls whether scheduler events are included in the snapshot.
13951440
///
13961441
/// When disabled, `run()` still includes the event section header but
@@ -1468,9 +1513,13 @@ mod tests {
14681513
// `FileStream`s directly, bypassing `DataSourceExec`, so they must
14691514
// perform the same setup explicitly when exercising sibling-stream
14701515
// work stealing.
1471-
let shared_work_source = config.create_sibling_state().and_then(|state| {
1472-
state.as_ref().downcast_ref::<SharedWorkSource>().cloned()
1473-
});
1516+
let mut options = ConfigOptions::default();
1517+
options.execution.enable_file_stream_work_stealing =
1518+
self.enable_file_stream_work_stealing;
1519+
let shared_work_source =
1520+
config.create_sibling_state(&options).and_then(|state| {
1521+
state.as_ref().downcast_ref::<SharedWorkSource>().cloned()
1522+
});
14741523
if !self.build_streams_on_first_read {
14751524
for partition in build_order {
14761525
let stream = FileStreamBuilder::new(&config)

datafusion/datasource/src/source.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,16 @@ pub trait DataSource: Any + Send + Sync + Debug {
243243
/// Create per execution state to share across sibling instances of this
244244
/// data source during one execution.
245245
///
246+
/// `config` is the session configuration, so implementations can honor
247+
/// options that disable sibling sharing (returning `None`) for consumers
248+
/// that cannot poll all partitions in one process.
249+
///
246250
/// Returns `None` (the default) if this data source has
247251
/// no sibling-shared execution state.
248-
fn create_sibling_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
252+
fn create_sibling_state(
253+
&self,
254+
_config: &ConfigOptions,
255+
) -> Option<Arc<dyn Any + Send + Sync>> {
249256
None
250257
}
251258

@@ -391,7 +398,10 @@ impl ExecutionPlan for DataSourceExec {
391398
) -> Result<SendableRecordBatchStream> {
392399
let shared_state = self
393400
.execution_state
394-
.get_or_init(|| self.data_source.create_sibling_state())
401+
.get_or_init(|| {
402+
self.data_source
403+
.create_sibling_state(context.session_config().options())
404+
})
395405
.clone();
396406
let args = OpenArgs::new(partition, Arc::clone(&context))
397407
.with_shared_state(shared_state);

datafusion/sqllogictest/test_files/information_schema.slt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ datafusion.execution.batch_size 8192
218218
datafusion.execution.coalesce_batches true
219219
datafusion.execution.collect_statistics true
220220
datafusion.execution.enable_ansi_mode false
221+
datafusion.execution.enable_file_stream_work_stealing true
221222
datafusion.execution.enable_recursive_ctes true
222223
datafusion.execution.enforce_batch_size_in_joins false
223224
datafusion.execution.hash_join_buffering_capacity 0
@@ -372,6 +373,7 @@ datafusion.execution.batch_size 8192 Default batch size while creating new batch
372373
datafusion.execution.coalesce_batches true When set to true, record batches will be examined between each operator and small batches will be coalesced into larger batches. This is helpful when there are highly selective filters or joins that could produce tiny output batches. The target batch size is determined by the configuration setting
373374
datafusion.execution.collect_statistics true Should DataFusion collect statistics when first creating a table. Has no effect after the table is created. Applies to the default `ListingTableProvider` in DataFusion. Defaults to true.
374375
datafusion.execution.enable_ansi_mode false Whether to enable ANSI SQL mode. The flag is experimental and relevant only for DataFusion Spark built-in functions When `enable_ansi_mode` is set to `true`, the query engine follows ANSI SQL semantics for expressions, casting, and error handling. This means: - **Strict type coercion rules:** implicit casts between incompatible types are disallowed. - **Standard SQL arithmetic behavior:** operations such as division by zero, numeric overflow, or invalid casts raise runtime errors rather than returning `NULL` or adjusted values. - **Consistent ANSI behavior** for string concatenation, comparisons, and `NULL` handling. When `enable_ansi_mode` is `false` (the default), the engine uses a more permissive, non-ANSI mode designed for user convenience and backward compatibility. In this mode: - Implicit casts between types are allowed (e.g., string to integer when possible). - Arithmetic operations are more lenient — for example, `abs()` on the minimum representable integer value returns the input value instead of raising overflow. - Division by zero or invalid casts may return `NULL` instead of failing. # Default `false` — ANSI SQL mode is disabled by default.
376+
datafusion.execution.enable_file_stream_work_stealing true When `true` (the default), DataFusion's built-in file scans dynamically rebalance files across partitions at query execution time: a partition that goes idle reads files (or byte-range morsels) originally assigned to a sibling partition, which keeps all partitions busy in a single process. Executors that depend on the plan-time partition assignment — such as Ballista and datafusion-distributed, which run each partition as an isolated task and never poll the siblings — should set this to `false` so each partition reads only its own file group and no runtime reassignment occurs.
375377
datafusion.execution.enable_recursive_ctes true Should DataFusion support recursive CTEs
376378
datafusion.execution.enforce_batch_size_in_joins false Should DataFusion enforce batch size in joins or not. By default, DataFusion will not enforce batch size in joins. Enforcing batch size in joins can reduce memory usage when joining large tables with a highly-selective join filter, but is also slightly slower.
377379
datafusion.execution.hash_join_buffering_capacity 0 How many bytes to buffer in the probe side of hash joins while the build side is concurrently being built. Without this, hash joins will wait until the full materialization of the build side before polling the probe side. This is useful in scenarios where the query is not completely CPU bounded, allowing to do some early work concurrently and reducing the latency of the query. Note that when hash join buffering is enabled, the probe side will start eagerly polling data, not giving time for the producer side of dynamic filters to produce any meaningful predicate. Queries with dynamic filters might see performance degradation. Disabled by default, set to a number greater than 0 for enabling it.

0 commit comments

Comments
 (0)