This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new f67969cf6 feat: Share read-side runtime state across a session's tasks 
on the executor (#1995)
f67969cf6 is described below

commit f67969cf6b642d49114674f10d30c520839d155e
Author: Andy Grove <[email protected]>
AuthorDate: Sun Jul 12 07:45:01 2026 -0600

    feat: Share read-side runtime state across a session's tasks on the 
executor (#1995)
---
 Cargo.lock                                |   1 +
 ballista/executor/Cargo.toml              |   1 +
 ballista/executor/src/config.rs           |  10 ++
 ballista/executor/src/execution_loop.rs   |   3 +-
 ballista/executor/src/executor.rs         | 105 ++++++++++++++
 ballista/executor/src/executor_process.rs | 171 +++++++++++++----------
 ballista/executor/src/executor_server.rs  |   4 +-
 ballista/executor/src/lib.rs              |   2 +
 ballista/executor/src/runtime_cache.rs    | 218 ++++++++++++++++++++++++++++++
 python/Cargo.lock                         |  24 +++-
 10 files changed, 462 insertions(+), 77 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index fa3e259c2..b918eb711 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1159,6 +1159,7 @@ dependencies = [
  "futures",
  "libc",
  "log",
+ "lru 0.18.0",
  "memory-stats",
  "mimalloc",
  "parking_lot",
diff --git a/ballista/executor/Cargo.toml b/ballista/executor/Cargo.toml
index c80e68b9c..aa60cb2fd 100644
--- a/ballista/executor/Cargo.toml
+++ b/ballista/executor/Cargo.toml
@@ -50,6 +50,7 @@ datafusion = { workspace = true }
 datafusion-proto = { workspace = true }
 futures = { workspace = true }
 log = { workspace = true }
+lru = "0.18"
 memory-stats = { version = "1.2.0", features = ["always_use_statm"] }
 mimalloc = { workspace = true, optional = true }
 parking_lot = { workspace = true }
diff --git a/ballista/executor/src/config.rs b/ballista/executor/src/config.rs
index 8ddeb4ab3..48b5a28c9 100644
--- a/ballista/executor/src/config.rs
+++ b/ballista/executor/src/config.rs
@@ -171,6 +171,15 @@ pub struct Config {
         help = "Optional total executor memory budget (e.g. \"8GB\", 
\"512MiB\"). Each concurrent task receives an equal share."
     )]
     pub memory_pool_size: Option<u64>,
+    /// Maximum number of sessions whose shared runtime state (object-store
+    /// clients, Parquet footer cache) is retained on the executor (LRU). `0`
+    /// disables caching.
+    #[arg(
+        long,
+        default_value_t = 16,
+        help = "Max number of sessions whose shared runtime state 
(object-store clients, Parquet footer cache) is retained on the executor (LRU). 
0 disables caching."
+    )]
+    pub session_runtime_cache_capacity: usize,
     /// Number of seconds established client connection should be cached if 
not used (0 means no cache)
     #[arg(
         long,
