Skip to content

fix: Validate file cache identity and isolate object stores - #25858

Open
limadog9 wants to merge 1 commit into
apache:mainfrom
limadog9:fix/cache-object-identity
Open

limadog9 wants to merge 1 commit into
apache:mainfrom
limadog9:fix/cache-object-identity

Conversation

@limadog9

@limadog9 limadog9 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #25841.

Rationale for this change

An overwrite can keep the same file size and modification time while changing its statistics and Parquet footer. Different object stores can also contain the same relative path with identical metadata. Reusing either cache entry can produce incorrect query results.

What changes are included in this PR?

  • Validate cached statistics and Parquet metadata using strong ETags when both are available, otherwise shared versions, otherwise size and modification time. Schema validation still applies independently. Weak HTTP ETags are treated as missing, and opaque ETag values are compared verbatim.
  • Include the object store URL in statistics, listing, and Parquet metadata cache keys, and carry it through schema inference, statistics inference, and execution readers. TableScopedPath composes the new StoreScopedPath key.
  • Keep anonymous listing-cache invalidation within the affected object store, and compute the store URL once per scan.
  • Expose object_store_url in all three CLI cache functions while keeping their path columns relative to the store.
  • Document the public API and CLI changes in the upgrade guide and CLI reference.
  • Remove a redundant semicolon flagged by strict Windows linting.

Validation still depends on current object metadata; cached listings and changes between planning and execution retain the consistency limitations described in the issue.

What is the testing strategy for this PR?

Regression tests cover identifier precedence, weak and unavailable ETags, schema changes, equal-size overwrites at an unchanged timestamp, and separate stores with identical object metadata. DataFrame tests assert correct aggregates and filtered scans. Cache tests verify isolation, reuse, and listing invalidation. A CLI regression test asserts exact relative paths and store URLs in all three cache functions for matching filenames in two stores.

Passed locally:

  • cargo test --profile ci -p datafusion-execution -p datafusion-datasource -p datafusion-datasource-parquet -p datafusion-cli --lib --bins cache --no-fail-fast (59 tests)
  • cargo test --profile ci -p datafusion -p datafusion-cli --test core_integration datasource:: (26 tests)
  • Strict Clippy (--lib --tests --all-features -- -D warnings) for datafusion, datafusion-execution, datafusion-catalog-listing, datafusion-datasource, datafusion-datasource-parquet, and datafusion-cli.
  • cargo fmt --all -- --check
  • ./ci/scripts/doc_prettier_check.sh --write --allow-dirty

Are there any user-facing changes?

File caches no longer reuse entries from another object store or from a changed strong ETag/version when fresh metadata is available. CLI cache functions expose the store URL separately from the relative path.

API changes: FileFormat inference methods now take an object store URL; TableScopedPath contains a StoreScopedPath; FileMetadataCache uses StoreScopedPath; and the cached Parquet reader factory/cache builders require the URL. See docs/source/library-user-guide/upgrading/56.0.0.md for migration details.

@github-actions github-actions Bot added documentation Improvements or additions to documentation core Core DataFusion crate catalog Related to the catalog crate common Related to common crate execution Related to the execution crate datasource Changes to the datasource crate labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning da11e68a327b00530539e907427d56853931cf80
    Building datafusion v55.1.0 (current)
       Built [  47.793s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.027s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  15.154s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.027s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.477s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  65.604s] datafusion
    Building datafusion-catalog-listing v55.1.0 (current)
       Built [  16.294s] (current)
     Parsing datafusion-catalog-listing v55.1.0 (current)
      Parsed [   0.007s] (current)
    Building datafusion-catalog-listing v55.1.0 (baseline)
       Built [  12.712s] (baseline)
     Parsing datafusion-catalog-listing v55.1.0 (baseline)
      Parsed [   0.006s] (baseline)
    Checking datafusion-catalog-listing v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.064s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  30.037s] datafusion-catalog-listing
    Building datafusion-cli v55.1.0 (current)
       Built [  48.478s] (current)
     Parsing datafusion-cli v55.1.0 (current)
      Parsed [   0.025s] (current)
    Building datafusion-cli v55.1.0 (baseline)
       Built [  17.360s] (baseline)
     Parsing datafusion-cli v55.1.0 (baseline)
      Parsed [   0.026s] (baseline)
    Checking datafusion-cli v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.113s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  68.659s] datafusion-cli
    Building datafusion-datasource v55.1.0 (current)
       Built [  14.418s] (current)
     Parsing datafusion-datasource v55.1.0 (current)
      Parsed [   0.025s] (current)
    Building datafusion-datasource v55.1.0 (baseline)
       Built [  13.814s] (baseline)
     Parsing datafusion-datasource v55.1.0 (baseline)
      Parsed [   0.025s] (baseline)
    Checking datafusion-datasource v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.209s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  29.413s] datafusion-datasource
    Building datafusion-datasource-parquet v55.1.0 (current)
       Built [  17.381s] (current)
     Parsing datafusion-datasource-parquet v55.1.0 (current)
      Parsed [   0.026s] (current)
    Building datafusion-datasource-parquet v55.1.0 (baseline)
       Built [  13.660s] (baseline)
     Parsing datafusion-datasource-parquet v55.1.0 (baseline)
      Parsed [   0.027s] (baseline)
    Checking datafusion-datasource-parquet v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.120s] 229 checks: 227 pass, 2 fail, 0 warn, 31 skip

