Skip to content

feat!: expose read-side expected stats schemas - #3308

Open
sanujbasu wants to merge 2 commits into
delta-io:mainfrom
sanujbasu:re-15607-read-stats-schema
Open

feat!: expose read-side expected stats schemas#3308
sanujbasu wants to merge 2 commits into
delta-io:mainfrom
sanujbasu:re-15607-read-stats-schema

Conversation

@sanujbasu

@sanujbasu sanujbasu commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What changes are proposed in this pull request?

Expose the resolved read-side file-statistics schema through Snapshot. The new API returns
aligned logical and physical schemas, applies column mapping, excludes partition columns, and lets
callers include extra indexed columns beyond the configured statistics budget. It returns None
when the matching scan would not emit structured statistics.

Internal scan, checkpoint, and transaction paths continue to use the physical schema builder. The
logical and physical schemas now share one column-selection pass, keeping their field order and
shape aligned.

This PR affects the following public APIs

  • Adds Snapshot::expected_stats_schemas, returning aligned schemas when the scan emits structured
    statistics.
  • Adds the logical schema to ExpectedStatsSchemas and marks the type #[non_exhaustive].
  • Renames the unstable TableConfiguration::build_expected_stats_schemas internal API to
    build_expected_physical_stats_schema and returns its physical SchemaRef directly.

How was this change tested?

Added scan-schema consistency tests covering excluded columns, explicit nested stats columns,
partition columns, unresolved extras, empty stats selection, and no/name/id column mapping. The
tests also verify logical-to-physical field alignment and metadata removal.

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.69072% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.32%. Comparing base (0eeb2a6) to head (6408779).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
kernel/src/table_configuration.rs 93.50% 2 Missing and 3 partials ⚠️
kernel/src/scan/state_info.rs 50.00% 1 Missing and 2 partials ⚠️
kernel/src/scan/data_skipping.rs 50.00% 0 Missing and 1 partial ⚠️
kernel/src/transaction/mod.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3308      +/-   ##
==========================================
+ Coverage   90.26%   90.32%   +0.06%     
==========================================
  Files         251      250       -1     
  Lines       88345    89100     +755     
  Branches    88345    89100     +755     
==========================================
+ Hits        79742    80477     +735     
+ Misses       5730     5687      -43     
- Partials     2873     2936      +63     

☔ 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.

@github-actions github-actions Bot added the breaking-change Public API change that could cause downstream compilation failures. Requires a major version bump. label Sep 11, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review (draft - human review required)

Show review

This change is a clean refactor plus an additive public API. Splitting the physical-only builder (build_expected_physical_stats_schema, returning SchemaRef) from the new logical+physical build_expected_stats_schemas, and updating the scan, checkpoint, and transaction call sites, is a faithful mechanical extraction with no semantic drift. The physical schema still uses physical names and strips all field metadata (no columnMapping.physicalName, no parquet.field.id), which is correct for stats read from JSON commits and checkpoint Parquet. The new Snapshot::expected_stats_schemas produces output aligned with the scan path, and the added parametrized test pins that contract across all three column-mapping modes.

No blocking issues.

Non-blocking notes

Nit1: kernel/src/table_configuration.rs:307 -- build_expected_stats_schemas resolves extra_indexed_columns against logical_schema_without_partition_columns(), while the scan path's resolve_physical_columns (scan/state_info.rs) resolves against the full logical_schema(). The two produce identical physical schemas today because both ultimately build from partition-excluded schemas, so this is not a current defect, only a maintenance risk from two hand-rolled resolvers that could drift on the "aligned with scan output" guarantee this API exists to provide. Raised by: architecture-reviewer, maintainer-claude-reviewer, delta-protocol-reviewer (disprove gate: NITPICK). Suggested fix: route both entry points through one shared best-effort resolver, or add a short comment at both sites noting the intentional schema choice.

Nit2: kernel/src/scan/tests.rs:2277 -- the consistency test covers a flat two-column schema and asserts the physical minValues field count, but does not assert the logical side's field count and does not exercise partition-column exclusion through the new API's logical branch or a nested/dataSkippingStatsColumns-configured schema. Raised by: test-coverage-reviewer, delta-protocol-reviewer, maintainer-codex-reviewer. Suggested fix: add assert_eq!(logical_min_values.num_fields(), 2); and a partitioned-table case asserting the partition column is absent from both logical and physical.

Nit3: kernel/src/table_configuration.rs:380 -- #[allow(unused)] on build_expected_physical_stats_schema looks stale now that checkpoint, scan, data-skipping, and transaction all call it unconditionally. Raised by: maintainer-claude-reviewer. Suggested fix: drop the attribute if the crate still builds clean under all feature combinations, so it does not mask future dead-code warnings.