@@ -207,6 +216,7 @@ impl TryFrom<Config> for ExecutorProcessConfig {
             executor_heartbeat_interval_seconds: 
opt.executor_heartbeat_interval_seconds,
             metric_collection_policy: opt.metric_collection_policy,
             memory_pool_size: opt.memory_pool_size,
+            session_runtime_cache_capacity: opt.session_runtime_cache_capacity,
             override_execution_engine: None,
             override_function_registry: None,
             override_config_producer: None,
diff --git a/ballista/executor/src/execution_loop.rs 
b/ballista/executor/src/execution_loop.rs
index 8a759f501..ff0838a7e 100644
--- a/ballista/executor/src/execution_loop.rs
+++ b/ballista/executor/src/execution_loop.rs
@@ -262,7 +262,8 @@ async fn run_received_task<T: 'static + AsLogicalPlan, U: 
'static + AsExecutionP
     let task_higher_order_functions =
         executor.function_registry.higher_order_functions.clone();
 
-    let runtime = executor.produce_runtime(&session_config)?;
+    let runtime =
+        executor.produce_runtime_for_session(&task.session_id, 
&session_config)?;
 
     let session_id = task.session_id.clone();
     let task_context = Arc::new(TaskContext::new(
diff --git a/ballista/executor/src/executor.rs 
b/ballista/executor/src/executor.rs
index 09c0af31c..d5f90b40a 100644
--- a/ballista/executor/src/executor.rs
+++ b/ballista/executor/src/executor.rs
@@ -23,6 +23,7 @@ use crate::execution_engine::QueryStageExecutor;
 use crate::execution_loop::any_to_string;
 use crate::metrics::ExecutorMetricsCollector;
 use crate::metrics::LoggingMetricsCollector;
+use crate::runtime_cache::SessionRuntimeCache;
 use ballista_core::ConfigProducer;
 use ballista_core::JobId;
 use ballista_core::RuntimeProducer;
@@ -97,6 +98,11 @@ pub struct Executor {
     /// Execution engine that the executor will delegate to
     /// for executing query stages
     pub(crate) execution_engine: Arc<dyn ExecutionEngine>,
+
+    /// Optional session-keyed cache of shared base runtime envs. When set,
+    /// `produce_runtime_for_session` reuses read-side state across a session's
+    /// tasks; when `None`, each task builds a runtime from `runtime_producer`.
+    session_runtime_cache: Option<Arc<dyn SessionRuntimeCache>>,
 }
 
 impl Executor {
@@ -144,6 +150,7 @@ impl Executor {
             concurrent_tasks,
             abort_handles: Default::default(),
             execution_engine,
+            session_runtime_cache: None,
         }
     }
     /// Creates new Executor with default `ExecutionEngine`.
@@ -167,6 +174,7 @@ impl Executor {
             concurrent_tasks,
             abort_handles: Default::default(),
             execution_engine: Arc::new(DefaultExecutionEngine::new()),
+            session_runtime_cache: None,
         }
     }
 }
@@ -180,6 +188,30 @@ impl Executor {
         (self.runtime_producer)(config)
     }
 
+    /// Attaches (or clears) the session-keyed runtime cache. Cloning the
+    /// `Executor` shares the same cache via `Arc`.
+    pub fn with_session_runtime_cache(
+        mut self,
+        cache: Option<Arc<dyn SessionRuntimeCache>>,
+    ) -> Self {
+        self.session_runtime_cache = cache;
+        self
+    }
+
+    /// Produces the runtime for a task, reusing the session's shared read-side
+    /// state when a session cache is attached; otherwise builds a runtime per
+    /// task via the `runtime_producer`.
+    pub fn produce_runtime_for_session(
+        &self,
+        session_id: &str,
+        config: &SessionConfig,
+    ) -> datafusion::error::Result<Arc<RuntimeEnv>> {
+        match &self.session_runtime_cache {
+            Some(cache) => cache.produce_runtime(session_id, config),
+            None => (self.runtime_producer)(config),
+        }
+    }
+
     /// Creates a default [`SessionConfig`] using the configured config 
producer.
     pub fn produce_config(&self) -> SessionConfig {
         (self.config_producer)()
@@ -269,6 +301,9 @@ impl Executor {
 mod test {
     use crate::execution_engine::{DefaultQueryStageExec, ShuffleWriterVariant};
     use crate::executor::Executor;
+    use crate::runtime_cache::{
+        DefaultSessionRuntimeCache, MemoryPoolPolicy, SessionRuntimeCache,
+    };
     use ballista_core::RuntimeProducer;
     use ballista_core::execution_plans::ShuffleWriterExec;
     use ballista_core::serde::protobuf::ExecutorRegistration;
@@ -278,11 +313,13 @@ mod test {
     use datafusion::arrow::record_batch::RecordBatch;
     use datafusion::error::{DataFusionError, Result};
     use datafusion::execution::context::TaskContext;
+    use datafusion::execution::runtime_env::RuntimeEnv;
 
     use datafusion::physical_plan::{
         DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, 
PlanProperties,
         RecordBatchStream, SendableRecordBatchStream,
     };
+    use datafusion::prelude::SessionConfig;
     use datafusion::prelude::SessionContext;
     use futures::Stream;
     use std::pin::Pin;
@@ -459,4 +496,72 @@ mod test {
         let inner_result = result.unwrap().unwrap();
         assert!(inner_result.is_err());
     }
+
+    #[test]
+    fn produce_runtime_for_session_shares_read_side_state() {
+        let executor_registration = ExecutorRegistration {
+            id: "executor".to_string(),
+            port: 0,
+            grpc_port: 0,
+            specification: None,
+            host: None,
+            os_info: None,
+        };
+        let config_producer = Arc::new(default_config_producer);
+
+        // A base producer that builds a fresh env each call, plus an identity
+        // pool policy, so shared read-side state is observable via ptr 
equality.
+        let base_producer: RuntimeProducer =
+            Arc::new(|_| Ok(Arc::new(RuntimeEnv::default())));
+        let identity: MemoryPoolPolicy = Arc::new(|base, _| Ok(base));
+        let cache: Arc<dyn SessionRuntimeCache> = Arc::new(
+            DefaultSessionRuntimeCache::new(base_producer.clone(), identity, 
4),
+        );
+
+        let executor = Executor::new_basic(
+            executor_registration,
+            "/tmp",
+            base_producer,
+            config_producer,
+            2,
+        )
+        .with_session_runtime_cache(Some(cache));
+
+        let cfg = SessionConfig::new();
+        let e1 = executor.produce_runtime_for_session("s1", &cfg).unwrap();
+        let e2 = executor.produce_runtime_for_session("s1", &cfg).unwrap();
+        let e3 = executor.produce_runtime_for_session("s2", &cfg).unwrap();
+
+        assert!(Arc::ptr_eq(&e1.cache_manager, &e2.cache_manager));
+        assert!(!Arc::ptr_eq(&e1.cache_manager, &e3.cache_manager));
+    }
+
+    #[test]
+    fn produce_runtime_for_session_falls_back_without_cache() {
+        let executor_registration = ExecutorRegistration {
+            id: "executor".to_string(),
+            port: 0,
+            grpc_port: 0,
+            specification: None,
+            host: None,
+            os_info: None,
+        };
+        let config_producer = Arc::new(default_config_producer);
+        let base_producer: RuntimeProducer =
+            Arc::new(|_| Ok(Arc::new(RuntimeEnv::default())));
+
+        // No cache attached: each call builds a fresh env.
+        let executor = Executor::new_basic(
+            executor_registration,
+            "/tmp",
+            base_producer,
+            config_producer,
+            2,
+        );
+
+        let cfg = SessionConfig::new();
+        let e1 = executor.produce_runtime_for_session("s1", &cfg).unwrap();
+        let e2 = executor.produce_runtime_for_session("s1", &cfg).unwrap();
+        assert!(!Arc::ptr_eq(&e1.cache_manager, &e2.cache_manager));
+    }
 }
diff --git a/ballista/executor/src/executor_process.rs 
b/ballista/executor/src/executor_process.rs
index 4e37687e8..360a89491 100644
--- a/ballista/executor/src/executor_process.rs
+++ b/ballista/executor/src/executor_process.rs
@@ -42,7 +42,7 @@ use tokio::{fs, time};
 use uuid::Uuid;
 
 use datafusion::execution::memory_pool::{FairSpillPool, MemoryPool};
-use datafusion::execution::runtime_env::RuntimeEnvBuilder;
+use datafusion::execution::runtime_env::{RuntimeEnv, RuntimeEnvBuilder};
 use datafusion::prelude::SessionConfig;
 
 use ballista_core::config::{LogRotationPolicy, TaskSchedulingPolicy};
@@ -70,36 +70,46 @@ use crate::executor_server::TERMINATING;
 use crate::flight_service::BallistaFlightService;
 use crate::metrics::ExecutorMetricCollectionPolicy;
 use crate::metrics::LoggingMetricsCollector;
+use crate::runtime_cache::{
+    DefaultSessionRuntimeCache, MemoryPoolPolicy, SessionRuntimeCache,
+};
 use crate::shutdown::Shutdown;
 use crate::shutdown::ShutdownNotifier;
 use crate::{ArrowFlightServerProvider, terminate};
 use crate::{execution_loop, executor_server};
 
-/// Wrap a [`RuntimeProducer`] so that every produced
-/// [`RuntimeEnv`](datafusion::execution::runtime_env::RuntimeEnv) carries a
-/// fresh [`FairSpillPool`] of size `total_bytes / concurrent_tasks`.
+/// Builds a per-task memory-pool policy: each task's runtime is rebuilt from 
the
+/// shared base env with a fresh [`FairSpillPool`] of size
+/// `total_bytes / concurrent_tasks`. The base env's disk manager, cache 
manager,
+/// and object-store registry are preserved via
+/// [`RuntimeEnvBuilder::from_runtime_env`].
 ///
 /// Returns an error if the per-task share would be zero (i.e. `total_bytes <
-/// concurrent_tasks`). The inner env's disk manager, cache manager, and
-/// object store registry are preserved via 
[`RuntimeEnvBuilder::from_runtime_env`].
-fn wrap_runtime_producer_with_memory_pool(
-    inner: RuntimeProducer,
+/// concurrent_tasks`).
+fn memory_pool_policy(
     total_bytes: u64,
     concurrent_tasks: usize,
-) -> Result<RuntimeProducer, BallistaError> {
+) -> Result<MemoryPoolPolicy, BallistaError> {
     let per_task = (total_bytes / concurrent_tasks as u64) as usize;
     if per_task == 0 {
         return Err(BallistaError::Configuration(format!(
             "memory_pool_size ({total_bytes} bytes) is smaller than 
concurrent_tasks ({concurrent_tasks})"
         )));
     }
-    Ok(Arc::new(move |session_config: &SessionConfig| {
-        let inner_env = inner(session_config)?;
-        let pool: Arc<dyn MemoryPool> = Arc::new(FairSpillPool::new(per_task));
-        RuntimeEnvBuilder::from_runtime_env(&inner_env)
-            .with_memory_pool(pool)
-            .build_arc()
-    }))
+    Ok(Arc::new(
+        move |base: Arc<RuntimeEnv>, _config: &SessionConfig| {
+            let pool: Arc<dyn MemoryPool> = 
Arc::new(FairSpillPool::new(per_task));
+            RuntimeEnvBuilder::from_runtime_env(&base)
+                .with_memory_pool(pool)
+                .build_arc()
+        },
+    ))
+}
+
+/// A no-op policy: the task uses the shared base env unchanged (DataFusion's
+/// default unbounded pool). Used when `--memory-pool-size` is unset.
+fn identity_pool_policy() -> MemoryPoolPolicy {
+    Arc::new(|base, _config| Ok(base))
 }
 
 /// Configuration for the executor process.
@@ -155,6 +165,11 @@ pub struct ExecutorProcessConfig {
     /// `memory_pool_size / concurrent_tasks`. When `None`, no pool is
     /// installed and DataFusion falls back to its unbounded default.
     pub memory_pool_size: Option<u64>,
+    /// Maximum number of sessions whose shared base runtime env is retained on
+    /// the executor (LRU). Sharing reuses object-store clients and the Parquet
+    /// footer cache across a session's tasks and queries. `0` disables caching
+    /// and builds a fresh runtime per task.
+    pub session_runtime_cache_capacity: usize,
     /// Optional execution engine to use to execute physical plans, will 
default to
     /// DataFusion if none is provided.
     pub override_execution_engine: Option<Arc<dyn ExecutionEngine>>,
@@ -214,6 +229,7 @@ impl Default for ExecutorProcessConfig {
             executor_heartbeat_interval_seconds: 60,
             metric_collection_policy: 
ExecutorMetricCollectionPolicy::default(),
             memory_pool_size: None,
+            session_runtime_cache_capacity: 16,
             override_execution_engine: None,
             override_function_registry: None,
             override_runtime_producer: None,
@@ -290,7 +306,7 @@ pub async fn start_executor_process(
         .unwrap_or_else(|| Arc::new(default_config_producer));
 
     let wd = work_dir.clone();
-    let runtime_producer: RuntimeProducer =
+    let base_runtime_producer: RuntimeProducer =
         opt.override_runtime_producer.clone().unwrap_or_else(|| {
             Arc::new(move |_| {
                 let runtime_env = RuntimeEnvBuilder::new()
@@ -300,19 +316,24 @@ pub async fn start_executor_process(
             })
         });
 
-    let runtime_producer = if let Some(total) = opt.memory_pool_size {
-        let producer = wrap_runtime_producer_with_memory_pool(
-            runtime_producer,
-            total,
-            concurrent_tasks,
-        )?;
+    let pool_policy: MemoryPoolPolicy = if let Some(total) = 
opt.memory_pool_size {
+        let policy = memory_pool_policy(total, concurrent_tasks)?;
         let per_task = total / concurrent_tasks as u64;
         info!(
             "Memory pool: total {total} bytes split into {concurrent_tasks} 
tasks ({per_task} bytes each)"
         );
-        producer
+        policy
     } else {
-        runtime_producer
+        identity_pool_policy()
+    };
+
+    // Combined producer preserving the current per-task behavior: build a 
fresh
+    // base env, then apply the pool policy. Used by 
`Executor::produce_runtime`
+    // and as the fallback when session caching is disabled.
+    let runtime_producer: RuntimeProducer = {
+        let base = base_runtime_producer.clone();
+        let policy = pool_policy.clone();
+        Arc::new(move |config: &SessionConfig| policy(base(config)?, config))
     };
 
     let logical = opt
@@ -330,26 +351,43 @@ pub async fn start_executor_process(
         datafusion_proto::protobuf::PhysicalPlanNode,
     > = BallistaCodec::new(logical, physical);
 
-    let executor = Arc::new(Executor::new(
-        executor_meta.clone(),
-        &work_dir,
-        runtime_producer,
-        config_producer,
-        opt.override_function_registry.clone().unwrap_or_default(),
-        metrics_collector,
-        concurrent_tasks,
-        opt.override_execution_engine.clone().unwrap_or_else(|| {
-            if opt.client_ttl > 0 {
-                let client_pool =
-                    Arc::new(DefaultBallistaClientPool::with_eviction_thread(
-                        Duration::from_secs(opt.client_ttl),
-                    ));
-                Arc::new(DefaultExecutionEngine::with_client_pool(client_pool))
-            } else {
-                Arc::new(DefaultExecutionEngine::new())
-            }
-        }),
-    ));
+    // Session caching applies only to the default runtime producer. With an
+    // override producer we cannot split base + pool, so we serve per-task as
+    // before by leaving the cache unset.
+    let session_runtime_cache: Option<Arc<dyn SessionRuntimeCache>> =
+        if opt.override_runtime_producer.is_none() {
+            Some(Arc::new(DefaultSessionRuntimeCache::new(
+                base_runtime_producer.clone(),
+                pool_policy.clone(),
+                opt.session_runtime_cache_capacity,
+            )))
+        } else {
+            None
+        };
+
+    let executor = Arc::new(
+        Executor::new(
+            executor_meta.clone(),
+            &work_dir,
+            runtime_producer,
+            config_producer,
+            opt.override_function_registry.clone().unwrap_or_default(),
+            metrics_collector,
+            concurrent_tasks,
+            opt.override_execution_engine.clone().unwrap_or_else(|| {
+                if opt.client_ttl > 0 {
+                    let client_pool =
+                        
Arc::new(DefaultBallistaClientPool::with_eviction_thread(
+                            Duration::from_secs(opt.client_ttl),
+                        ));
+                    
Arc::new(DefaultExecutionEngine::with_client_pool(client_pool))
+                } else {
+                    Arc::new(DefaultExecutionEngine::new())
+                }
+            }),
+        )
+        .with_session_runtime_cache(session_runtime_cache),
+    );
 
     let connect_timeout = opt.scheduler_connect_timeout_seconds as u64;
     let session_config = (executor.config_producer)();
@@ -1064,14 +1102,9 @@ mod memory_pool_tests {
     use datafusion::execution::runtime_env::{RuntimeEnv, RuntimeEnvBuilder};
     use std::sync::Arc;
 
-    fn baseline_producer() -> RuntimeProducer {
-        Arc::new(|_| Ok(Arc::new(RuntimeEnv::default())))
-    }
-
     #[test]
     fn returns_error_when_total_smaller_than_concurrent_tasks() {
-        let inner = baseline_producer();
-        let result = wrap_runtime_producer_with_memory_pool(inner, 4, 8);
+        let result = memory_pool_policy(4, 8);
         assert!(result.is_err());
         let msg = result.err().unwrap().to_string();
         assert!(msg.contains("memory_pool_size"));
@@ -1084,13 +1117,9 @@ mod memory_pool_tests {
         let concurrent = 8usize;
         let expected_per_task = (total / concurrent as u64) as usize;
 
-        let wrapped = wrap_runtime_producer_with_memory_pool(
-            baseline_producer(),
-            total,
-            concurrent,
-        )
-        .unwrap();
-        let env = wrapped(&SessionConfig::new()).unwrap();
+        let policy = memory_pool_policy(total, concurrent).unwrap();
+        let base = Arc::new(RuntimeEnv::default());
+        let env = policy(base, &SessionConfig::new()).unwrap();
 
         match env.memory_pool.memory_limit() {
             MemoryLimit::Finite(n) => assert_eq!(n, expected_per_task),
@@ -1100,20 +1129,26 @@ mod memory_pool_tests {
     }
 
     #[test]
-    fn preserves_inner_object_store_registry() {
+    fn preserves_base_object_store_registry() {
         let registry: Arc<dyn 
datafusion::execution::object_store::ObjectStoreRegistry> =
             Arc::new(DefaultObjectStoreRegistry::new());
-        let registry_for_inner = registry.clone();
-        let inner: RuntimeProducer = Arc::new(move |_| {
-            let env = RuntimeEnvBuilder::new()
-                .with_object_store_registry(registry_for_inner.clone())
-                .build()?;
-            Ok(Arc::new(env))
-        });
+        let base = Arc::new(
+            RuntimeEnvBuilder::new()
+                .with_object_store_registry(registry.clone())
+                .build()
+                .unwrap(),
+        );
 
-        let wrapped = wrap_runtime_producer_with_memory_pool(inner, 1024, 
1).unwrap();
-        let env = wrapped(&SessionConfig::new()).unwrap();
+        let policy = memory_pool_policy(1024, 1).unwrap();
+        let env = policy(base, &SessionConfig::new()).unwrap();
 
         assert!(Arc::ptr_eq(&env.object_store_registry, &registry));
     }
+
+    #[test]
+    fn identity_policy_returns_base_unchanged() {
+        let base = Arc::new(RuntimeEnv::default());
+        let env = identity_pool_policy()(base.clone(), 
&SessionConfig::new()).unwrap();
+        assert!(Arc::ptr_eq(&env, &base));
+    }
 }
diff --git a/ballista/executor/src/executor_server.rs 
b/ballista/executor/src/executor_server.rs
index e65dbb0e0..a78b006e9 100644
--- a/ballista/executor/src/executor_server.rs
+++ b/ballista/executor/src/executor_server.rs
@@ -386,7 +386,9 @@ impl<T: 'static + AsLogicalPlan, U: 'static + 
AsExecutionPlan> ExecutorServer<T,
             &task.session_config,
         );
 
-        let runtime = self.executor.produce_runtime(&task.session_config);
+        let runtime = self
+            .executor
+            .produce_runtime_for_session(&task.session_id, 
&task.session_config);
 
         match (exec, runtime) {
             (Ok(exec), Ok(runtime)) => {
diff --git a/ballista/executor/src/lib.rs b/ballista/executor/src/lib.rs
index f0d0f995e..70234ceaa 100644
--- a/ballista/executor/src/lib.rs
+++ b/ballista/executor/src/lib.rs
@@ -39,6 +39,8 @@ pub mod executor_server;
 pub mod flight_service;
 /// Metrics collection for executor runtime statistics.
 pub mod metrics;
+/// Session-scoped cache of shared executor runtime environments.
+pub mod runtime_cache;
 /// Graceful shutdown coordination for executor components.
 pub mod shutdown;
 /// Signal handling for process termination.
diff --git a/ballista/executor/src/runtime_cache.rs 
b/ballista/executor/src/runtime_cache.rs
new file mode 100644
index 000000000..2cc0e05fb
--- /dev/null
+++ b/ballista/executor/src/runtime_cache.rs
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Session-scoped reuse of executor runtime state.
+
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use ballista_core::RuntimeProducer;
+use datafusion::execution::runtime_env::RuntimeEnv;
+use datafusion::prelude::SessionConfig;
+use lru::LruCache;
+use parking_lot::Mutex;
+
+/// Derives a task's runtime from a shared base [`RuntimeEnv`] by installing a
+/// fresh per-task memory pool. The base's object-store registry and the cache
+/// manager's underlying file-metadata (footer) cache are preserved via
+/// 
[`RuntimeEnvBuilder::from_runtime_env`](datafusion::execution::runtime_env::RuntimeEnvBuilder::from_runtime_env)
+/// — the outer `CacheManager` is rebuilt around that same inner cache — so
+/// read-side state is shared while the memory pool stays per task.
+pub type MemoryPoolPolicy = Arc<
+    dyn Fn(Arc<RuntimeEnv>, &SessionConfig) -> 
datafusion::error::Result<Arc<RuntimeEnv>>
+        + Send
+        + Sync,
+>;
+
+/// Produces a task's [`RuntimeEnv`], optionally reusing read-side state across
+/// the tasks of a session.
+///
+/// The executor calls [`produce_runtime`](Self::produce_runtime) for every 
task;
+/// implementors decide whether and how to share base runtime state 
(object-store
+/// clients, file-metadata cache) between a session's tasks.
+/// [`DefaultSessionRuntimeCache`] is the built-in implementation — provide a
+/// custom one to change the caching/sharing strategy.
+pub trait SessionRuntimeCache: Send + Sync {
+    /// Returns the per-task runtime for `session_id`.
+    fn produce_runtime(
+        &self,
+        session_id: &str,
+        config: &SessionConfig,
+    ) -> datafusion::error::Result<Arc<RuntimeEnv>>;
+}
+
+/// A bounded, session-keyed cache of shared *base* [`RuntimeEnv`]s.
+///
+/// A base env carries the read-side state safe to share across all tasks of a
+/// session: the object-store registry and the cache manager (whose Parquet
+/// footer cache is thereby reused across the session's tasks and queries).
+/// `RuntimeEnvBuilder::from_runtime_env` also carries over the disk manager
+/// (rooted at the executor's `work_dir`), so a session's tasks share one
+/// `DiskManager` too; this is safe because spill temp files are uniquely
+/// named, matching the standard one-`RuntimeEnv`-per-`SessionContext` model.
+/// Each task's real runtime is produced by applying [`MemoryPoolPolicy`] to
+/// the shared base, which installs a fresh per-task memory pool — so memory
+/// isolation is unchanged.
+///
+/// The cache is bounded by an LRU of `capacity` sessions. A capacity of `0`
+/// disables caching entirely: every call builds a fresh base env, matching the
+/// behavior of building a runtime per task.
+pub struct DefaultSessionRuntimeCache {
+    base_producer: RuntimeProducer,
+    pool_policy: MemoryPoolPolicy,
+    cache: Option<Mutex<LruCache<String, Arc<RuntimeEnv>>>>,
+}
+
+impl DefaultSessionRuntimeCache {
+    /// Creates a new cache that produces per-task runtimes from 
`base_producer`
+    /// (invoked at most once per cached session) and `pool_policy` (invoked on
+    /// every call). `capacity` bounds the number of distinct sessions kept in
+    /// the LRU; `0` disables caching.
+    pub fn new(
+        base_producer: RuntimeProducer,
+        pool_policy: MemoryPoolPolicy,
+        capacity: usize,
+    ) -> Self {
+        let cache = NonZeroUsize::new(capacity).map(|cap| 
Mutex::new(LruCache::new(cap)));
+        Self {
+            base_producer,
+            pool_policy,
+            cache,
+        }
+    }
+}
+
+impl SessionRuntimeCache for DefaultSessionRuntimeCache {
+    /// Returns the per-task runtime for `session_id`, reusing a cached base 
env
+    /// when present and building + caching one on miss.
+    fn produce_runtime(
+        &self,
+        session_id: &str,
+        config: &SessionConfig,
+    ) -> datafusion::error::Result<Arc<RuntimeEnv>> {
+        let base = match &self.cache {
+            None => (self.base_producer)(config)?,
+            Some(cache) => {
+                if let Some(base) = cache.lock().get(session_id) {
+                    base.clone()
+                } else {
+                    // Build the base env without holding the lock, so a miss
+                    // never stalls other sessions' lookups. A rare concurrent
+                    // first-miss for the same session may build twice; that is
+                    // harmless (idempotent, cheap) — the last writer wins and
+                    // the extra env is dropped.
+                    let base = (self.base_producer)(config)?;
+                    cache.lock().put(session_id.to_string(), base.clone());
+                    base
+                }
+            }
+        };
+        (self.pool_policy)(base, config)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use datafusion::execution::memory_pool::GreedyMemoryPool;
+    use datafusion::execution::runtime_env::RuntimeEnvBuilder;
+
+    fn base_producer() -> RuntimeProducer {
+        Arc::new(|_| Ok(Arc::new(RuntimeEnv::default())))
+    }
+
+    /// Identity policy: the task shares the base env unchanged (no pool swap).
+    fn identity_policy() -> MemoryPoolPolicy {
+        Arc::new(|base, _| Ok(base))
+    }
+
+    /// Rebuilding policy: mimics production — a fresh per-task pool layered 
onto
+    /// the shared base, preserving the base's read-side state.
+    fn per_task_pool_policy() -> MemoryPoolPolicy {
+        Arc::new(|base, _| {
+            RuntimeEnvBuilder::from_runtime_env(&base)
+                .with_memory_pool(Arc::new(GreedyMemoryPool::new(1024)))
+                .build_arc()
+        })
+    }
+
+    #[test]
+    fn same_session_shares_cache_manager() {
+        let cache =
+            DefaultSessionRuntimeCache::new(base_producer(), 
identity_policy(), 4);
+        let cfg = SessionConfig::new();
+        let e1 = cache.produce_runtime("s1", &cfg).unwrap();
+        let e2 = cache.produce_runtime("s1", &cfg).unwrap();
+        assert!(Arc::ptr_eq(&e1.cache_manager, &e2.cache_manager));
+    }
+
+    #[test]
+    fn different_sessions_get_different_base() {
+        let cache =
+            DefaultSessionRuntimeCache::new(base_producer(), 
identity_policy(), 4);
+        let cfg = SessionConfig::new();
+        let e1 = cache.produce_runtime("s1", &cfg).unwrap();
+        let e2 = cache.produce_runtime("s2", &cfg).unwrap();
+        assert!(!Arc::ptr_eq(&e1.cache_manager, &e2.cache_manager));
+    }
+
+    #[test]
+    fn per_task_pool_shares_footer_cache_but_not_env() {
+        let cache =
+            DefaultSessionRuntimeCache::new(base_producer(), 
per_task_pool_policy(), 4);
+        let cfg = SessionConfig::new();
+        let e1 = cache.produce_runtime("s1", &cfg).unwrap();
+        let e2 = cache.produce_runtime("s1", &cfg).unwrap();
+        // Different runtime envs (fresh per-task pool)...
+        assert!(!Arc::ptr_eq(&e1, &e2));
+        // ...but the shared read-side state is reused: object-store registry
+        // passes through unchanged, and the inner footer (file-metadata) cache
+        // is the same instance even though the outer CacheManager wrapper is
+        // rebuilt. Do NOT compare the outer Arc<CacheManager>.
+        assert!(Arc::ptr_eq(
+            &e1.object_store_registry,
+            &e2.object_store_registry
+        ));
+        assert!(Arc::ptr_eq(
+            &e1.cache_manager.get_file_metadata_cache(),
+            &e2.cache_manager.get_file_metadata_cache(),
+        ));
+    }
+
+    #[test]
+    fn capacity_zero_disables_cache() {
+        let cache =
+            DefaultSessionRuntimeCache::new(base_producer(), 
identity_policy(), 0);
+        let cfg = SessionConfig::new();
+        let e1 = cache.produce_runtime("s1", &cfg).unwrap();
+        let e2 = cache.produce_runtime("s1", &cfg).unwrap();
+        assert!(!Arc::ptr_eq(&e1.cache_manager, &e2.cache_manager));
+    }
+
+    #[test]
+    fn evicts_least_recently_used() {
+        let cache =
+            DefaultSessionRuntimeCache::new(base_producer(), 
identity_policy(), 2);
+        let cfg = SessionConfig::new();
+        let s1_a = cache.produce_runtime("s1", &cfg).unwrap();
+        cache.produce_runtime("s2", &cfg).unwrap();
+        // Inserting s3 (capacity 2) evicts the least-recently-used, s1.
+        cache.produce_runtime("s3", &cfg).unwrap();
+        let s1_b = cache.produce_runtime("s1", &cfg).unwrap();
+        assert!(!Arc::ptr_eq(&s1_a.cache_manager, &s1_b.cache_manager));
+    }
+}
diff --git a/python/Cargo.lock b/python/Cargo.lock
index ca63ef385..e7d0ef3c9 100644
--- a/python/Cargo.lock
+++ b/python/Cargo.lock
@@ -548,6 +548,7 @@ dependencies = [
  "futures",
  "libc",
  "log",
+ "lru",
  "memory-stats",
  "parking_lot",
  "serde",
@@ -1847,7 +1848,7 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2617,6 +2618,15 @@ version = "0.4.33"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
 
+[[package]]
+name = "lru"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
 [[package]]
 name = "lru-slab"
 version = "0.1.2"
@@ -3504,7 +3514,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3784,7 +3794,7 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
 dependencies = [
  "libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3859,7 +3869,7 @@ dependencies = [
  "cfg-if",
  "libc",
  "psm",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3938,10 +3948,10 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
  "once_cell",
  "rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -4545,7 +4555,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to