--- failure auto_trait_impl_removed: auto trait no longer implemented ---

Description:
A public type has stopped implementing one or more auto traits. This can break downstream code that depends on the traits being implemented.
        ref: https://doc.rust-lang.org/reference/special-types-and-traits.html#auto-traits
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/auto_trait_impl_removed.ron

Failed in:
  type ParquetFormat is no longer UnwindSafe, in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/file_format.rs:147
  type ParquetFormat is no longer RefUnwindSafe, in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/file_format.rs:147
  type ParquetFormat is no longer UnwindSafe, in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/file_format.rs:147
  type ParquetFormat is no longer RefUnwindSafe, in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/file_format.rs:147

--- failure method_parameter_count_changed: pub method parameter count changed ---

Description:
A publicly-visible method now takes a different number of parameters, not counting the receiver (self) parameter.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#fn-change-arity
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/method_parameter_count_changed.ron

Failed in:
  datafusion_datasource_parquet::CachedParquetFileReaderFactory::new takes 2 parameters in /home/runner/work/datafusion/datafusion/target/semver-checks-shared/semver-checks/git-da11e68a327b00530539e907427d56853931cf80/ef6e6db7448a32cd101cbd39979eb521330961b1/datafusion/datasource-parquet/src/reader.rs:150, but now takes 3 parameters in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/reader.rs:153

     Summary semver requires new major version: 2 major and 0 minor checks failed
    Finished [  32.227s] datafusion-datasource-parquet
    Building datafusion-execution v55.1.0 (current)
       Built [   7.177s] (current)
     Parsing datafusion-execution v55.1.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-execution v55.1.0 (baseline)
       Built [   4.166s] (baseline)
     Parsing datafusion-execution v55.1.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-execution v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.216s] 229 checks: 227 pass, 2 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field TableScopedPath.store_path in /home/runner/work/datafusion/datafusion/datafusion/execution/src/cache/mod.rs:196
  field TableScopedPath.store_path in /home/runner/work/datafusion/datafusion/datafusion/execution/src/cache/mod.rs:196

--- failure struct_pub_field_missing: pub struct's pub field removed or renamed ---

Description:
A publicly-visible struct has at least one public field that is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/struct_pub_field_missing.ron

Failed in:
  field path of struct TableScopedPath, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks-shared/semver-checks/git-da11e68a327b00530539e907427d56853931cf80/ef6e6db7448a32cd101cbd39979eb521330961b1/datafusion/execution/src/cache/mod.rs:152
  field path of struct TableScopedPath, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks-shared/semver-checks/git-da11e68a327b00530539e907427d56853931cf80/ef6e6db7448a32cd101cbd39979eb521330961b1/datafusion/execution/src/cache/mod.rs:152

     Summary semver requires new major version: 2 major and 0 minor checks failed
    Finished [  12.489s] datafusion-execution

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 29, 2026
@codecov-commenter

codecov-commenter commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.36266% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.95%. Comparing base (d89a85d) to head (2ef0653).
⚠️ Report is 131 commits behind head on main.

