Skip to content

Commit 551c592

Browse files
authored
Add ObjectStore-backed TempFileFactor / spill example (#23170)
## Which issue does this PR close? - Related to #21882 (comment). ## Rationale for this change PR #21882 adds custom spill file support. Having an example showing how a downstream application can use this new API will help make sure the API is good enough for our needs ## What changes are included in this PR? This PR adds a small example showing how users can back spill files with an `ObjectStore`, using a local object store for a runnable example while keeping the implementation applicable to remote stores. ## Are these changes tested? Yes by CI ## Are there any user-facing changes? Yes. This adds a new example for configuring ObjectStore-backed spill files.
1 parent 88365dd commit 551c592

5 files changed

Lines changed: 304 additions & 2 deletions

File tree

datafusion-examples/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ futures = { workspace = true }
6060
insta = { workspace = true }
6161
log = { workspace = true }
6262
mimalloc = { version = "0.1", default-features = false }
63-
object_store = { workspace = true, features = ["aws", "http"] }
63+
object_store = { workspace = true, features = ["aws", "fs", "http"] }
6464
prost = { workspace = true }
6565
rand = { workspace = true }
6666
serde = { version = "1", features = ["derive"] }

datafusion-examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ cargo run --example dataframe -- dataframe
9393
| catalog | [`data_io/catalog.rs`](examples/data_io/catalog.rs) | Register tables into a custom catalog |
9494
| in_memory_object_store | [`data_io/in_memory_object_store.rs`](examples/data_io/in_memory_object_store.rs) | Read CSV from an in-memory object store (pattern applies to JSON/Parquet) |
9595
| json_shredding | [`data_io/json_shredding.rs`](examples/data_io/json_shredding.rs) | Implement filter rewriting for JSON shredding |
96+
| object_store_spill | [`data_io/object_store_spill.rs`](examples/data_io/object_store_spill.rs) | Use ObjectStore-backed spill files |
9697
| parquet_adv_idx | [`data_io/parquet_advanced_index.rs`](examples/data_io/parquet_advanced_index.rs) | Create a secondary index across multiple parquet files |
9798
| parquet_emb_idx | [`data_io/parquet_embedded_index.rs`](examples/data_io/parquet_embedded_index.rs) | Store a custom index inside Parquet files |
9899
| parquet_enc | [`data_io/parquet_encrypted.rs`](examples/data_io/parquet_encrypted.rs) | Read & write encrypted Parquet files |

datafusion-examples/examples/data_io/main.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
//!
2222
//! ## Usage
2323
//! ```bash
24-
//! cargo run --example data_io -- [all|catalog|in_memory_object_store|json_shredding|parquet_adv_idx|parquet_emb_idx|parquet_enc_with_kms|parquet_enc|parquet_exec_visitor|parquet_idx|query_http_csv|remote_catalog]
24+
//! cargo run --example data_io -- [all|catalog|in_memory_object_store|json_shredding|object_store_spill|parquet_adv_idx|parquet_emb_idx|parquet_enc_with_kms|parquet_enc|parquet_exec_visitor|parquet_idx|query_http_csv|remote_catalog]
2525
//! ```
2626
//!
2727
//! Each subcommand runs a corresponding example:
@@ -36,6 +36,9 @@
3636
//! - `json_shredding`
3737
//! (file: json_shredding.rs, desc: Implement filter rewriting for JSON shredding)
3838
//!
39+
//! - `object_store_spill`
40+
//! (file: object_store_spill.rs, desc: Use ObjectStore-backed spill files)
41+
//!
3942
//! - `parquet_adv_idx`
4043
//! (file: parquet_advanced_index.rs, desc: Create a secondary index across multiple parquet files)
4144
//!
@@ -66,6 +69,7 @@
6669
mod catalog;
6770
mod in_memory_object_store;
6871
mod json_shredding;
72+
mod object_store_spill;
6973
mod parquet_advanced_index;
7074
mod parquet_embedded_index;
7175
mod parquet_encrypted;
@@ -87,6 +91,7 @@ enum ExampleKind {
8791
Catalog,
8892
InMemoryObjectStore,
8993
JsonShredding,
94+
ObjectStoreSpill,
9095
ParquetAdvIdx,
9196
ParquetEmbIdx,
9297
ParquetEnc,
@@ -118,6 +123,9 @@ impl ExampleKind {
118123
in_memory_object_store::in_memory_object_store().await?
119124
}
120125
ExampleKind::JsonShredding => json_shredding::json_shredding().await?,
126+
ExampleKind::ObjectStoreSpill => {
127+
object_store_spill::object_store_spill().await?
128+
}
121129
ExampleKind::ParquetAdvIdx => {
122130
parquet_advanced_index::parquet_advanced_index().await?
123131
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! See `main.rs` for how to run it.
19+
//!
20+
//! [`object_store_spill`] demonstrates how to use the [`TempFileFactory`] API to configure
21+
//! DataFusion to spill intermediate results to remote storage when it exceeds
22+
//! the configured memory limits.
23+
//!
24+
//! See [`datafusion::execution::memory_pool`] for more information on how
25+
//! DataFusion decides when operators should spill, and [`SpillFile`] for the
26+
//! spill file abstraction this example implements.
27+
use std::future::Future;
28+
use std::io::Write;
29+
use std::path::Path as StdPath;
30+
use std::pin::Pin;
31+
use std::sync::Arc;
32+
use std::sync::atomic::{AtomicU64, Ordering};
33+
34+
use bytes::Bytes;
35+
use datafusion::common::Result;
36+
use datafusion::execution::disk_manager::DiskManagerBuilder;
37+
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
38+
use datafusion::execution::{SpillFile, SpillWriter, TempFileFactory};
39+
use datafusion::prelude::{SessionConfig, SessionContext};
40+
use datafusion_common::exec_err;
41+
use futures::{Stream, StreamExt, TryStreamExt, stream};
42+
use object_store::local::LocalFileSystem;
43+
use object_store::path::Path;
44+
use object_store::{ObjectStore, ObjectStoreExt, PutPayload};
45+
use tempfile::tempdir;
46+
47+
/// Demonstrates configuring DataFusion with spill files backed by an ObjectStore.
48+
pub async fn object_store_spill() -> Result<()> {
49+
// A real system would use S3, GCS, Azure, or some other ObjectStore for
50+
// remote spills. This example uses a local-file-backed ObjectStore for
51+
// simplicity.
52+
let tmp_dir = tempdir()?;
53+
let store: Arc<dyn ObjectStore> =
54+
Arc::new(LocalFileSystem::new_with_prefix(tmp_dir.path())?);
55+
56+
// Create the custom TempFileFactory that creates spill files in the ObjectStore.
57+
let temp_file_factory = Arc::new(ObjectStoreTempFileFactory::new(store));
58+
let disk_manager_builder =
59+
DiskManagerBuilder::default().with_temp_file_factory(temp_file_factory.clone());
60+
let runtime = RuntimeEnvBuilder::new()
61+
.with_disk_manager_builder(disk_manager_builder) // use the factory
62+
// and set a small memory limit so the example spills
63+
.with_memory_limit(1024 * 1024, 1.0)
64+
.build_arc()?;
65+
66+
// Configure a SessionContext for running queries; use a single partition
67+
// and no sort spill reservation to make the example deterministic and keep
68+
// the spill behavior easy to observe.
69+
let config = SessionConfig::new()
70+
.with_sort_spill_reservation_bytes(0)
71+
.with_sort_in_place_threshold_bytes(0)
72+
.with_target_partitions(1);
73+
let ctx = SessionContext::new_with_config_rt(config, Arc::clone(&runtime));
74+
75+
// Run an SQL query that sorts a "large" amount of data. Given the
76+
// SessionContext's low memory limit, the sort will spill.
77+
let row_count = 10_000_000;
78+
let mut stream = ctx
79+
.sql(&format!(
80+
"SELECT * FROM generate_series(1, {row_count}) AS t(v) ORDER BY v DESC"
81+
))
82+
.await?
83+
.execute_stream()
84+
.await?;
85+
86+
// Drive the query to completion, and verify output
87+
let mut output_rows = 0;
88+
while let Some(batch) = stream.next().await {
89+
output_rows += batch?.num_rows();
90+
}
91+
92+
assert_eq!(output_rows, row_count as usize);
93+
assert!(
94+
temp_file_factory.created_files() > 0,
95+
"expected the custom TempFileFactory to be used for spilling"
96+
);
97+
98+
Ok(())
99+
}
100+
101+
/// Creates spill files backed by an [`ObjectStore`].
102+
///
103+
/// DataFusion calls this factory whenever an operator needs a new temporary
104+
/// file for spilling. A remote deployment would use the same pattern with an
105+
/// S3, GCS, Azure, or other remote ObjectStore implementation.
106+
struct ObjectStoreTempFileFactory {
107+
/// ObjectStore used for spill file reads and writes.
108+
store: Arc<dyn ObjectStore>,
109+
/// Monotonic counter used to create unique object paths.
110+
counter: AtomicU64,
111+
/// Counts how many spill files DataFusion requested from this factory.
112+
created_files: AtomicU64,
113+
}
114+
115+
impl ObjectStoreTempFileFactory {
116+
/// Create a new spill file factory that stores spill data in `store`.
117+
fn new(store: Arc<dyn ObjectStore>) -> Self {
118+
Self {
119+
store,
120+
counter: AtomicU64::new(0),
121+
created_files: AtomicU64::new(0),
122+
}
123+
}
124+
125+
/// Return the number of spill files created through this factory.
126+
fn created_files(&self) -> u64 {
127+
self.created_files.load(Ordering::Relaxed)
128+
}
129+
}
130+
131+
impl TempFileFactory for ObjectStoreTempFileFactory {
132+
/// Create one logical spill file backed by an ObjectStore path.
133+
fn create_temp_file(&self, description: &str) -> Result<Arc<dyn SpillFile>> {
134+
let id = self.counter.fetch_add(1, Ordering::Relaxed);
135+
self.created_files.fetch_add(1, Ordering::Relaxed);
136+
137+
// Convert a query-provided spill description into an ObjectStore-safe path component.
138+
//
139+
// For example, `"Sort Spill: partition 0"` becomes `"Sort_Spill__partition_0"`.
140+
let cleaned_description: String = description
141+
.chars()
142+
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
143+
.collect();
144+
let location = Path::from(format!("spill/{cleaned_description}-{id}.bin"));
145+
146+
// Return a SpillFile implementation that reads and writes this ObjectStore path.
147+
Ok(Arc::new(ObjectStoreSpillFile {
148+
store: Arc::clone(&self.store),
149+
location,
150+
size: Arc::new(AtomicU64::new(0)),
151+
}))
152+
}
153+
}
154+
155+
/// Logical spill file stored at an ObjectStore path.
156+
///
157+
/// DataFusion writes spill data by calling [`SpillFile::open_writer`] and reads
158+
/// it back by calling [`SpillFile::read_stream`].
159+
struct ObjectStoreSpillFile {
160+
/// ObjectStore containing the spill object.
161+
store: Arc<dyn ObjectStore>,
162+
/// ObjectStore path for this spill object.
163+
location: Path,
164+
/// Last committed object size, updated when the writer finishes.
165+
size: Arc<AtomicU64>,
166+
}
167+
168+
impl SpillFile for ObjectStoreSpillFile {
169+
/// Return no local filesystem path because the spill file is accessed through ObjectStore.
170+
fn path(&self) -> Option<&StdPath> {
171+
None // Remote ObjectStores do not have a local OS path.
172+
}
173+
174+
/// Return the size of the uploaded object
175+
fn size(&self) -> Option<u64> {
176+
// Return the last committed size, which this example tracks after upload.
177+
Some(self.size.load(Ordering::Relaxed))
178+
}
179+
180+
/// Read the spill file contents as a byte stream.
181+
fn read_stream(&self) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>> {
182+
let store = Arc::clone(&self.store);
183+
let location = self.location.clone();
184+
185+
// Use `stream::once` to defer the ObjectStore read until DataFusion
186+
// polls the returned stream.
187+
let result_stream =
188+
async move { store.get(&location).await.map(|r| r.into_stream()) };
189+
let stream = stream::once(result_stream)
190+
.try_flatten()
191+
.map_err(Into::into);
192+
193+
Ok(Box::pin(stream))
194+
}
195+
196+
/// Open a synchronous writer for this spill file.
197+
fn open_writer(&self) -> Result<Box<dyn SpillWriter>> {
198+
// Create a writer that buffers bytes and uploads them on finish.
199+
Ok(Box::new(ObjectStoreSpillWriter {
200+
store: Arc::clone(&self.store),
201+
location: self.location.clone(),
202+
size: Arc::clone(&self.size),
203+
buffer: Vec::new(),
204+
}))
205+
}
206+
}
207+
208+
/// Adapts DataFusion's [`SpillWriter`] API to ObjectStore.
209+
///
210+
/// This simple example buffers bytes in memory and uploads them in
211+
/// [`SpillWriter::finish`]. A production remote implementation should consider
212+
/// multipart or streaming uploads.
213+
struct ObjectStoreSpillWriter {
214+
/// ObjectStore to read/write bytes to.
215+
store: Arc<dyn ObjectStore>,
216+
/// ObjectStore path to upload to.
217+
location: Path,
218+
/// Shared size field on the corresponding [`ObjectStoreSpillFile`].
219+
size: Arc<AtomicU64>,
220+
/// Buffered spill bytes waiting to be uploaded.
221+
///
222+
/// This simple example buffers the spill and uploads it on finish.
223+
/// Production remote stores should consider multipart or streaming uploads.
224+
buffer: Vec<u8>,
225+
}
226+
227+
impl Write for ObjectStoreSpillWriter {
228+
/// Append bytes to the in-memory buffer.
229+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
230+
// Buffer bytes written through the synchronous Write API.
231+
self.buffer.extend_from_slice(buf);
232+
Ok(buf.len())
233+
}
234+
235+
/// No-op because data is committed in [`SpillWriter::finish`].
236+
fn flush(&mut self) -> std::io::Result<()> {
237+
Ok(())
238+
}
239+
}
240+
241+
impl SpillWriter for ObjectStoreSpillWriter {
242+
/// Upload buffered bytes to ObjectStore and mark the spill file complete.
243+
fn finish(&mut self) -> Result<()> {
244+
// Move the buffered bytes into the upload future.
245+
let store = Arc::clone(&self.store);
246+
let location = self.location.clone();
247+
let data = std::mem::take(&mut self.buffer);
248+
let size = data.len() as u64;
249+
250+
// This simple example buffers the spill and uploads it on finish.
251+
// Production remote stores should consider multipart or streaming uploads.
252+
block_on_object_store(async move {
253+
store
254+
.put(&location, PutPayload::from_bytes(data.into()))
255+
.await?;
256+
Ok(())
257+
})?;
258+
259+
self.size.store(size, Ordering::Relaxed);
260+
Ok(())
261+
}
262+
}
263+
264+
/// Run an async ObjectStore operation.
265+
///
266+
/// Adding a native async API is tracked in <https://github.com/apache/datafusion/issues/23247>
267+
fn block_on_object_store<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
268+
if let Ok(handle) = tokio::runtime::Handle::try_current() {
269+
tokio::task::block_in_place(|| handle.block_on(future))
270+
} else {
271+
exec_err!("No current Tokio runtime available")
272+
}
273+
}

datafusion/execution/src/disk_manager.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ impl DiskManagerBuilder {
7474
self
7575
}
7676

77+
/// Configure a custom factory for creating temporary spill files.
78+
///
79+
/// This sets the disk manager mode to [`DiskManagerMode::Custom`], so
80+
/// operators that spill during query execution create files through the
81+
/// provided [`TempFileFactory`] instead of using local temporary files.
82+
pub fn set_temp_file_factory(&mut self, temp_file_factory: Arc<dyn TempFileFactory>) {
83+
self.mode = DiskManagerMode::Custom(temp_file_factory);
84+
}
85+
86+
/// Configure a custom factory for creating temporary spill files.
87+
///
88+
/// See details on [`Self::set_temp_file_factory`].
89+
pub fn with_temp_file_factory(
90+
mut self,
91+
temp_file_factory: Arc<dyn TempFileFactory>,
92+
) -> Self {
93+
self.set_temp_file_factory(temp_file_factory);
94+
self
95+
}
96+
7797
pub fn set_max_temp_directory_size(&mut self, value: u64) {
7898
self.max_temp_directory_size = value;
7999
}

0 commit comments

Comments
 (0)