Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
035ad9a
feat: implement DataFrame.checkpoint and DataFrame.localCheckpoint
Copilot May 10, 2026
0ad35c2
test: clarify null preservation assertions; doc cached_relations
Copilot May 10, 2026
262e7f7
fix: preserve duplicate columns in checkpointed relations
Copilot May 10, 2026
b4fa7b4
Potential fix for pull request finding
shehabgamin May 10, 2026
0fecd12
Potential fix for pull request finding
shehabgamin May 10, 2026
5148efd
fix: checkpoint storage parity and formatting
Copilot May 10, 2026
3b1bdc6
fix: create checkpoint storage directory
Copilot May 10, 2026
6246edc
fix: clean up checkpoint storage
Copilot May 10, 2026
8f80818
docs: clarify checkpoint storage lifecycle
Copilot May 10, 2026
72e3002
fix: clean up checkpoint storage on session drop
Copilot May 10, 2026
43ad663
fix: default local checkpoint to disk storage to avoid OOM
Copilot May 10, 2026
0fb4ff5
fix: handle empty DataFrame in disk checkpoint materialization
Copilot May 10, 2026
1b69b96
fix: improve error handling for checkpoint path existence check
Copilot May 10, 2026
f5341da
fix: skip checkpoint doctests for PySpark 3.5.x and handle empty Data…
Copilot May 12, 2026
b3b58c0
refactor: address checkpoint review feedback (runner, config, eager s…
Copilot May 12, 2026
241c8e3
refactor: stream checkpoint output directly to parquet and clarify ch…
Copilot May 12, 2026
ac15f6b
feat: support object-store checkpoint locations
Copilot May 12, 2026
b5831b7
Apply suggestions from code review
shehabgamin May 12, 2026
d1e4dc7
fix: address checkpoint review follow-ups
Copilot May 13, 2026
aa44e2b
Potential fix for pull request finding
shehabgamin May 13, 2026
f771809
fix: remove local checkpoint directories
Copilot May 13, 2026
70332d8
refactor checkpoint cleanup follow-ups
Copilot May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sail-catalog/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ lazy_static = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
2 changes: 2 additions & 0 deletions crates/sail-catalog/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub enum CatalogObject {
Function,
TemporaryView,
LogicalPlan,
CachedRelation,
}

impl fmt::Display for CatalogObject {
Expand All @@ -30,6 +31,7 @@ impl fmt::Display for CatalogObject {
CatalogObject::Function => "Function",
CatalogObject::TemporaryView => "Temporary View",
CatalogObject::LogicalPlan => "Logical Plan",
CatalogObject::CachedRelation => "Cached Relation",
};
write!(f, "{name}")
}
Expand Down
27 changes: 26 additions & 1 deletion crates/sail-catalog/src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use datafusion_expr::{LogicalPlan, ScalarUDF};
use sail_common_datafusion::extension::SessionExtension;

use crate::error::{CatalogError, CatalogObject, CatalogResult};
use crate::manager::tracker::{CatalogFunctionId, CatalogLogicalPlanId, CatalogObjectTracker};
use crate::manager::tracker::{
CatalogCachedRelation, CatalogFunctionId, CatalogLogicalPlanId, CatalogObjectTracker,
};
use crate::provider::{CatalogProvider, Namespace};
use crate::temp_view::TemporaryViewManager;

Expand Down Expand Up @@ -147,6 +149,29 @@ impl CatalogManager {
) -> CatalogResult<Arc<LogicalPlan>> {
self.tracker.get_tracked_logical_plan(id)
}

pub fn track_cached_relation(
&self,
relation_id: String,
relation: CatalogCachedRelation,
) -> CatalogResult<()> {
self.tracker.track_cached_relation(relation_id, relation)
}

pub fn get_cached_relation(&self, relation_id: &str) -> CatalogResult<CatalogCachedRelation> {
self.tracker.get_cached_relation(relation_id)
}

pub fn remove_cached_relation(
&self,
relation_id: &str,
) -> CatalogResult<Option<CatalogCachedRelation>> {
self.tracker.remove_cached_relation(relation_id)
}

pub fn drain_cached_relation_storage_uris(&self) -> CatalogResult<Vec<String>> {
self.tracker.drain_cached_relation_storage_uris()
}
}

impl CatalogManagerState {
Expand Down
54 changes: 54 additions & 0 deletions crates/sail-catalog/src/manager/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,25 @@ pub struct CatalogFunctionId(u64);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Serialize, Deserialize)]
pub struct CatalogLogicalPlanId(u64);

