Repository navigation
Description
Is your feature request related to a problem or challenge?
DataFrame.cache() does not work in distributed environments as it will materialise whole table in memory.
pub async fn cache(self) -> Result<DataFrame> {
let context = SessionContext::new_with_state((*self.session_state).clone());
// The schema is consistent with the output
let plan = self.clone().create_physical_plan().await?;
let schema = plan.schema();
let task_ctx = Arc::new(self.task_ctx());
let partitions = collect_partitioned(plan, task_ctx).await?;
let mem_table = MemTable::try_new(schema, partitions)?;
context.read_table(Arc::new(mem_table))
}This does not work in distributed environments such as ballista.
Also note, cache will eagerly materialise, even there is no downstream consumers (which may not be a big problem) but it does not follow semantics of spark.cache(..)
Describe the solution you'd like
-
Ideally cache should be represented with logical plan node, which would be resolved first time cache is needed. This approach may be a bit complicated to implement, and datafusion may not really benefit from it.
-
As an easier to implement alternative, we could provide a
cache factory(at session_state maybe which would provide same functionality as it is currently if not overridden or provide user specified logic, such as returningLogicalPlan::Extensionor similar, and leave query planner/user to deal with cache materialisation decision. -
EDIT: Another alternative would be to have a configuration option, like
datafusion.execution.local_cachewhich would decide should it use current cache logic or return new logical plan element likeLogicalPlan::Cache { id: string, lineage: LogicalPlan }
pub async fn cache(self) -> Result<DataFrame> {
if `datafusion.execution.local_cache` == true {
let context = SessionContext::new_with_state((*self.session_state).clone());
// The schema is consistent with the output
let plan = self.clone().create_physical_plan().await?;
let schema = plan.schema();
let task_ctx = Arc::new(self.task_ctx());
let partitions = collect_partitioned(plan, task_ctx).await?;
let mem_table = MemTable::try_new(schema, partitions)?;
context.read_table(Arc::new(mem_table))
} else {
let lineage = self... // get current logical plan from df up to this point
Ok(
LogicalPlan::Cache(Cache { id : uuid(), lineage)
)
}
}Describe alternatives you've considered
Users can create DataFrameExt which would provide a new method like distributed_cache which would return LogicalPlan::Extension with DistributedCacheExtension. This alternative is the simplest to implement (not affecting datafusion) but user would need to change code when moving from datafusion to ballista
Additional context
No response
Activity
Jefffrey commentedon Aug 24, 2025
Feel free to take this issue by leaving a comment with just the word
takeSee reference in contributor guide: https://datafusion.apache.org/contributor-guide/index.html#open-contribution-and-assigning-tickets
milenkovicm commentedon Aug 24, 2025
Edited issue description with another option:
Another alternative would be to have a configuration option, like
datafusion.execution.local_cachewhich would decide should it use current cache logic or return new logical plan element likeLogicalPlan::Cache { id: string, lineage: LogicalPlan }MrGranday commentedon Aug 24, 2025
take
feat: add configurable cache mode (local_cache) with LogicalPlan::Cac…
milenkovicm commentedon Aug 26, 2025
@alamb if you have time it would be great to get your opinion about this issue as solving it may require some coding.
alamb commentedon Aug 27, 2025
In terms of designing an API, I think it would help me to understand what behavior you want in Ballista when a user runs
DataFrame::cache(aka how wll you use this API)?For example do you want it to write to fies distributed across the nodes?
It seems to me at the moment
DataFrame::cacheis basically a shorthand for"CREATE TABLE <temp> AS SELECT * FROM <dataframe>The current implementation has a hard coded MemTable and the orchestration (creation of the MemTable / batches) is in the DataFrame::cache function itself.
If you want to move the logic to LogicalPlans you'll probably also have to add an equivalent physical node that handles the orchestration
milenkovicm commentedon Aug 27, 2025
Ballista will create cache partitions locally on some of the executors. Handling the physical part of execution is specific to the implementor, such as Ballista in this case.
Overall flow would be something like:
LogicalPlan::CacheLogicalPlan::Cacheis then converted toBallistaCacheReadExec, this part is handled withBallistaQueryPlanner(there is a bit more to it, a job can be started which would create cache withBallistaCacheWriteExecif not materialised already, task will be pending until cache job creation is in progress, cache partitions can be re-created in case of node failures ...)As the physical execution is tied to an external system (cache implementer) I believe we do not need to bring
CachePhysicalExecto DataFusion, we just need to provide aLogicalPlan::Cache.With the proposed solution we would be able to keep the current behaviour, or we can delegate cache handling to the external system if we wish. So if the user disables local cache
datafusion.execution.local_cache=falseit would be up to them to provide a query planner which would know how to handleLogicalPlan::CacheProbably it would make more sense to name
datafusion.execution.local_cache=false,datafusion.execution.external_cache=truealamb commentedon Sep 4, 2025
That makes sense to me and the idea of being able to specially handle LogicalPlan nodes seems reasonable to me
I dug around and it seems like we already do this for various DDL here:
https://github.com/apache/datafusion/blob/cb69574c0ff326ff975c58234b124904add33cfc/datafusion/core/src/execution/context/mod.rs#L664-L663
What if we instead changed
DataFrame::cacheto use aLogicalPlan::DDL/CreateMemoryTablehttps://github.com/apache/datafusion/blob/a571b7d44436c5c2bf641444857e8358edd9d8a8/datafusion/expr/src/logical_plan/ddl.rs#L41-L40Then ballista could use the same mechanism?
milenkovicm commentedon Sep 4, 2025
Create memory table may work, input property may be used as lineage, table reference can capture cache id and session I'd.
One thing I need to check if DDL statements will be propagated to custom query planner, if I recall correctly they might be intercepted
milenkovicm commentedon Sep 5, 2025
looking at spark, cache is represented as
InMemoryRelation:will produce:
milenkovicm commentedon Sep 5, 2025
Looks like create memory table will be handled by datafusion context
datafusion/datafusion/core/src/execution/context/mod.rs
Line 662 in fd7df66
datafusion/datafusion/core/src/execution/context/mod.rs
Lines 807 to 808 in fd7df66
So it looks like create memory table will not be propagated to custom query planner.
alamb commentedon Sep 8, 2025
What we do in InfluxDB is basically override
sqland specially handle all the DDL commands -- maybe ballista could do the same 🤔milenkovicm commentedon Sep 8, 2025
I'm trying to keep Ballista’s
SessionContextthe same as DataFusion’sSessionContext. I believe there are a lot of benefits to sharing the same context.milenkovicm commentedon Sep 17, 2025
if we use
LogicalPlan::TableScan( )where we implement customInMemoryTableSource(implementingTableSource) may make sense.TableSourceexposesTableSource::get_logical_planwhich could capture lineage. This would be quite aligned with spark, and would not introduce new logical plan node 🤔jizezhang commentedon Nov 9, 2025
Hi @milenkovicm , I am interested in this task and would like to understand the proposal better. Is the idea that
DataFrame::cachewill create a new logical plan with aTableScannode as the root and current logical plan of the dataframe (viaself.plan) as its child?TableScanwould be constructed with a customInMemoryTableSourcethat overrides theget_logical_planmethod from the traitTableSource?DataFrame::cachereturnsctx.execute_logical_plan(new_plan)?Thanks a lot!
milenkovicm commentedon Nov 9, 2025
Hi @jizezhang, thanks for sharing interest,
yes, new logical plan node to be created, now its question which one to be creted
I have tried that, it did not work as expected, I don't remember reason why
Not necessarily to execute logical plan right then, it should just return logical plan node and let user decide when to run the plan .
I believe the simplest approach would be to create a factory method which user can configure
Something like:
cache_producerwould be a method accepting DataFrame and returning DataFrame, probably a logical plan extension.Does it make sense? Let me know what you think
jizezhang commentedon Nov 10, 2025
Hi @milenkovicm thanks for replying. I do have some questions:
cache_producerrelated to the discussion above on creating a new logical node/plan at all? or in this case it would be up to the user to encapsulate their custom logic incache_producer?cache_producer, e.g. viaSessionState?Thanks!
milenkovicm commentedon Nov 10, 2025
yes, just a logical plan node, no custom logic
maybe we could copy approach function factory follows ?
jizezhang commentedon Nov 11, 2025
Thank you. Actually I would like to ask another question:
By "logical plan extension", are you referring to
datafusion/datafusion/expr/src/logical_plan/plan.rs
Lines 3186 to 3189 in 552dbe4
Is the idea that we create some
UserDefinedLogicalNodethat takes current dataframe's logical plan (self.plan) as inputs, and then return a dataframe with a newLogicalPlan::Extensionthat wraps the node? or how does the extension relate tocache_producerbeing a method that accepts and returns a dataframe? Could you elaborate a bit more? Thanks!milenkovicm commentedon Nov 11, 2025
yes i believe your understanding is right. user can create their own cache implementation, for doing that they would need their own
UserDefinedLogicalNodewhich will represent their cache implementation. regarding input and outputs ... its up to you to experiment and see which one makes most sense, having input as logical plan and state and dataframe as output may be the best approachjizezhang commentedon Nov 14, 2025
@milenkovicm Please review the PR when you get a chance. Thanks.
jizezhang commentedon Nov 16, 2025
I have updated the PR to address the comments. Please review when you get a chance. Thank you.
feat: allow custom caching via logical node (#18688)