Files with missing lines Patch % Lines
...afusion/core/src/datasource/file_format/parquet.rs 91.17% 0 Missing and 9 partials ⚠️
datafusion/execution/src/cache/cache_manager.rs 94.30% 9 Missing ⚠️
datafusion/datasource/src/url.rs 86.88% 0 Missing and 8 partials ⚠️
datafusion/core/src/datasource/file_format/csv.rs 54.54% 0 Missing and 5 partials ⚠️
datafusion/catalog-listing/src/table.rs 90.62% 0 Missing and 3 partials ⚠️
datafusion/datasource-arrow/src/file_format.rs 0.00% 0 Missing and 2 partials ⚠️
datafusion/datasource-parquet/src/metadata.rs 98.13% 0 Missing and 2 partials ⚠️
datafusion/datasource-parquet/src/source.rs 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25858      +/-   ##
==========================================
+ Coverage   82.93%   82.95%   +0.02%     
==========================================
  Files        1154     1154              
  Lines      457933   458638     +705     
  Branches   457933   458638     +705     
==========================================
+ Hits       379786   380463     +677     
- Misses      55128    55136       +8     
- Partials    23019    23039      +20     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@villebro villebro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If ETag or version are available, I feel we should IMO not need to compare against last_modified or size. WDYT?

Comment thread datafusion/execution/src/cache/cache_manager.rs Outdated
Comment thread datafusion/common/src/rounding.rs

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @limadog9, this looks like the right approach to me. Putting the object store URL in the cache keys, rather than checking it on the cached value, means files at the same path in different stores get separate entries instead of evicting each other. Sharing one comparison function between both caches, with a table-driven test over every ETag × version combination, is nice too.

The keys are built from the same URL on the planning path (ListingOptions::infer_schema, ListingTable statistics) and the execution path (ParquetFormat::create_physical_plan and the proto ParquetSource path), so metadata cached during planning is still reused at execution.

I left one comment I think should be addressed before merging: datafusion-cli's metadata_cache() output changes, see the comment on Display for ObjectStorePath. The others are nits. I also added some object_store caveats to the existing thread on the ETag rule.

This is an API change. The semver check flags the FileFormat inference methods, CachedParquetFileReaderFactory::new, DFParquetMetadata::with_file_metadata_cache, ParquetFileReader::with_metadata_cache and TableScopedPath, and the upgrade guide covers all of them.

Comment thread datafusion/execution/src/cache/mod.rs Outdated
Comment thread datafusion/execution/src/cache/mod.rs Outdated
Comment thread datafusion/catalog-listing/src/table.rs Outdated
Comment thread datafusion/datasource/src/url.rs Outdated
@limadog9

limadog9 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews, @andygrove and @villebro. I've updated the PR to address your feedback, including the CLI cache output and ETag handling. Thanks for taking another look.

@villebro villebro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated cache_manager.rs changes, LGTM. Referring to @andygrove to review other changes.

@limadog9

Copy link
Copy Markdown
Contributor Author

Hi @andygrove, following up on the cache identity fix. The updates for the CLI cache output and ETag handling are in, and Villebro has approved the cache manager changes. Could you take another look at the remaining changes when you have a chance?

The latest Actions runs are still action_required; could a maintainer approve CI? GitHub is also reporting merge conflicts with main, so a rebase is needed before merging. Thanks!

@limadog9
limadog9 force-pushed the fix/cache-object-identity branch from c6fdf87 to 2ef0653 Compare October 20, 2026 11:27
@github-actions github-actions Bot removed the common Related to common crate label Oct 20, 2026

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this. Scoping the metadata, statistics and listing caches by object store and validating by ETag is the right direction, and parquet_caches_separate_object_stores and parquet_caches_refresh_changed_etag are good tests. I have one correctness gap and a few smaller points.

1. Re-registering a store under the same URL keeps the old store's entries valid

