Skip to content

Commit 7decfb2

Browse files
authored
fix(scan): don't treat data columns named pos or file_path as metadata columns (#2865)
## Which issue does this PR close? - Closes #2837. ## What changes are included in this PR? A user data column named `pos` or `file_path` can't be scanned: projecting it fails with `field not found`, and renaming the column makes it work again. This bites VCF → Iceberg pipelines, where `pos` is a common column name. `pos` and `file_path` are the internal columns of a position-delete file, but they were registered in the same reserved name→field-id map (`get_metadata_field_id`) that a data-table scan consults through `is_metadata_column_name`. Scan planning checks that map first and never falls back to the data schema, so a real `pos` column gets mapped to a reserved delete-file id and is never looked up in the actual schema. The Iceberg spec reserves only `_`-prefixed names as metadata columns, so they can't collide with user columns. This guards `is_metadata_column_name` with a `starts_with('_')` check, and the two bare names stop shadowing data columns. `get_metadata_field_id` still maps them to their reserved ids, which the delete read path needs via the id→field direction; its doc now says it isn't a membership test. No public API signature changes. ## Are these changes tested? Unit tests, no infrastructure needed: - `metadata_columns`: `is_metadata_column_name` accepts the `_`-prefixed names and rejects the bare `pos`/`file_path`, while `get_metadata_field_id` still maps them to reserved ids. - `scan`: projecting `pos`/`file_path`, both explicitly and via the default projection, resolves to the real field id; an absent column still fails with `DataInvalid` / "not found". Both fail before the fix and pass after. `cargo test -p iceberg --lib`, `cargo fmt --check`, and `cargo clippy -D warnings` all pass. ## One open question for reviewers This is the minimal fix, on the scan side. The bare names still live in the shared name→id map, so `get_metadata_field_id("pos").is_ok()` stays true and the two public predicates now disagree (`is_metadata_column_name("pos")` is false). Removing the two names from that map would be the deeper fix and looks safe in-repo (the delete read path uses the untouched id→field direction), but it changes a second public function's behavior, so I left it out. Happy to fold it in if you'd rather go deeper.
1 parent 4532da8 commit 7decfb2

2 files changed

Lines changed: 234 additions & 10 deletions

File tree

crates/iceberg/src/metadata_columns.rs

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,12 @@ pub fn get_metadata_field(field_id: i32) -> Result<&'static NestedFieldRef> {
425425

426426
/// Returns the field ID for a metadata column name.
427427
///
428+
/// This maps every reserved name to its field id, including the position-delete-file
429+
/// internal columns `pos` and `file_path`, which are not `_`-prefixed. It is therefore
430+
/// not a membership test for data-table metadata columns: `get_metadata_field_id("pos")`
431+
/// returns `Ok` even though `pos` is a valid user data column name. To decide whether a
432+
/// name is a projectable data-table metadata column, use [`is_metadata_column_name`].
433+
///
428434
/// # Arguments
429435
/// * `column_name` - The metadata column name
430436
///
@@ -453,13 +459,20 @@ pub fn get_metadata_field_id(column_name: &str) -> Result<i32> {
453459
}
454460
}
455461

