Skip to content

feat: share Parquet metadata cache across tasks in an executor#4995

Draft
andygrove wants to merge 10 commits into
apache:mainfrom
andygrove:share-parquet-metadata-across-tasks
Draft

feat: share Parquet metadata cache across tasks in an executor#4995
andygrove wants to merge 10 commits into
apache:mainfrom
andygrove:share-parquet-metadata-across-tasks

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #4994. This is item P5 of #4842.

Rationale for this change

Comet's native Parquet scan takes its FileMetadataCache from the task's RuntimeEnv, and Comet builds a fresh RuntimeEnv for every Spark task, so the cached footer and page index are discarded when the task ends. Two tasks on one executor re-fetch and re-parse the same footer, which costs a round trip to object storage plus a Thrift parse per task. This happens whenever a large file is split across tasks, and again whenever a later stage or query rescans the same table.

Sharing the cache across tasks also lowers memory use: the 50MB budget was previously allocated per task, so an executor with 8 task slots could hold 8 budgets at once.

Making the cache outlive a task requires its validity check to work first. DataFusion validates cached metadata against (size, last_modified), but Comet builds PartitionedFile with PartitionedFile::new_with_range, which hardcodes last_modified to epoch 0, and SparkPartitionedFile carried no modification time. Validation was therefore size-only. That is harmless for a cache that lives for one task and a wrong-results hazard for one that lives for the executor process, so the modification time is plumbed through as part of this change rather than separately.

What changes are included in this PR?

Carry the file modification time to the native side:

  • SparkPartitionedFile gains a modification_time field, populated from Spark's PartitionedFile.modificationTime in partition2Proto.
  • PhysicalPlanner::get_partitioned_files sets object_meta.last_modified from it, so DataFusion's (size, last_modified) validation is real.

Share the cache:

  • New native/core/src/parquet/metadata_cache.rs holds a process-wide registry of FileMetadataCache instances, keyed by object store URL. It parallels the existing process-wide object_store_cache in parquet_support.rs, including its rationale for process lifetime. Keying per object store matters because DefaultFilesMetadataCache keys entries on a bare object_store::path::Path, and Comet builds that path from url.path(), which drops the bucket.
  • init_datasource_exec uses the registry, falling back to the per-task RuntimeEnv cache when sharing is disabled.

Configuration and docs:

  • spark.comet.scan.metadataCache.enabled (default true) and spark.comet.scan.metadataCache.memoryLimit (default 50MB, matching DataFusion's own default). Both are injected explicitly in serializeCometSQLConfs, since SQLConf.getAllConfs carries only explicitly-set values.
  • A tuning guide section covering the memory scope, eviction, and validation.

Two known limitations are documented rather than fixed here. The memory limit bounds each object store's cache within an executor, so an executor reading several buckets can hold a multiple of it. And for abfs:// URLs the container is URL userinfo rather than host, so Comet's object store URL key does not separate two containers in one storage account; those entries rely on (size, last_modified) validation. The second limitation stems from a pre-existing issue in the object store cache itself, filed as #4993.

How are these changes tested?

New Rust tests:

  • metadata_cache.rs: the same URL returns one instance, different buckets get separate instances, a disabled registry returns None, the configured limit reaches new and live caches, and concurrent callers on one URL all receive the same instance.
  • parquet_exec.rs: two independent SessionContexts (standing in for two tasks) over one file, asserting the second sees a cache hit through the entry's hit counter; and a file whose last_modified advances, asserting the cached entry is replaced rather than served stale. The existing caches_full_metadata_with_page_index regression test now targets the shared cache.
  • planner.rs: the modification time from the protobuf reaches object_meta.last_modified.

New Scala tests:

  • CometFilePartitionSerdeSuite: the modification time is serialized into the protobuf.
  • CometMetadataCacheSuite: both configs cross JNI with their defaults; a repeated read of the same files exercises a shared-cache hit across queries in one process; and a scan with sharing disabled exercises the per-task fallback path.

Also verified locally: make build, cargo clippy --all-targets --workspace -- -D warnings, cargo fmt, spotless:check, the native parquet (92) and planner (16) test modules, and ParquetReadV1Suite (51).

Hold the config write lock for the whole configure() operation,
including the loop that applies the new limit to live caches, so
concurrent configure() calls with different limits can no longer
race and leave a cache on a stale limit that the idempotency check
would then never revisit.

Add a test that exercises cache_for() from multiple threads
concurrently and asserts they all observe the same cache instance.
cache_for copied config.memory_limit into a local and released the
config read guard before acquiring the caches write lock. A concurrent
configure() could run entirely in that window, updating config and
resyncing every cache already in the map, none of which yet included
the cache cache_for was about to insert. The new cache would then be
permanently stuck on the old limit, with configure's idempotency
short-circuit never revisiting it.

Hold the config read guard for the whole of cache_for, including the
caches write lock used to insert a new cache, so a concurrent
configure blocks until the insert is complete and visible to its
cache scan. Acquisition order stays config-before-caches on both
paths, so this cannot deadlock with configure.
Fix code review findings on the shared Parquet metadata cache:
correct the false claim that keying by object store URL removes all
bucket collisions (ABFS containers on one storage account still
share a key, since the container is URL userinfo, not host), correct
the memory limit doc to state it bounds each object store's cache
rather than one process-wide budget, note that the limit only applies
when sharing is enabled, note that the enabled flag is process-global
and last-writer-wins, note that staleness validation degrades to
size-only without a reported modification time, and document the
epoch-0 last_modified risk in the dormant single-file JNI path.

Add tests covering a read-after-overwrite in the same session and the
per-task fallback scan path when cache sharing is disabled.
The overwrite test previously asserted results only around a
overwrite, but write.mode("overwrite") produces new file names, so
those reads never hit the same cache entry and the test exercised
nothing the shared cache is involved in. Read the same files twice
before the overwrite to cover an actual shared-cache hit, and correct
the comment to describe what the overwrite step does and does not
prove. Use checkSparkAnswer so each read is also checked against
vanilla Spark, consistent with the rest of the suite.
@mbutrovich

Copy link
Copy Markdown
Contributor

I remember @comphead tried this and we always ran into a Spark SQL test failure where it creates a file, reads it, deletes it, then reads it again. We'd fail for serving the query from metadata (i.e., cache invalidation wasn't managed).

@andygrove

Copy link
Copy Markdown
Member Author

I remember @comphead tried this and we always ran into a Spark SQL test failure where it creates a file, reads it, deletes it, then reads it again. We'd fail for serving the query from metadata (i.e., cache invalidation wasn't managed).

Yup, I hit the same issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Share Parquet footer and page index metadata across tasks in an executor

2 participants