The caches are keyed on ObjectStoreUrl, not on the store instance. If register_object_store replaces the store for a URL (new credentials, a different endpoint, a repointed mem://), the old entries stay in the cache. If the new store has a file with the same path, size, mtime and ETag, it gets the old store's footer and statistics.

This reproduces on the PR head (2ef06534). The test below fails with the old data (expected min=7 max=9, actual min=1 max=3):

Test (add to cache_object_identity.rs)
#[tokio::test]
async fn parquet_caches_reregistered_object_store() {
    let first = Arc::new(FixedTimeStore::default());
    let second = Arc::new(FixedTimeStore::default());
    let path = Path::from("data.parquet");
    let first_bytes = parquet_bytes(vec![1, 2, 3]);
    let second_bytes = parquet_bytes(vec![7, 8, 9]);
    assert_eq!(first_bytes.len(), second_bytes.len());
    first.put(&path, first_bytes.into()).await.unwrap();
    second.put(&path, second_bytes.into()).await.unwrap();
    // The two stores are indistinguishable by ObjectMeta, ETag included.
    assert_eq!(
        first.head(&path).await.unwrap(),
        second.head(&path).await.unwrap()
    );

    let ctx = SessionContext::new();
    let url = Url::parse("mem://reused").unwrap();
    ctx.runtime_env().register_object_store(&url, first);
    let first_result = aggregate_file(&ctx, "mem://reused/data.parquet").await;
    assert_batches_eq!(
        [
            "+-----+-----+-------+",
            "| min | max | count |",
            "+-----+-----+-------+",
            "| 1   | 3   | 3     |",
            "+-----+-----+-------+",
        ],
        &first_result
    );

    // Point the same URL at a different backend holding different bytes.
    ctx.runtime_env().register_object_store(&url, second);
    let second_result = aggregate_file(&ctx, "mem://reused/data.parquet").await;
    assert_batches_eq!(
        [
            "+-----+-----+-------+",
            "| min | max | count |",
            "+-----+-----+-------+",
            "| 7   | 9   | 3     |",
            "+-----+-----+-------+",
        ],
        &second_result
    );
}

The setup is narrow, since the ETag and size must also match. Still, the PR title says it isolates object stores, and this is the remaining way for one store's cache entries to reach another. I haven't checked the base commit; I'd expect the same stale result there, so this is probably a gap rather than a regression. Possible fixes: evict entries for the URL when register_object_store replaces a store, or include a per-registration identity in the key.

2. A matching ETag skips the size and mtime check (cache_manager.rs, file_metadata_matches)

This is a design question, not a bug I can show. A non-weak ETag match now returns true without looking at size or mtime, and a matching version does the same. That is correct for stores whose ETags are content or version based (S3, GCS, Azure), but it trusts every store to return unique ETags, which the old check did not. Requiring size and mtime to agree as well would cost nothing in correct cases and keep the old safety net. I'm not asking for a change if you'd rather keep this.

3. Public API changes

  • FileFormat::infer_stats and infer_ordering gain an &ObjectStoreUrl parameter. Only Parquet uses it (the other formats take _object_store_url), and every downstream FileFormat implementation stops compiling. Could the store scope be applied where the cache handle is created instead, so the trait doesn't change?
  • FileMetadataCache is now keyed on StoreScopedPath, and TableScopedPath.path became store_path.path. A TableScopedPath::new(...) constructor or path() accessor would ease migration. The 56.0.0 upgrade guide helps, but source-level compatibility is lost.
  • datafusion-cli: object_store_url is inserted right after path in the metadata_cache() and statistics_cache() schemas (and the list-files one), which shifts columns for positional consumers. Appending it would keep them working.

4. Minor

  • Each cache get and put builds a StoreScopedPath, cloning the URL and path, per file, and list_with_cache re-parses the URL string twice. Holding an Arc<ObjectStoreUrl> computed once would avoid this. I haven't measured it.
  • with_metadata_cache and with_file_metadata_cache require a URL even when the cache is None. Passing the pair as Option<(Arc<cache>, ObjectStoreUrl)> would make that invalid state unrepresentable.

Item 1 is the one I'd like resolved or explicitly scoped out before merge. The rest are suggestions.

Scope listing, statistics and Parquet metadata by object store URL and
validate size, modification time, ETag/version and schema independently.
Invalidate the affected scope when runtime registrations change, while
retaining entries for other stores.

Preserve FileFormat inference signatures through optional scoped cache
configuration. Share store URLs, reuse metadata keys, provide cache-key
migration helpers and append the CLI store column to preserve positions.

Cover same-URL replacement, deregistration, stale listings, named and
anonymous entries, unchanged metadata across stores and CLI column order.
@limadog9
limadog9 force-pushed the fix/cache-object-identity branch from 2ef0653 to 177efad Compare October 25, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change catalog Related to the catalog crate core Core DataFusion crate datasource Changes to the datasource crate documentation Improvements or additions to documentation execution Related to the execution crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate cached file statistics and Parquet metadata with e_tag, not just size and mtime

4 participants