#[derive(Debug, Clone)]
pub struct CatalogCachedRelation {
pub plan: Arc<LogicalPlan>,
pub fields: Vec<String>,
pub storage_uri: Option<String>,
}

#[derive(Default)]
struct CatalogObjectTrackerState {
next_function_id: u64,
next_logical_plan_id: u64,
functions: HashMap<u64, ScalarUDF>,
logical_plans: HashMap<u64, Arc<LogicalPlan>>,
/// Maps relation IDs from `CachedRemoteRelation` to their checkpointed
/// logical plans and user-facing field names. Populated when the server
/// handles a `CheckpointCommand`, and queried when resolving a
/// `CachedRemoteRelation` query node. Entries are removed when the server
/// handles a `RemoveCachedRemoteRelationCommand`.
cached_relations: HashMap<String, CatalogCachedRelation>,
}

/// Tracks in-memory objects (UDFs and logical plans) that cannot be serialized directly,
Expand Down Expand Up @@ -75,4 +88,45 @@ impl CatalogObjectTracker {
.cloned()
.ok_or_else(|| CatalogError::NotFound(CatalogObject::LogicalPlan, id.0.to_string()))
}

/// Stores a logical plan keyed by an opaque relation ID.
/// This is used to back checkpointed DataFrames referenced via
/// `CachedRemoteRelation` in subsequent Spark Connect requests.
pub fn track_cached_relation(
&self,
relation_id: String,
relation: CatalogCachedRelation,
) -> CatalogResult<()> {
let mut state = self.state()?;
state.cached_relations.insert(relation_id, relation);
Ok(())
}

pub fn get_cached_relation(&self, relation_id: &str) -> CatalogResult<CatalogCachedRelation> {
let state = self.state()?;
state
.cached_relations
.get(relation_id)
.cloned()
.ok_or_else(|| {
CatalogError::NotFound(CatalogObject::CachedRelation, relation_id.to_string())
})
}

pub fn remove_cached_relation(
&self,
relation_id: &str,
) -> CatalogResult<Option<CatalogCachedRelation>> {
let mut state = self.state()?;
Ok(state.cached_relations.remove(relation_id))
}

pub fn drain_cached_relation_storage_uris(&self) -> CatalogResult<Vec<String>> {
let mut state = self.state()?;
Ok(state
.cached_relations
.drain()
.filter_map(|(_, relation)| relation.storage_uri)
.collect())
}
}
3 changes: 3 additions & 0 deletions crates/sail-common-datafusion/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ thiserror = { workspace = true }
either = { workspace = true }
lexical-core = { workspace = true }
futures = { workspace = true }
log = { workspace = true }
object_store = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
Expand All @@ -27,6 +29,7 @@ pin-project-lite = { workspace = true }
parquet-variant-compute = { workspace = true }
parquet-variant-json = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
serde_arrow = { workspace = true }

[build-dependencies]
Expand Down
46 changes: 46 additions & 0 deletions crates/sail-common-datafusion/src/checkpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use datafusion::execution::runtime_env::RuntimeEnv;
use futures::{StreamExt, TryStreamExt};
use log::warn;
use object_store::ObjectStoreScheme;

pub async fn cleanup_checkpoint_storage(runtime_env: &RuntimeEnv, storage_uri: &str) {
let Ok(url) = url::Url::parse(storage_uri) else {
warn!("failed to parse checkpoint location for cleanup: {storage_uri}");
return;
};
let Ok((_, checkpoint_path)) = ObjectStoreScheme::parse(&url) else {
warn!("failed to parse checkpoint object-store path for cleanup: {storage_uri}");
return;
};
let Ok(object_store) = runtime_env.object_store_registry.get_store(&url) else {
warn!("failed to resolve checkpoint object store for cleanup: {storage_uri}");
return;
};
let locations = object_store
.list(Some(&checkpoint_path))
.map_ok(|meta| meta.location)
.boxed();
if let Err(e) = object_store
.delete_stream(locations)
.try_collect::<Vec<_>>()
.await
{
warn!("failed to remove checkpoint location {storage_uri}: {e}");
}
cleanup_file_checkpoint_directory(&url, storage_uri).await;
}