456-
/// Checks if a field ID is a metadata field.
462+
/// Checks if a field ID is a data-table metadata column.
463+
///
464+
/// Mirrors Java `MetadataColumns.isMetadataColumn(int)` (backed by `META_IDS`): only the
465+
/// field ids of columns projectable in a data-table scan return `true`. The
466+
/// position-delete-file internal columns (`file_path`/`pos`, ids `i32::MAX - 101/-102`)
467+
/// and the changelog columns (`_change_type`/`_change_ordinal`/`_commit_snapshot_id`) are
468+
/// deliberately excluded, so their ids return `false` here even though
469+
/// [`get_metadata_field`] can still resolve them.
457470
///
458471
/// # Arguments
459472
/// * `field_id` - The field ID to check
460473
///
461474
/// # Returns
462-
/// `true` if the field ID is a (currently supported) metadata field, `false` otherwise
475+
/// `true` if the field ID is a data-table metadata column, `false` otherwise
463476
pub fn is_metadata_field(field_id: i32) -> bool {
464477
matches!(
465478
field_id,
@@ -468,25 +481,43 @@ pub fn is_metadata_field(field_id: i32) -> bool {
468481
| RESERVED_FIELD_ID_DELETED
469482
| RESERVED_FIELD_ID_SPEC_ID
470483
| RESERVED_FIELD_ID_PARTITION
471-
| RESERVED_FIELD_ID_DELETE_FILE_PATH
472-
| RESERVED_FIELD_ID_DELETE_FILE_POS
473-
| RESERVED_FIELD_ID_CHANGE_TYPE
474-
| RESERVED_FIELD_ID_CHANGE_ORDINAL
475-
| RESERVED_FIELD_ID_COMMIT_SNAPSHOT_ID
476484
| RESERVED_FIELD_ID_ROW_ID
477485
| RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER
478486
)
479487
}
480488

481-
/// Checks if a column name is a metadata column.
489+
/// Checks if a column name is a data-table metadata column.
490+
///
491+
/// Mirrors Java `MetadataColumns.isMetadataColumn(String)` (backed by its `META_COLUMNS`
492+
/// allowlist plus the `_partition` special case): a column name is a data-table metadata
493+
/// column only if it is one of the reserved names that a scan can project.
494+
///
495+
/// This deliberately excludes two groups of reserved names that Java also keeps out of
496+
/// `META_COLUMNS`:
497+
/// - the position-delete-file internal columns `pos`, `file_path`, and `row`, so a user
498+
/// data column of the same name is not shadowed during a scan (issue #2837);
499+
/// - the changelog columns `_change_type`, `_change_ordinal`, and `_commit_snapshot_id`.
500+
///
501+
/// [`get_metadata_field_id`] still maps the reserved names it knows (including `pos` and
502+
/// `file_path`) to their field ids; only this membership check excludes them. (`row` has
503+
/// no reserved id in this crate, so `get_metadata_field_id("row")` is an error.)
482504
///
483505
/// # Arguments
484506
/// * `column_name` - The column name to check
485507
///
486508
/// # Returns
487-
/// `true` if the column name is a metadata column, `false` otherwise
509+
/// `true` if the column name is a data-table metadata column, `false` otherwise
488510
pub fn is_metadata_column_name(column_name: &str) -> bool {
489-
get_metadata_field_id(column_name).is_ok()
511+
matches!(
512+
column_name,
513+
RESERVED_COL_NAME_FILE
514+
| RESERVED_COL_NAME_POS
515+
| RESERVED_COL_NAME_DELETED
516+
| RESERVED_COL_NAME_SPEC_ID
517+
| RESERVED_COL_NAME_PARTITION
518+
| RESERVED_COL_NAME_ROW_ID
519+
| RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER
520+
)
490521
}
491522

492523
#[cfg(test)]
@@ -569,4 +600,80 @@ mod tests {
569600
assert!(get_metadata_field(RESERVED_FIELD_ID_ROW_ID).is_ok());
570601
assert!(get_metadata_field(RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER).is_ok());
571602
}
603+
604+
#[test]
605+
fn test_data_table_metadata_columns() {
606+
// Both sides of the data-table metadata check (name and field id) must accept
607+
// these seven columns. On the name side Java keeps six in `META_COLUMNS` and
608+
// recognizes `_partition` via the `isMetadataColumn(String)` special case; on the
609+
// id side all seven are in `META_IDS`.
610+
assert!(is_metadata_column_name(RESERVED_COL_NAME_FILE));
611+
assert!(is_metadata_column_name(RESERVED_COL_NAME_POS));
612+
assert!(is_metadata_column_name(RESERVED_COL_NAME_DELETED));
613+
assert!(is_metadata_column_name(RESERVED_COL_NAME_SPEC_ID));
614+
assert!(is_metadata_column_name(RESERVED_COL_NAME_PARTITION));
615+
assert!(is_metadata_column_name(RESERVED_COL_NAME_ROW_ID));
616+
assert!(is_metadata_column_name(
617+
RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER
618+
));
619+
620+
assert!(is_metadata_field(RESERVED_FIELD_ID_FILE));
621+
assert!(is_metadata_field(RESERVED_FIELD_ID_POS));
622+
assert!(is_metadata_field(RESERVED_FIELD_ID_DELETED));
623+
assert!(is_metadata_field(RESERVED_FIELD_ID_SPEC_ID));
624+
assert!(is_metadata_field(RESERVED_FIELD_ID_PARTITION));
625+
assert!(is_metadata_field(RESERVED_FIELD_ID_ROW_ID));
626+
assert!(is_metadata_field(
627+
RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER
628+
));
629+
}
630+
631+
#[test]
632+
fn test_changelog_columns_are_not_data_table_metadata_columns() {
633+
// Changelog columns are not in Java's `META_COLUMNS`, so they are not projectable
634+
// data-table metadata columns (`isMetadataColumn` is false for them).
635+
assert!(!is_metadata_column_name(RESERVED_COL_NAME_CHANGE_TYPE));
636+
assert!(!is_metadata_column_name(RESERVED_COL_NAME_CHANGE_ORDINAL));
637+
assert!(!is_metadata_column_name(
638+
RESERVED_COL_NAME_COMMIT_SNAPSHOT_ID
639+
));
640+
assert!(!is_metadata_field(RESERVED_FIELD_ID_CHANGE_TYPE));
641+
assert!(!is_metadata_field(RESERVED_FIELD_ID_CHANGE_ORDINAL));
642+
assert!(!is_metadata_field(RESERVED_FIELD_ID_COMMIT_SNAPSHOT_ID));
643+
}
644+
645+
#[test]
646+
fn test_delete_file_columns_are_not_data_table_metadata_columns() {
647+
// `pos`, `file_path`, and `row` are the internal columns of a position-delete file,
648+
// not projectable metadata columns of a data table. They must not shadow real data
649+
// columns of the same name during a scan (issue #2837). Java returns false for both
650+
// their names and their field ids; mirror that on both sides here.
651+
assert!(!is_metadata_column_name("pos"));
652+
assert!(!is_metadata_column_name("file_path"));
653+
assert!(!is_metadata_column_name("row"));
654+
assert!(!is_metadata_field(RESERVED_FIELD_ID_DELETE_FILE_POS));
655+
assert!(!is_metadata_field(RESERVED_FIELD_ID_DELETE_FILE_PATH));
656+
// `row` (Java's DELETE_FILE_ROW_FIELD_ID, i32::MAX - 103) has no reserved id in
657+
// this crate, so it is not a metadata field either.
658+
assert!(!is_metadata_field(i32::MAX - 103));
659+
660+
// `get_metadata_field_id` intentionally still maps the two bare names it knows to
661+
// their reserved field ids; only the data-table membership check excludes them.
662+
assert_eq!(
663+
get_metadata_field_id(RESERVED_COL_NAME_DELETE_FILE_POS).unwrap(),
664+
RESERVED_FIELD_ID_DELETE_FILE_POS
665+
);
666+
assert_eq!(
667+
get_metadata_field_id(RESERVED_COL_NAME_DELETE_FILE_PATH).unwrap(),
668+
RESERVED_FIELD_ID_DELETE_FILE_PATH
669+
);
670+
}
671+
672+
#[test]
673+
fn test_ordinary_data_column_names_are_not_metadata_columns() {
674+
assert!(!is_metadata_column_name("id"));
675+
assert!(!is_metadata_column_name("name"));
676+
// A user column that merely starts with an underscore but is not reserved.
677+
assert!(!is_metadata_column_name("_custom"));
678+
}
572679
}

