andygrove commented on code in PR #7082:
URL: https://github.com/apache/datafusion-comet/pull/7082#discussion_r4308772382
##########
native/core/src/execution/jni_api.rs:
##########
@@ -1009,6 +1011,14 @@ fn prepare_datafusion_session_context(
.with_object_store_registry(Arc::new(CometObjectStoreRegistry::default()));
rt_config = rt_config.with_memory_pool(memory_pool);
+ // Let the tasks of one stage on this executor share the Parquet footer
and page index cache
+ // instead of each fetching and parsing the footers of the files it reads.
+ if let Some(cache) = shared_metadata_cache_for_task(spark_config) {
+ rt_config = rt_config.with_cache_manager(
+
CacheManagerConfig::default().with_file_metadata_cache(Some(cache)),
Review Comment:
Good catch. DataFusion keys the cache by `ObjectMeta.location`, and the
location of a scanned file has no bucket, so equal-size files at the same path
in two stores passed the size check and shared a footer.
Each scan now looks up footers through a view of the cache that also keys by
the scan's registered object-store URL (`scope_to_store`). That URL already
carries the backend and a hash of the store config, so I didn't add a separate
identity. It's applied at `parquet_exec.rs:230` and in the Delta
deletion-vector footer read, which gets the scan's URL through
`attach_access_plans` so both find the same entries. The view is used whether
sharing is on or off.
The regression is `equal_paths_in_two_stores_do_not_share_footers`. It puts
equal-size files holding `[1,2,3]` and `[101,102,103]` at the same path in two
stores and scans each with `a > 100`, with the cache shared and not shared. It
fails without the scoping (`[[], []]`). It runs two sequential scans rather
than a UNION. Is that enough for what you had in mind?
##########
native/core/src/parquet/shared_metadata_cache.rs:
##########
@@ -0,0 +1,187 @@
+// 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.
+
+//! A registry that lets the tasks of one Spark stage share a Parquet
`FileMetadataCache`.
+//!
+//! Comet builds a `RuntimeEnv`, and with it a metadata cache, for every Spark
task, so two tasks
+//! that read splits of the same file each fetch and parse its footer and page
index. This
+//! registry hands every task of a stage on this executor the same cache
instead.
+//!
+//! The scope is the stage, not the executor, on purpose. A cache that
outlives the query would
+//! answer a later query from metadata of a file that has since been deleted
or rewritten, and
+//! `(size, modification time)` validation cannot catch that: Spark's cached
file index lists the
+//! same size and time as the cached footer. Spark's `HiveMetadataCacheSuite`
reads a table,
+//! deletes one data file, reads again, and expects `FILE_NOT_EXIST`. Every
new query is a new
+//! stage, so it opens its files and sees the deletion, as it does without the
registry.
+//!
+//! A stage attempt is part of the key because a retried stage may read a
changed file.
+//!
+//! The registry keeps the cache of the stages that still have a running task,
plus the last
+//! [`MAX_IDLE_CACHES`] that do not. The idle ones matter when an executor
runs one task at a
+//! time: the next task of the stage starts after the previous one released
its plan.
+
+use crate::execution::spark_config::{
+ SparkConfig, COMET_PARQUET_SHARED_METADATA_CACHE_ENABLED,
COMET_STAGE_ATTEMPT, COMET_STAGE_ID,
+};
+use datafusion::execution::cache::cache_manager::{
+ FileMetadataCache, DEFAULT_METADATA_CACHE_LIMIT,
+};
+use datafusion::execution::cache::default_cache::DefaultCache;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex, OnceLock};
+
+/// How many caches of stages with no running task the registry holds on to.
+const MAX_IDLE_CACHES: usize = 2;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct StageKey {
+ pub stage_id: i32,
+ pub stage_attempt: i32,
+}
+
+struct Entry {
+ key: StageKey,
+ cache: Arc<FileMetadataCache>,
+}
+
+#[derive(Default)]
+pub(crate) struct SharedMetadataCaches {
+ /// Ordered from least to most recently requested.
+ entries: Mutex<Vec<Entry>>,
+}
+
+impl SharedMetadataCaches {
+ /// The cache shared by the tasks of `key`, created if no task of that
stage has asked for one
+ /// yet.
+ fn cache_for(&self, key: StageKey) -> Arc<FileMetadataCache> {
+ let mut entries = self.entries.lock().unwrap();
+ let entry = match entries.iter().position(|e| e.key == key) {
+ Some(pos) => entries.remove(pos),
+ None => Entry {
+ key,
+ cache: Arc::new(
+ DefaultCache::new(DEFAULT_METADATA_CACHE_LIMIT)
+ .with_name("SharedFileMetadataCache"),
+ ),
+ },
+ };
+ let cache = Arc::clone(&entry.cache);
+ entries.push(entry);
+
+ // The registry holds one reference; a task holds another for as long
as its
+ // `RuntimeEnv` lives. The newest entry also has the reference
returned from here, which
+ // the task takes over, so it counts as idle at two. The newest entry
is never evicted.
+ let newest = entries.len() - 1;
+ let is_idle = |index: usize, entry: &Entry| {
+ Arc::strong_count(&entry.cache) == if index == newest { 2 } else {
1 }
Review Comment:
You're right. The bound was only enforced on the next `cache_for` call, so
dropping 16 handles left 16 caches pinned.
I took the weak-reference model you suggested. The registry now keeps a
`Weak` for every cache, which the running tasks own through their `RuntimeEnv`,
plus a queue of strong references to the last two it handed out, refreshed on
acquisition. Expired weak entries are pruned on each request, and the
`strong_count` arithmetic is gone. At most `MAX_IDLE_CACHES` caches outlive
their tasks, and sequential tasks of one stage still get hits.
I added your test as `idle_retention_is_bounded_after_all_tasks_finish`,
plus one that checks dropped caches are forgotten on the next request.
##########
native/core/src/parquet/shared_metadata_cache.rs:
##########
@@ -0,0 +1,187 @@
+// 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.
+
+//! A registry that lets the tasks of one Spark stage share a Parquet
`FileMetadataCache`.
+//!
+//! Comet builds a `RuntimeEnv`, and with it a metadata cache, for every Spark
task, so two tasks
+//! that read splits of the same file each fetch and parse its footer and page
index. This
+//! registry hands every task of a stage on this executor the same cache
instead.
+//!
+//! The scope is the stage, not the executor, on purpose. A cache that
outlives the query would
+//! answer a later query from metadata of a file that has since been deleted
or rewritten, and
+//! `(size, modification time)` validation cannot catch that: Spark's cached
file index lists the
+//! same size and time as the cached footer. Spark's `HiveMetadataCacheSuite`
reads a table,
+//! deletes one data file, reads again, and expects `FILE_NOT_EXIST`. Every
new query is a new
+//! stage, so it opens its files and sees the deletion, as it does without the
registry.
+//!
+//! A stage attempt is part of the key because a retried stage may read a
changed file.
+//!
+//! The registry keeps the cache of the stages that still have a running task,
plus the last
+//! [`MAX_IDLE_CACHES`] that do not. The idle ones matter when an executor
runs one task at a
+//! time: the next task of the stage starts after the previous one released
its plan.
+
+use crate::execution::spark_config::{
+ SparkConfig, COMET_PARQUET_SHARED_METADATA_CACHE_ENABLED,
COMET_STAGE_ATTEMPT, COMET_STAGE_ID,
+};
+use datafusion::execution::cache::cache_manager::{
+ FileMetadataCache, DEFAULT_METADATA_CACHE_LIMIT,
+};
+use datafusion::execution::cache::default_cache::DefaultCache;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex, OnceLock};
+
+/// How many caches of stages with no running task the registry holds on to.
+const MAX_IDLE_CACHES: usize = 2;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct StageKey {
+ pub stage_id: i32,
+ pub stage_attempt: i32,
+}
Review Comment:
Agreed, stage ids restart at zero in every `SparkContext` and the registry
outlives a context stopped in the same JVM.
`serializeCometSQLConfs` now sends `spark.app.id` with the stage fields, and
it's part of the key. If there's no app id, sharing is off for that task rather
than guessing.
I ran the restart scenario. `CometSharedMetadataCacheRestartSuite` stops and
recreates the local context, replaces an equal-size file at the same path, and
counts rows with `id >= 1000`. With the app id taken out of the key it fails
with `0 did not equal 1000`, because the stale footer prunes every row. With
it, the suite passes.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]