async fn cleanup_file_checkpoint_directory(url: &url::Url, storage_uri: &str) {
if url.scheme() != "file" {
return;
}
let Ok(path) = url.to_file_path() else {
warn!("failed to convert checkpoint file URL to path for cleanup: {storage_uri}");
return;
};
if let Err(e) = tokio::fs::remove_dir_all(&path).await {
if e.kind() != std::io::ErrorKind::NotFound {
warn!("failed to remove checkpoint directory {storage_uri}: {e}");
}
}
}
1 change: 1 addition & 0 deletions crates/sail-common-datafusion/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod array;
pub mod catalog;
pub mod checkpoint;
pub mod column_features;
pub mod datasource;
pub mod display;
Expand Down
1 change: 1 addition & 0 deletions crates/sail-common/src/config/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ pub enum CatalogType {
pub struct SparkConfig {
pub session_timeout_secs: u64,
pub execution_heartbeat_interval_secs: u64,
pub checkpoint_dir: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
14 changes: 14 additions & 0 deletions crates/sail-common/src/config/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,20 @@
the session active.
experimental: true

- key: spark.checkpoint_dir
type: string
default: ""
description: |
The directory where reliable (disk) checkpoints created via
`DataFrame.checkpoint()` and `DataFrame.localCheckpoint()` are written.
When empty, a `sail-checkpoints/` subdirectory of the system temporary
directory is used. In cluster deployments, this should be set to a
location that is shared and readable by all workers, such as an
object-store URI or a shared filesystem path, since the default location
is node-local. Checkpoint files are best-effort cleaned up when the client
releases the cached relation or when the session ends.
experimental: true

- key: flight.session_timeout_secs
type: number
default: "3600"
Expand Down
31 changes: 31 additions & 0 deletions crates/sail-plan/src/resolver/query/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ use datafusion::catalog::MemTable;
use datafusion_common::{DFSchema, DFSchemaRef, ParamValues};
use datafusion_expr::{EmptyRelation, Extension, LogicalPlan, UNNAMED_TABLE};
use log::warn;
use sail_catalog::error::{CatalogError, CatalogObject};
use sail_catalog::manager::CatalogManager;
use sail_common::spec;
use sail_common_datafusion::array::record_batch::{cast_record_batch, read_record_batches};
use sail_common_datafusion::extension::SessionExtensionAccessor;
use sail_common_datafusion::literal::LiteralEvaluator;
use sail_common_datafusion::rename::logical_plan::rename_logical_plan;
use sail_logical_plan::range::RangeNode;

use crate::error::{PlanError, PlanResult};
Expand Down Expand Up @@ -138,6 +142,33 @@ impl PlanResolver<'_> {
)
}

/// Resolves a query that refers to a cached remote relation by looking up
/// the previously checkpointed logical plan from the catalog manager. The
/// cached plan was stored with internal field IDs and its user-facing field
/// names, so we register fresh field IDs in the current resolver state and
/// rename the plan accordingly. This preserves duplicate user-facing names
/// without relying on them as `Expr::Column` references inside the cached
/// plan.
pub(super) async fn resolve_query_cached_remote_relation(
&self,
relation_id: String,
state: &mut PlanResolverState,
) -> PlanResult<LogicalPlan> {
let manager = self.ctx.extension::<CatalogManager>()?;
let relation = match manager.get_cached_relation(&relation_id) {
Ok(relation) => relation,
Err(CatalogError::NotFound(CatalogObject::CachedRelation, _)) => {
return Err(PlanError::AnalysisError(format!(
"cached remote relation not found: {relation_id}"
)));
}
Err(error) => return Err(error.into()),
};
let names = state.register_field_names(relation.fields.clone());
let plan = rename_logical_plan((*relation.plan).clone(), &names)?;
Ok(plan)
}

pub(super) async fn resolve_query_hint(
&self,
input: spec::QueryPlan,
Expand Down
5 changes: 3 additions & 2 deletions crates/sail-plan/src/resolver/query/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ impl PlanResolver<'_> {
QueryNode::CachedLocalRelation { .. } => {
return Err(PlanError::todo("cached local relation"));
}
QueryNode::CachedRemoteRelation { .. } => {
return Err(PlanError::todo("cached remote relation"));
QueryNode::CachedRemoteRelation { relation_id } => {
self.resolve_query_cached_remote_relation(relation_id, state)
.await?
}
QueryNode::CommonInlineUserDefinedTableFunction(udtf) => {
self.resolve_query_common_inline_udtf(udtf, state).await?
Expand Down
2 changes: 2 additions & 0 deletions crates/sail-session/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ datafusion = { workspace = true }
datafusion-common = { workspace = true }
datafusion-expr = { workspace = true }
datafusion-physical-expr = { workspace = true }
object_store = { workspace = true }
secrecy = { workspace = true }
tonic = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
indexmap = { workspace = true }
futures = { workspace = true }
url = { workspace = true }
Loading
Loading