Skip to content
Closed
Show file tree
Hide file tree
Changes from 21 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
6 changes: 6 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
81 changes: 81 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,72 @@ 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())
}
}

impl Drop for CatalogObjectTracker {
/// Best-effort cleanup of any on-disk checkpoint storage for cached
/// relations that were never released via `RemoveCachedRemoteRelationCommand`.
/// This covers session timeouts, client disconnects, and any other path
/// where the catalog manager is dropped without explicit cleanup.
fn drop(&mut self) {
let Ok(state) = self.state.get_mut() else {
return;
};
for (_, relation) in state.cached_relations.drain() {
let Some(uri) = relation.storage_uri else {
continue;
};
if let Ok(url) = url::Url::parse(&uri) {
if url.scheme() == "file" {
if let Ok(path) = url.to_file_path() {
// `Drop` runs in a sync context, so we use blocking
// filesystem calls for local checkpoint cleanup.
// Errors are ignored because the caller cannot react.
let _ = std::fs::remove_dir_all(path);
}
}
}
}
Comment thread
shehabgamin marked this conversation as resolved.
Outdated
Comment thread
shehabgamin marked this conversation as resolved.
Outdated
}
}
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