Skip to content

Commit 32e1146

Browse files
refactor: make file-statistics cache keys schema-aware (#23201)
## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes #123` indicates that this PR will close issue #123. --> - Closes #23072. ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> File statistics are computed against a specific `file_schema` (their `column_statistics` are positional, one per column), but the file-statistics cache was keyed only by table and path. Reading the same path under a different schema could therefore reuse statistics whose columns no longer line up, panicking during statistics projection. #22950 worked around this by **bypassing** the file-statistics cache entirely for anonymous explicit-schema reads — correct, but it gave up cache reuse for them (every such read recomputes statistics). #23072 asks to make the cache itself schema-aware so those reads can reuse the cache safely instead of skipping it. ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> - Add `SchemaFingerprint` — the per-column `(name, data_type, nullable)` of a `file_schema`, in order — and `FileStatisticsCacheKey { table, path, schema }`, and key the file-statistics cache on it (`FileStatisticsCache` is now `dyn Cache<FileStatisticsCacheKey, CachedFileMetadata>`). - `ListingTable::do_collect_statistics_and_ordering` builds the key with the `file_schema` fingerprint and uses the shared cache directly. The #22950 bypass (`statistics_cache` helper / `schema_source`-based skip) is removed: different schemas now land in distinct entries (no stale cross-schema reuse), while a repeated read of the same schema reuses its entry. - The fingerprint deliberately **excludes** field/schema metadata (it cannot affect statistics, and including it would needlessly fragment the cache) and partition columns (partition statistics are computed separately, outside this cache). - Table-drop invalidation is unchanged: `drop_table_entries` matches on `CacheKey::table_ref()`, which still returns the table, so all schema variants for a dropped table are removed together. - The list-files cache continues to key on `TableScopedPath`. ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> Yes. - Updated the #22950 regression test (`anonymous_parquet_stats_cache_with_explicit_wider_schema`): the wider explicit-schema read now lands in its own cache entry (2 entries, was 1 under the bypass) with correct statistics and no panic, and a repeated read of that schema is served from the cache (a cache hit, no new entry). - Added unit tests for `SchemaFingerprint`: it distinguishes nullability and field order, and ignores field/schema metadata. - `cargo test` for the `file_statistics` integration module and the `datafusion-execution` cache tests (including `drop_table_entries`) pass, along with `cargo fmt --all` and `cargo clippy --all-targets --all-features -- -D warnings` for the touched crates. ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. --> No change to query results, physical plans, or the serialized (proto) wire format; file statistics are computed exactly as before. One public API change (please add the `api change` label): the `FileStatisticsCache` type alias now uses `FileStatisticsCacheKey` instead of `TableScopedPath` as its key. Code that constructed keys for this cache directly must switch to `FileStatisticsCacheKey`. `SchemaFingerprint` and `FileStatisticsCacheKey` are newly public; `TableScopedPath` remains (still used by the list-files cache). `cargo-semver-checks` will flag the key-type change, which is expected. --------- Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>
1 parent f755cb4 commit 32e1146

7 files changed

Lines changed: 249 additions & 46 deletions

File tree

datafusion/catalog-listing/src/table.rs

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ use datafusion_datasource::schema_adapter::SchemaAdapterFactory;
3737
use datafusion_datasource::{
3838
ListingTableUrl, PartitionedFile, TableSchemaBuilder, compute_all_files_statistics,
3939
};
40-
use datafusion_execution::cache::cache_manager::{FileStatisticsCache, TableScopedPath};
40+
use datafusion_execution::cache::cache_manager::{
41+
CachedFileMetadata, FileStatisticsCache, SchemaFingerprint, TableScopedPath,
42+
};
4143
use datafusion_expr::dml::InsertOp;
4244
use datafusion_expr::execution_props::ExecutionProps;
4345
use datafusion_expr::{
@@ -197,6 +199,10 @@ pub struct ListingTable {
197199
column_defaults: HashMap<String, Expr>,
198200
/// Optional [`PhysicalExprAdapterFactory`] for creating physical expression adapters
199201
expr_adapter_factory: Option<Arc<dyn PhysicalExprAdapterFactory>>,
202+
/// Precomputed fingerprint of `file_schema` for file-statistics cache
203+
/// validation. Constant for the table, so computed once here instead of per
204+
/// file.
205+
file_schema_fingerprint: Arc<SchemaFingerprint>,
200206
}
201207

202208
impl ListingTable {
@@ -227,6 +233,9 @@ impl ListingTable {
227233
.with_metadata(file_schema.metadata().clone()),
228234
);
229235

236+
let file_schema_fingerprint =
237+
Arc::new(SchemaFingerprint::from_schema(&file_schema));
238+
230239
let table = Self {
231240
table_paths: config.table_paths,
232241
file_schema,
@@ -238,6 +247,7 @@ impl ListingTable {
238247
constraints: Constraints::default(),
239248
column_defaults: HashMap::new(),
240249
expr_adapter_factory: config.expr_adapter_factory,
250+
file_schema_fingerprint,
241251
};
242252

243253
Ok(table)
@@ -268,21 +278,6 @@ impl ListingTable {
268278
self
269279
}
270280

271-
fn statistics_cache(
272-
&self,
273-
has_table_reference: bool,
274-
) -> Option<&Arc<FileStatisticsCache>> {
275-
let shared_cache = self.collected_statistics.as_ref()?;
276-
if has_table_reference || self.schema_source == SchemaSource::Inferred {
277-
Some(shared_cache)
278-
} else {
279-
// Anonymous specified-schema reads can use the same file path with
280-
// different logical schemas. File statistics are schema-dependent,
281-
// so avoid reusing stats computed for a different read schema.
282-
None
283-
}
284-
}
285-
286281
/// Specify the SQL definition for this table, if any
287282
pub fn with_definition(mut self, definition: Option<String>) -> Self {
288283
self.definition = definition;
@@ -990,18 +985,18 @@ impl ListingTable {
990985
store: &Arc<dyn ObjectStore>,
991986
part_file: &PartitionedFile,
992987
) -> datafusion_common::Result<(Arc<Statistics>, Option<LexOrdering>)> {
993-
use datafusion_execution::cache::cache_manager::CachedFileMetadata;
994-
995988
let path = TableScopedPath {
996989
table: part_file.table_reference.clone(),
997990
path: part_file.object_meta.location.clone(),
998991
};
999992
let meta = &part_file.object_meta;
1000993

1001-
// Check cache first - if we have valid cached statistics and ordering
1002-
if let Some(cache) = self.statistics_cache(path.table.is_some())
994+
// Check cache first. The key stays `{table, path}` for cheap lookups;
995+
// the cached value carries the schema fingerprint to prevent reusing
996+
// stats computed under a different file schema.
997+
if let Some(cache) = &self.collected_statistics
1003998
&& let Some(cached) = cache.get(&path)
1004-
&& cached.is_valid_for(meta)
999+
&& cached.is_valid_for(meta, &self.file_schema_fingerprint)
10051000
{
10061001
// Return cached statistics and ordering
10071002
return Ok((Arc::clone(&cached.statistics), cached.ordering.clone()));
@@ -1017,11 +1012,12 @@ impl ListingTable {
10171012
let statistics = Arc::new(file_meta.statistics);
10181013

10191014
// Store in cache
1020-
if let Some(cache) = self.statistics_cache(path.table.is_some()) {
1015+
if let Some(cache) = &self.collected_statistics {
10211016
cache.put(
10221017
&path,
10231018
CachedFileMetadata::new(
10241019
meta.clone(),
1020+
Arc::clone(&self.file_schema_fingerprint),
10251021
Arc::clone(&statistics),
10261022
file_meta.ordering.clone(),
10271023
),

datafusion/common/src/heap_size.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,17 @@ where
348348
}
349349
}
350350

351+
impl<A, B, C> DFHeapSize for (A, B, C)
352+
where
353+
A: DFHeapSize,
354+
B: DFHeapSize,
355+
C: DFHeapSize,
356+
{
357+
fn heap_size(&self, ctx: &mut DFHeapSizeCtx) -> usize {
358+
self.0.heap_size(ctx) + self.1.heap_size(ctx) + self.2.heap_size(ctx)
359+
}
360+
}
361+
351362
impl DFHeapSize for String {
352363
fn heap_size(&self, _: &mut DFHeapSizeCtx) -> usize {
353364
self.capacity()

datafusion/core/tests/parquet/file_statistics.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,29 @@ async fn anonymous_parquet_stats_cache_with_explicit_wider_schema() {
255255
let stats = plan.statistics_with_args(&StatisticsArgs::new()).unwrap();
256256
assert_eq!(stats.column_statistics.len(), 2);
257257
assert_eq!(stats.column_statistics[1].null_count, Precision::Exact(1));
258+
259+
// #23072: the cache now validates file_schema, so the wider read no
260+
// longer bypasses the cache (as in #22950), but it overwrites the existing
261+
// `{table, path}` entry instead of adding a schema-specific key.
258262
assert_eq!(cache.len(), 1);
263+
264+
// Repeat the wider read: same path + same file_schema -> reuse (no new
265+
// entry) and a cache hit. Under #22950's bypass this read could never reuse.
266+
ctx.read_parquet(
267+
&parquet_path,
268+
ParquetReadOptions::default().schema(&wider_schema),
269+
)
270+
.await
271+
.unwrap()
272+
.create_physical_plan()
273+
.await
274+
.unwrap();
275+
assert_eq!(cache.len(), 1);
276+
let hits: usize = cache.list_entries().values().map(|e| e.hits).sum();
277+
assert_eq!(
278+
hits, 1,
279+
"expected a cache hit on the repeat read, got {hits}"
280+
);
259281
}
260282

261283
#[tokio::test]

datafusion/execution/src/cache/cache_manager.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
// under the License.
1717

1818
use crate::cache::default_cache::DefaultCache;
19-
pub use crate::cache::{Cache, CacheValue, TableScopedPath};
19+
pub use crate::cache::{Cache, CacheValue, SchemaFingerprint, TableScopedPath};
2020
use datafusion_common::HashMap;
2121
use datafusion_common::heap_size::{DFHeapSize, DFHeapSizeCtx};
2222
use datafusion_common::{Result, Statistics};
@@ -50,7 +50,8 @@ pub const DEFAULT_METADATA_CACHE_LIMIT: usize = 50 * 1024 * 1024; // 50M
5050
///
5151
/// The typical usage pattern is:
5252
/// 1. Call `get(path)` to check for cached value
53-
/// 2. If `Some(cached)`, validate with `cached.is_valid_for(&current_meta)`
53+
/// 2. If `Some(cached)`, validate with
54+
/// `cached.is_valid_for(&current_meta, &current_schema_fingerprint)`
5455
/// 3. If invalid or missing, compute new value and call `put(path, new_value)`
5556
///
5657
/// See [`crate::runtime_env::RuntimeEnv`] for more details
@@ -91,11 +92,13 @@ pub type FileMetadataCache = dyn Cache<Path, CachedFileMetadataEntry>;
9192
/// Cached metadata for a file, including statistics and ordering.
9293
///
9394
/// This struct embeds the [`ObjectMeta`] used for cache validation,
94-
/// along with the cached statistics and ordering information.
95+
/// the `file_schema` fingerprint, cached statistics, and ordering information.
9596
#[derive(Debug, Clone, PartialEq, Eq)]
9697
pub struct CachedFileMetadata {
9798
/// File metadata used for cache validation (size, last_modified).
9899
pub meta: ObjectMeta,
100+
/// Fingerprint of the `file_schema` used to compute `statistics`.
101+
pub schema_fingerprint: Arc<SchemaFingerprint>,
99102
/// Cached statistics for the file, if available.
100103
pub statistics: Arc<Statistics>,
101104
/// Cached ordering for the file.
@@ -106,22 +109,31 @@ impl CachedFileMetadata {
106109
/// Create a new cached file metadata entry.
107110
pub fn new(
108111
meta: ObjectMeta,
112+
schema_fingerprint: Arc<SchemaFingerprint>,
109113
statistics: Arc<Statistics>,
110114
ordering: Option<LexOrdering>,
111115
) -> Self {
112116
Self {
113117
meta,
118+
schema_fingerprint,
114119
statistics,
115120
ordering,
116121
}
117122
}
118123

119124
/// Check if this cached entry is still valid for the given metadata.
120125
///
121-
/// Returns true if the file size and last modified time match.
122-
pub fn is_valid_for(&self, current_meta: &ObjectMeta) -> bool {
126+
/// Returns true if the file size, last modified time, and schema match.
127+
pub fn is_valid_for(
128+
&self,
129+
current_meta: &ObjectMeta,
130+
current_schema_fingerprint: &Arc<SchemaFingerprint>,
131+
) -> bool {
123132
self.meta.size == current_meta.size
124133
&& self.meta.last_modified == current_meta.last_modified
134+
&& (Arc::ptr_eq(&self.schema_fingerprint, current_schema_fingerprint)
135+
|| self.schema_fingerprint.as_ref()
136+
== current_schema_fingerprint.as_ref())
125137
}
126138
}
127139

@@ -139,6 +151,8 @@ impl DFHeapSize for CachedFileMetadata {
139151
+ self.meta.e_tag.heap_size(ctx)
140152
+ self.meta.location.as_ref().heap_size(ctx)
141153
+ self.statistics.heap_size(ctx)
154+
// Do not deep-count `schema_fingerprint`: each ListingTable shares one
155+
// fingerprint across all cached files.
142156
//TODO add ordering once LexOrdering/PhysicalExpr implements DFHeapSize
143157
}
144158
}

0 commit comments

Comments
 (0)