Nit4: kernel/src/snapshot/mod.rs:362 -- the # Errors section ("Returns an error if kernel cannot construct a valid stats schema") only restates the return type, and the doc does not mention that both returned schemas are metadata-stripped even though the logical field is described as connector-facing. Raised by: maintainer-claude-reviewer. Suggested fix: name the concrete error condition (or drop the section) and add a one-line note that both schemas have field metadata stripped.

Nit5: kernel/src/table_configuration.rs:357 -- the ASCII schema diagram on build_expected_physical_stats_schema lists numRecords, nullCount, minValues, and maxValues but omits tightBounds, which the builder always emits. The new Snapshot::expected_stats_schemas rustdoc names tightBounds correctly, so the two docs disagree. Raised by: docs-reviewer. Suggested fix: add tightBounds: boolean, to the diagram.

Nit6: kernel/src/snapshot/mod.rs:368 -- expected_stats_schemas(&[ColumnName]) models only the all_struct_with_extra_indexed stats policy; a connector scanning with struct_columns(...) gets a schema that can disagree with its own scan output, which is the mismatch this API is meant to prevent. Raised by: architecture-reviewer. Suggested fix: consider accepting the scan's StatsOptions (or the StructStats policy) so one method covers all modes, or scope the method name to the single policy it serves.

Summary
The refactor preserves protocol semantics and the new public API is additive and test-covered for its intended mode. There are no blocking issues. The notes above are maintenance and coverage improvements worth considering before the public signature ships.


Automated review - workflow run

