Skip to content

Commit 457fc53

Browse files
authored
feat: add datafusion.execution.enable_file_stream_work_stealing config (#23294)
## Which issue does this PR close? - Closes #23293. ## Rationale for this change `FileStream` sibling work-stealing (`WorkSource::Shared`, added in #21351 and extended by #23285) 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? - 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`. - Turn the previously `#[ignore]`d reproduction test into a passing regression test that drives only partition 0 (as an isolated task does) and asserts both behaviors: with the default (stealing on) partition 0 also reads partition 1's file, and with the flag off it reads only its own. ## Are these changes tested? Yes. `isolated_partition_respects_work_stealing_config` in `datafusion/datasource/src/file_stream/mod.rs` covers both the default (shared-queue) behavior and the flag-off behavior. The existing sibling work-stealing tests continue to pass with the default. `information_schema` sqllogictests pass with the new setting listed. ## 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 3496b9e commit 457fc53

6 files changed

Lines changed: 96 additions & 9 deletions

File tree

datafusion/common/src/config.rs

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

906+
/// When `true` (the default), DataFusion's built-in file scans
907+
/// dynamically rebalance files across partitions at query execution
908+
/// time: a partition that goes idle reads files (or byte-range morsels)
909+
/// originally assigned to a sibling partition, which keeps all
910+
/// partitions busy in a single process.
911+
///
912+
/// Executors that depend on the plan-time partition assignment — such as
913+
/// Ballista and datafusion-distributed, which run each partition as an
914+
/// isolated task and never poll the siblings — should set this to
915+
/// `false` so each partition reads only its own file group and no
916+
/// runtime reassignment occurs.
917+
pub enable_file_stream_work_stealing: bool, default = true
918+
906919
/// Aggregation ratio (number of distinct groups / number of input rows)
907920
/// threshold for skipping partial aggregation. If the value is greater
908921
/// then partial aggregation will skip aggregation for further input

datafusion/datasource/src/file_scan_config/mod.rs

Lines changed: 15 additions & 3 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,
@@ -1124,12 +1130,18 @@ impl DataSource for FileScanConfig {
11241130
/// during one execution.
11251131
///
11261132
/// This returns `None` when sibling streams must not share work, such as
1127-
/// when file order must be preserved or the file groups define the output
1128-
/// partitioning needed for the rest of the plan
1129-
fn create_sibling_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
1133+
/// when file order must be preserved, the file groups define the output
1134+
/// partitioning needed for the rest of the plan, or work stealing is
1135+
/// disabled via
1136+
/// `datafusion.execution.enable_file_stream_work_stealing`.
1137+
fn create_sibling_state(
1138+
&self,
1139+
config: &ConfigOptions,
1140+
) -> Option<Arc<dyn Any + Send + Sync>> {
11301141
if self.preserve_order
11311142
|| self.output_partitioning.is_some()
11321143
|| self.partitioned_by_file_group
1144+
|| !config.execution.enable_file_stream_work_stealing
11331145
{
11341146
return None;
11351147
}

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
@@ -244,9 +244,16 @@ pub trait DataSource: Any + Send + Sync + Debug {
244244
/// Create per execution state to share across sibling instances of this
245245
/// data source during one execution.
246246
///
247+
/// `config` is the session configuration, so implementations can honor
248+
/// options that disable sibling sharing (returning `None`) for consumers
249+
/// that cannot poll all partitions in one process.
250+
///
247251
/// Returns `None` (the default) if this data source has
248252
/// no sibling-shared execution state.
249-
fn create_sibling_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
253+
fn create_sibling_state(
254+
&self,
255+
_config: &ConfigOptions,
256+
) -> Option<Arc<dyn Any + Send + Sync>> {
250257
None
251258
}
252259

@@ -392,7 +399,10 @@ impl ExecutionPlan for DataSourceExec {
392399
) -> Result<SendableRecordBatchStream> {
393400
let shared_state = self
394401
.execution_state
395-
.get_or_init(|| self.data_source.create_sibling_state())
402+
.get_or_init(|| {
403+
self.data_source
404+
.create_sibling_state(context.session_config().options())
405+
})
396406
.clone();
397407
let args = OpenArgs::new(partition, Arc::clone(&context))
398408
.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_migration_aggregate true
222223
datafusion.execution.enable_recursive_ctes true
223224
datafusion.execution.enforce_batch_size_in_joins false
@@ -375,6 +376,7 @@ datafusion.execution.batch_size 8192 Default batch size while creating new batch
375376
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
376377
datafusion.execution.collect_statistics true Should DataFusion collect statistics when first creating a table. Has no effect after the table is created. Defaults to true.
377378
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.
379+
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.
378380
datafusion.execution.enable_migration_aggregate true Temporary switch for aggregate stream implementations that are being migrated from `GroupedHashAggregateStream`. When set to true, DataFusion tries the migrated implementations when their preconditions are satisfied. When set to false, grouped aggregation falls back to `GroupedHashAggregateStream`. This option will be removed after the migration is finished. See <https://github.com/apache/datafusion/issues/22710> for details.
379381
datafusion.execution.enable_recursive_ctes true Should DataFusion support recursive CTEs
380382
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.

0 commit comments

Comments
 (0)