crates/iceberg/src/scan/mod.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2775,6 +2775,123 @@ pub mod tests {
27752775
);
27762776
}
27772777

2778+
/// Builds a minimal single-snapshot table (no manifests on disk) whose schema
2779+
/// contains a column with the given name, so scan planning resolves column names
2780+
/// against a real schema. `TableScan::build()` only reads metadata, not manifest
2781+
/// files, so a snapshot pointing at a dummy manifest list is enough. Used to
2782+
/// reproduce issue #2837.
2783+
fn table_with_data_column(column_name: &str) -> Table {
2784+
use crate::spec::{
2785+
FormatVersion, MAIN_BRANCH, Operation, Snapshot, SnapshotReference, SnapshotRetention,
2786+
SortOrder, Summary, TableMetadataBuilder, UnboundPartitionSpec,
2787+
};
2788+
2789+
let schema = Schema::builder()
2790+
.with_schema_id(0)
2791+
.with_fields(vec![
2792+
NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2793+
NestedField::required(2, column_name, Type::Primitive(PrimitiveType::Int)).into(),
2794+
])
2795+
.build()
2796+
.unwrap();
2797+
2798+
let snapshot = Snapshot::builder()
2799+
.with_snapshot_id(1)
2800+
.with_timestamp_ms(1)
2801+
.with_sequence_number(0)
2802+
.with_schema_id(0)
2803+
.with_manifest_list("/snap-1.avro")
2804+
.with_summary(Summary {
2805+
operation: Operation::Append,
2806+
additional_properties: HashMap::new(),
2807+
})
2808+
.build();
2809+
2810+
let metadata = TableMetadataBuilder::new(
2811+
schema,
2812+
UnboundPartitionSpec::builder().with_spec_id(0).build(),
2813+
SortOrder::unsorted_order(),
2814+
"s3://bucket/table".to_string(),
2815+
FormatVersion::V2,
2816+
HashMap::new(),
2817+
)
2818+
.unwrap()
2819+
.add_snapshot(snapshot)
2820+
.unwrap()
2821+
.set_ref(MAIN_BRANCH, SnapshotReference {
2822+
snapshot_id: 1,
2823+
retention: SnapshotRetention::Branch {
2824+
min_snapshots_to_keep: None,
2825+
max_snapshot_age_ms: None,
2826+
max_ref_age_ms: None,
2827+
},
2828+
})
2829+
.unwrap()
2830+
.build()
2831+
.unwrap()
2832+
.metadata;
2833+
2834+
Table::builder()
2835+
.metadata(metadata)
2836+
.identifier(TableIdent::from_strs(["db", "table1"]).unwrap())
2837+
.file_io(FileIO::new_with_fs())
2838+
.runtime(test_runtime())
2839+
.build()
2840+
.unwrap()
2841+
}
2842+
2843+
/// A user data column named `pos` (a delete-file internal column name that is not a
2844+
/// data-table metadata column) must be projectable rather than shadowed. Regression
2845+
/// test for issue #2837.
2846+
#[test]
2847+
fn test_scan_projects_data_column_named_like_delete_file_column() {
2848+
for column_name in ["pos", "file_path"] {
2849+
let table = table_with_data_column(column_name);
2850+
2851+
// Projecting the data column must succeed and resolve to its real field id (2),
2852+
// not the reserved delete-file field id.
2853+
let table_scan = table
2854+
.scan()
2855+
.select([column_name])
2856+
.build()
2857+
.unwrap_or_else(|e| panic!("scan of data column `{column_name}` failed: {e}"));
2858+
2859+
assert_eq!(
2860+
table_scan.plan_context.as_ref().unwrap().field_ids.as_ref(),
2861+
&[2]
2862+
);
2863+
2864+
// The default projection (all columns) must resolve to the real field ids
2865+
// too, not shadow the data column with a reserved delete-file id.
2866+
let default_scan = table.scan().build().unwrap();
2867+
assert_eq!(
2868+
default_scan
2869+
.plan_context
2870+
.as_ref()
2871+
.unwrap()
2872+
.field_ids
2873+
.as_ref(),
2874+
&[1, 2]
2875+
);
2876+
}
2877+
}
2878+
2879+
/// Projecting a genuinely absent column still fails with a clear "not found" error
2880+
/// rather than being silently accepted as a metadata column.
2881+
#[test]
2882+
fn test_scan_rejects_unknown_column_named_like_delete_file_column() {
2883+
// This table has no `pos` column (only `id` and `file_path`).
2884+
let table = table_with_data_column("file_path");
2885+
2886+
let err = table
2887+
.scan()
2888+
.select(["pos"])
2889+
.build()
2890+
.expect_err("projecting an absent column should fail");
2891+
assert_eq!(err.kind(), ErrorKind::DataInvalid);
2892+
assert!(err.to_string().contains("not found"));
2893+
}
2894+
27782895
#[tokio::test]
27792896
async fn test_scan_deadlock() {
27802897
let mut fixture = TableTestFixture::new();

0 commit comments

Comments
 (0)