Comment thread kernel/src/table_configuration.rs Outdated
.iter()
.filter_map(|logical_column| {
get_any_level_column_physical_name(
&self.logical_schema_without_partition_columns(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit1 build_expected_stats_schemas resolves extra_indexed_columns against logical_schema_without_partition_columns(), while the scan path's resolve_physical_columns resolves against the full logical_schema(). Physical schemas agree today (both build from partition-excluded schemas), so this is not a current defect, only a drift risk between two hand-rolled resolvers on the exact 'aligned with scan output' guarantee this API provides. Raised by: architecture-reviewer, maintainer-claude-reviewer, delta-protocol-reviewer. Suggested fix: route both entry points through one shared resolver, or add a comment at both sites noting the intended schema choice.

Comment thread kernel/src/scan/tests.rs
#[case::no_column_mapping(None)]
#[case::name_column_mapping(Some("name"))]
#[case::id_column_mapping(Some("id"))]
fn snapshot_expected_stats_schemas_match_scan_output(#[case] column_mapping_mode: Option<&str>) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit2 The consistency test covers only a flat two-column schema, asserts the physical minValues field count but not the logical side, and does not exercise partition-column exclusion through the new API's logical branch or a nested/dataSkippingStatsColumns schema. Raised by: test-coverage-reviewer, delta-protocol-reviewer, maintainer-codex-reviewer. Suggested fix: add assert_eq!(logical_min_values.num_fields(), 2); and a partitioned-table case asserting the partition column is absent from both logical and physical.

Comment thread kernel/src/table_configuration.rs Outdated
@@ -323,11 +379,11 @@ impl TableConfiguration {
/// <https://github.com/delta-io/delta/blob/master/PROTOCOL.md#per-file-statistics>
#[allow(unused)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit3 #[allow(unused)] on build_expected_physical_stats_schema looks stale now that checkpoint, scan, data-skipping, and transaction all call it unconditionally. Raised by: maintainer-claude-reviewer. Suggested fix: drop the attribute if the crate still builds clean under all feature combinations, so it does not mask future dead-code warnings.

/// Pass the same extra columns to [`StatsOptions::all_struct_with_extra_indexed`] when building
/// a scan that returns structured statistics.
///
/// # Errors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit4 The # Errors section only restates the return type, and the doc does not mention that both returned schemas are metadata-stripped even though the logical field is described as connector-facing. Raised by: maintainer-claude-reviewer. Suggested fix: name the concrete error condition (or drop the section) and note that both schemas have field metadata stripped.

/// an `ExpectedStatsSchemas`.
/// data skipping and other optimizations.
///
/// The schema is structured as:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit5 The ASCII schema diagram on build_expected_physical_stats_schema lists numRecords, nullCount, minValues, maxValues but omits tightBounds, which the builder always emits; the new Snapshot::expected_stats_schemas rustdoc names it, so the two docs disagree. Raised by: docs-reviewer. Suggested fix: add tightBounds: boolean, to the diagram.

///
/// [`StatsOptions::all_struct_with_extra_indexed`]:
/// crate::scan::StatsOptions::all_struct_with_extra_indexed
pub fn expected_stats_schemas(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit6 expected_stats_schemas(&[ColumnName]) models only the all_struct_with_extra_indexed policy; a connector scanning with struct_columns(...) gets a schema that can disagree with its own scan output, the mismatch this API aims to prevent. Raised by: architecture-reviewer. Suggested fix: consider accepting the scan's StatsOptions (or the StructStats policy) so one method covers all modes, or scope the method name to the single policy it serves.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Benchmark results: ✅ Pass

Summary: 🚀 0  ·  ✅ 5  ·  ☑️ 8  ·  🚧 2  ·  ❌ 0

Per-benchmark results (15 rows)
Test Change Base PR
clustered/readMetadataLatestPredicate/serial ✅ 1.01x faster 101.2±1.86ms 100.0±2.88ms
crcLatest/snapshotLatest ☑️ 1.01x slower 9.6±0.11ms 9.7±0.41ms
crcMissing/snapshotLatest ☑️ 1.01x slower 23.5±2.03ms 23.8±0.34ms
crcSlightlyStale/snapshotLatest ☑️ 1.01x slower 10.4±0.14ms 10.5±0.20ms
crcVeryStale/snapshotLatest ☑️ 1.03x slower 15.8±0.19ms 16.2±0.39ms
partitioned/readMetadataLatestPredicate/serial ✅ 1.00x 56.2±4.29ms 56.0±3.69ms
v1Checkpoint/readMetadataLatest/serial 🚧 1.04x slower 12.5±0.41ms 13.0±1.02ms
v1Checkpoint/snapshotLatest ☑️ 1.02x slower 765.2±14.37µs 778.7±16.98µs
v2Checkpoint/readMetadataLatest/parallel2 ☑️ 1.02x slower 8.9±0.49ms 9.1±0.48ms
v2Checkpoint/readMetadataLatest/serial ☑️ 1.01x slower 14.6±0.76ms 14.8±0.12ms
v2Checkpoint/snapshotLatest ☑️ 1.02x slower 764.1±26.89µs 782.0±81.05µs
wideSchemaJsonStats/readMetadataLatestPredicate/serial ✅ 1.01x faster 78.4±5.91ms 77.3±4.50ms
wideSchemaJsonStats/snapshotLatest ✅ 1.00x 2.4±0.02ms 2.4±0.06ms
wideSchemaStructStats/readMetadataLatestPredicate/serial 🚧 1.07x slower 34.2±1.31ms 36.6±2.25ms
wideSchemaStructStats/snapshotLatest ✅ 1.00x 2.3±0.07ms 2.3±0.03ms

Legend: 🚀 ≥1.15x faster  · ✅ faster or unchanged  · ☑️ ≤1.03x slower  · 🚧 1.03x-1.15x slower  · ❌ ≥1.15x slower
Commit: 6408779 · Trigger: auto-push · Tags: base · Updated: 2026-09-10 18:36 PDT

@sanujbasu sanujbasu changed the title feat: expose read-side expected stats schemas feat!: expose read-side expected stats schemas Sep 11, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review (draft - human review required)

Show review

No blocking issues. This is a clean, additive change: one logical column selection is mapped to physical names and both schemas are built from structurally identical inputs, so logical/physical alignment holds by construction. The rename to build_expected_physical_stats_schema is applied consistently across the scan, checkpoint, transaction, and data-skipping call sites, and the new tests pin the alignment and scan-consistency contracts well.

Non-blocking notes

Nit1 - kernel/src/table_configuration.rs (build_expected_stats_schemas, physical build around line 366)
The physical schema advertised by expected_stats_schemas is produced by a selection pass (stats_column_names on the logical schema, mapped to physical, rebuilt with num_indexed_cols=None) that is separate from the pass the scan uses to build physical_stats_output_schema (build_expected_physical_stats_schema applying num_indexed_cols directly). The two agree today and the tests assert scan.physical_stats_output_schema == expected.physical, so this is not a current defect. The maintenance cost is that the "advertised schema equals scan-emitted schema" invariant lives in two selection pipelines and is guarded only by the pinned test cases, so a future change to either selector could desynchronize them silently.
Raised by: architecture-reviewer, delta-protocol-reviewer, maintainer-claude-reviewer
Suggested fix: derive the physical field by delegating to build_expected_physical_stats_schema(Some(&physical_columns), Some(&physical_columns)) so both paths share one physical-materialization pipeline, or add a short comment at the physical build pointing at the invariant and the guarding test.

Nit2 - kernel/src/table_configuration.rs (extra column resolution around line 320)
Each extra_indexed_columns entry is resolved to a physical name once for warn-and-skip validation (the result is discarded) and again when the selected logical_columns are mapped to physical_columns. Both calls use the same logical schema and mapping mode, so they cannot disagree today; this is only minor duplicated work.
Raised by: maintainer-claude-reviewer, maintainer-codex-reviewer
Suggested fix: carry the resolved physical name from the first pass instead of recomputing it.

Summary
The change exposes read-side logical and physical stats schemas correctly: per-file stats shape, column-mapping name substitution, id-mode field-id stripping, partition-column exclusion, extra-indexed inclusion beyond the budget, and None-when-no-data-columns all match the protocol and stay consistent with the existing scan and write paths. No correctness, protocol, or safety defects were found in the diff. The only items are two non-blocking maintainability notes about the dual physical-schema selection path and a duplicated resolution step.


Automated review - workflow run

})
.collect::<DeltaResult<Vec<_>>>()?;

let logical = build_stats_schema_for_columns(&logical_schema, &logical_columns)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit1 The physical schema advertised here is built by a selection pass separate from the one the scan uses for physical_stats_output_schema (build_expected_physical_stats_schema applying num_indexed_cols directly). They agree today and tests assert scan.physical_stats_output_schema == expected.physical, so this is not a current defect, but the equality invariant now lives in two selection pipelines guarded only by the pinned tests and could desynchronize on a future change to either selector. Raised by: architecture-reviewer, delta-protocol-reviewer, maintainer-claude-reviewer. Suggested fix: derive the physical field via build_expected_physical_stats_schema(Some(&physical_columns), Some(&physical_columns)) so both paths share one pipeline, or add a comment pinning the invariant and its guarding test.

) -> DeltaResult<Option<ExpectedStatsSchemas>> {
let logical_schema = self.logical_schema_without_partition_columns();
let column_mapping_mode = self.column_mapping_mode();
let required_logical_columns: Vec<_> = extra_indexed_columns

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit2 Each extra_indexed_columns entry is resolved to a physical name once for warn-and-skip validation (result discarded) and again when logical_columns are mapped to physical_columns. Both calls use the same logical schema and mapping mode, so they cannot disagree today; this is only minor duplicated work. Raised by: maintainer-claude-reviewer, maintainer-codex-reviewer. Suggested fix: carry the resolved physical name from the first pass instead of recomputing it.

@dengsh12 dengsh12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM with minor comments.

self.table_configuration.logical_schema()
}

/// Returns aligned schemas for scans using all indexed structured statistics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NIT: This seems not fully accurate? We are returning stats schemas for all indexed columns + caller specified columns

pub struct ExpectedStatsSchemas {
/// Schema using logical table column names.
pub logical: SchemaRef,
/// Schema using physical column names as encoded in Delta statistics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NIT: From the logic we build it, seems physical col names come from column mapping not the on-disk delta stats?

Suggested change
/// Schema using physical column names as encoded in Delta statistics.
/// Schema using physical column names.

Comment on lines +355 to +364
let physical_columns = logical_columns
.iter()
.map(|logical_column| {
get_any_level_column_physical_name(
&logical_schema,
logical_column,
column_mapping_mode,
)
})
.collect::<DeltaResult<Vec<_>>>()?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On the above code we emit warning but here we emit error, wonder the reason for the asymmetry? Feel like both can just be warning, as the schema is already validated during construction, the error won't really happen. Then we can remove error from the pub api expected_stats_schemas

 pub fn expected_stats_schemas(
      &self,
      extra_indexed_columns: &[ColumnName],
  ) -> Option<ExpectedStatsSchemas>

Defer the choice to you -- not blocker

data_skipping_stats_columns: Some(selected_columns),
data_skipping_num_indexed_cols: None,
};
let schema = Arc::new(expected_stats_schema(data_schema, &config, None, None)?);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NIT: inline comments for None

Comment thread kernel/src/scan/tests.rs
assert!(info.field("name").is_some());
assert!(info.field("age").is_none());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wonder if we want to test:

  • extra_indexed_columns intersect with the table's configured indexed columns
  • extra_indexed_columns includes partition columns
  • extra_indexed_columns contains a struct
    • IIUC, the expected behavior is: all leaf columns inside the struct will be include
  • extra_indexed_columns contains map/array
    • IIUC the expected behavior is only nullCount, not min/max values

Comment on lines +350 to +353
/// `extra_indexed_columns` are logical column paths that may have statistics even when they
/// fall outside the table's configured indexed-column set. Pass the same columns to
/// [`StatsOptions::all_struct_with_extra_indexed`] when building the scan. Partition columns
/// and unresolvable paths are omitted.

@dengsh12 dengsh12 Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Codex suggested providing a scan::stats_output_schemas instead of this. Seems making sense? It avoid the case that someone pass X to expected_stats_schemas then pass Y to ScanBuilder. But I'm unsure if someone wants to have the stats schema before scan -- defer the choice to you

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

Labels

Area: Data skipping breaking-change Public API change that could cause downstream compilation failures. Requires a major version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants