Skip to content

feat: add support for _spec_id metadata column#2695

Open
hsiang-c wants to merge 12 commits into
apache:mainfrom
hsiang-c:meta_col_spec_id
Open

feat: add support for _spec_id metadata column#2695
hsiang-c wants to merge 12 commits into
apache:mainfrom
hsiang-c:meta_col_spec_id

Conversation

@hsiang-c

@hsiang-c hsiang-c commented Jun 22, 2026

Copy link
Copy Markdown

Which issue does this PR close?

What changes are included in this PR?

  • If a projection includes _spec_id, which is a constant like the _file metadata column for all rows, add it to RecordBatchTransformerBuilder

Are these changes tested?

  • Yes, unit tests.
  • Iceberg Spark tests with Comet's 1.9.1.diff and temporarily turn off the check on metadata columns

Sample plan

== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- CometSort [id#1247L, data#1248, _spec_id#1249], [id#1247L ASC NULLS FIRST]
   +- CometExchange rangepartitioning(id#1247L ASC NULLS FIRST, 200), ENSURE_REQUIREMENTS, CometNativeShuffle, [plan_id=3533]
      +- CometFilter [id#1247L, data#1248, _spec_id#1249], (id#1247L >= 10)
         +- CometIcebergNativeScan [id#1247L, data#1248, _spec_id#1249], /var/folders/d2/b93h6k7174ddqxltrxgb51040000gn/T/iceberg_warehouse16657344237870771906/iceberg_data/default/table/metadata/00004-70b616c5-1bbe-4e1d-bdd6-e9ac4910674a.metadata.json, testrest.default.table (branch=null) [filters=id IS NOT NULL, id >= 10, groupedBy=], 1

@hsiang-c

hsiang-c commented Jun 22, 2026

Copy link
Copy Markdown
Author

FYI @advancedxy @parthchandra

if task
.project_field_ids()
.contains(&RESERVED_FIELD_ID_SPEC_ID)
&& let Some(partition_spec) = &task.partition_spec

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think we should always add spec_id into the constant map if it's selected/projected.

The task.partition_spec should be present even for unpartitioned table(spec_id = 0).

@mbutrovich mbutrovich Jun 30, 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.

+1. If the invariant is that task.partition_spec is always present (spec_id = 0 even for unpartitioned tables), then gating the column on let Some(partition_spec) means a projected _spec_id can silently produce no column when that invariant is ever violated, which would be a confusing 'column vanished' bug rather than a clean error. Adding _spec_id to the constant map whenever it's projected (per @advancedxy) is the safer contract. Worth a test on an unpartitioned table to lock in spec_id = 0.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@mbutrovich Thanks for the suggestion, I think it makes sense and I have a test_select_with_spec_id_column_from_unpartitioned_table for this case.

Comment on lines 96 to +102
partition_spec_id: manifest_file.partition_spec_id,
bound_predicates: bound_predicates.clone(),
snapshot_schema: snapshot_schema.clone(),
delete_file_index: delete_file_index.clone(),
name_mapping: name_mapping.clone(),
case_sensitive,
partition_spec: partition_spec.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

the partition spec is already in mainifest_file, like the partition_spec_id. maybe we could just use that and avoid embedding partition_spec into ManifestFileContext

@advancedxy

Copy link
Copy Markdown

@hsiang-c thanks, I think it's in good shape, just two minor comments.

@mbutrovich mbutrovich 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.

This is a clean, well-scoped PR. Reusing with_constant is exactly how Java treats _spec_id (a per-file constant in PartitionUtil.constantsMap), and the REE-vs-schema-type split in record_batch_transformer.rs looks like a genuine fix the other two PRs will want. Two questions before I'd feel good approving, both about test confidence rather than the implementation:

  1. Both _spec_id tests assert 0 on single-spec fixtures. Could we add one where two files have different spec ids, since that's the case the column exists for?
  2. The change edits the shared setup_manifest_files fixture (and its inspect/manifests.rs snapshot). Was that necessary, or could a dedicated fixture avoid weakening coverage for other tests?

(I've read @advancedxy's review. The "spec_id should always be added when projected / partition_spec is always present" point is already covered there, so I've turned mine into a +1 rather than a separate comment. The items below are the ones I didn't see raised yet.)

There is also a cross-PR shape question (below): I think the synthesized metadata-column mechanisms should converge, but the enum is better introduced in #2668 than here. This PR just needs to keep its scalar handling foldable.

Three sibling PRs each add a metadata column, and each currently reaches for a different mechanism: this PR (_spec_id) reuses the existing constant_fields: HashMap<i32, Datum> map; #2668 (_partition) adds partition_column: Option<PartitionColumnConstant>; #2746 (_pos) adds virtual_fields: HashSet<i32>.

Two of these are the same kind of thing and one isn't:

  • _file/_spec_id/identity partitions and _partition all synthesize a constant column (they already converge on ColumnSource::Add / AddStructConstant).
  • _pos is a different axis: arrow-rs RowNumber makes it a real column in the source batch, so it is passed through, not synthesized. It probably should stay its own small mechanism.

So the unification worth making is scalar-constant plus struct-constant (this PR's path plus #2668's), not all three. The natural place to introduce it is #2668, where the genuine second synthesis variant (StructConstant) arrives and both arms are test-covered at introduction (the existing _file test plus #2668's _partition tests). There's no good way to land that enum as a standalone PR first: the StructConstant arm has no producer or consumer until #2668, so it would be untested speculative code.

The only ask on this PR: keep the scalar-constant handling (the REE-vs-schema-type branch) in a shape that #2668 can fold into a MetadataColumnSource::ScalarConstant arm cleanly. Since this merges first in the suggested order (#2695 -> #2746 -> #2668), it sets the scalar baseline and #2668 generalizes on top. The full sketch is in the #2668 notes (a comment on its partition_column field; the author can move it to #2699 if that's a better home). cc the #2668 / #2746 authors.

Comment thread crates/iceberg/src/scan/mod.rs Outdated
.file_size_in_bytes(parquet_file_size)
.record_count(1)
.partition(Struct::from_iter([Some(Literal::long(100))]))
.partition(Struct::from_iter([Some(Literal::long(1))]))

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.

Collapsing three distinct partition values to 1/1/1 reduces distinctness for every test on setup_manifest_files and forces the inspect/manifests.rs bound edits below. Was there a failure with the original values that motivated this? If the new constant-type handling needs specific values, a separate fixture might keep the shared one intact. But if the old values actually broke, that itself sounds like a test worth keeping. What did you hit here?

@hsiang-c hsiang-c Jul 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks @mbutrovich, good catch. It is actually a bug in my prior commits b/c I didn't distinguish field ids that are not present in a data file. This is now fixed.

Comment thread crates/iceberg/src/inspect/manifests.rs Outdated
StringArray
[
"100",
"1",

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.

This expected-output edit only exists because the fixture changed; if the fixture stays, this revert isn't needed. Flagging so reviewers know it's coupled to the above, not an independent behavior change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This unnecessary change is reverted.

assert!(result.is_ok(), "Scan timed out - deadlock detected");
}

#[tokio::test]

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.

Both new tests assert 0 on a single-spec table. The whole point of _spec_id (https://iceberg.apache.org/spec/#reserved-field-ids) is distinguishing files written under different specs. A partition-evolved fixture where two files carry different spec ids would validate that directly. Would that be easy to add on top of the existing fixtures?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I added a new test fixture with evolved table partitions.

if task
.project_field_ids()
.contains(&RESERVED_FIELD_ID_SPEC_ID)
&& let Some(partition_spec) = &task.partition_spec

@mbutrovich mbutrovich Jun 30, 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.

+1. If the invariant is that task.partition_spec is always present (spec_id = 0 even for unpartitioned tables), then gating the column on let Some(partition_spec) means a projected _spec_id can silently produce no column when that invariant is ever violated, which would be a confusing 'column vanished' bug rather than a clean error. Adding _spec_id to the constant map whenever it's projected (per @advancedxy) is the safer contract. Worth a test on an unpartitioned table to lock in spec_id = 0.

Comment thread crates/iceberg/src/arrow/value.rs Outdated
&& let Some(partition_spec) = &task.partition_spec
{
let spec_id_datum = Datum::int(partition_spec.spec_id());
record_batch_transformer_builder = record_batch_transformer_builder

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.

Routing _spec_id through with_constant makes it RunEndEncoded (metadata-field constants get datum_to_arrow_type_with_ree), same as _file. The spec types _spec_id as a plain int (https://iceberg.apache.org/spec/#reserved-field-ids). For the Comet/Spark consumer, does REE get decoded cheaply downstream, or would a plain Int32 avoid a cast? Not necessarily a change, just confirming the consumer is happy with REE here as it is for _file.

datum_to_arrow_type_with_ree(datum)
} else {
field_id_to_mapped_schema_map
.get(field_id)

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.

Separating "metadata field becomes REE" from "identity-partition field becomes schema type" looks like a real correctness improvement: identity-partition constants shouldn't be forced to RunEndEncoded. Worth calling out to the #2668/#2746 authors so they rebase on top rather than reintroducing the old derivation. This branch is the scalar arm that #2668 would fold into a MetadataColumnSource::ScalarConstant; keeping it in this shape makes that easy.

@hsiang-c hsiang-c force-pushed the meta_col_spec_id branch from 5ecd879 to bf527a6 Compare July 6, 2026 00:46
@hsiang-c hsiang-c force-pushed the meta_col_spec_id branch from bf527a6 to aee857e Compare July 6, 2026 00:48
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.

Support for projecting metadata columns _pos, _spec_id, and _partition in table scan

3 participants