Skip to content

Core: Add v4 manifest reader - #16958

Open
anoopj wants to merge 26 commits into
apache:mainfrom
anoopj:v4-manifest-reader
Open

Core: Add v4 manifest reader#16958
anoopj wants to merge 26 commits into
apache:mainfrom
anoopj:v4-manifest-reader

Conversation

@anoopj

@anoopj anoopj commented Jun 24, 2026

Copy link
Copy Markdown
Member

The reader supports:

  • column projection
  • partition pruning via a row filter, including multi-spec manifests

Content stats reading and metadata inheritance are not yet implemented.

@anoopj anoopj moved this to In review in V4: metadata tree Jun 24, 2026
@github-actions github-actions Bot added the core label Jun 24, 2026
.specId(0);
}

private static TrackedFile fileWithStatus(EntryStatus status, String location) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not using the builder here so that we can set any status here.

@gaborkaszab gaborkaszab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm in the process of getting familiar with the details here. Asked early questions for my benefit.
Thanks @anoopj !

Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java
import org.apache.iceberg.util.StructProjection;

/** Reader that reads a v4 manifest file as {@link TrackedFile}s. */
class V4ManifestReader extends CloseableGroup implements CloseableIterable<TrackedFile> {

@stevenzwu stevenzwu Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this name V4ManifestReader would become stale when V5 rolls in. maybe TrackedFileManifestReader or just TrackedFileReader.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

TrackedFile is an abstraction that we want to keep internal for now. V4ManifestReader is the best name I could come up with. Open to other suggestions here. cc @rdblue for his thoughts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class implements and returns a CloseableIterable<TrackedFile> so we don't really keep TrackedFile internal in my opinion. Following this design we can name this TrackedFileReader.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That is actually a good point. Leaving it open to hear from others.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TrackedFile is internal in the sense that it is not a public interface (at least not yet). This class TrackedFileReader would probably only be package private.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a good point, but I don't have a name suggestion that is better right now. Let's keep it in mind.

Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
import org.apache.iceberg.util.StructProjection;

/** Reader that reads a v4 manifest file as {@link TrackedFile}s. */
class V4ManifestReader extends CloseableGroup implements CloseableIterable<TrackedFile> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class implements and returns a CloseableIterable<TrackedFile> so we don't really keep TrackedFile internal in my opinion. Following this design we can name this TrackedFileReader.

Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated

if (field.fieldId() == TrackedFile.TRACKING.fieldId()) {
fields.add(trackingWithRowPosition());
} else if (field.fieldId() == TrackedFile.CONTENT_STATS_ID) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the explanation! Projecting stats makes total sense.

I think most of my comment on this area are for a single reason: for me this readSchema() function seems a bit more complicated than what it is for. What I have in mind is like this:

if (fileProjection == null) {
  return TrackedFileStruct.READ_SCHEMA;
  // this could be prepared with the desired Tracking schema, omitting stats, including partition unconditionally. Preferable a static field in TrackedFileStruct.
}

// construct the projected schema including the mandatory fields similarly as now

Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
@anoopj
anoopj requested a review from stevenzwu July 1, 2026 14:40
TrackedFile.TRACKING.name(),
fullTracking ? TrackingStruct.BASE_TYPE : STATUS_TRACKING,
TrackedFile.TRACKING.doc()));
} else if (field.fieldId() == TrackedFile.CONTENT_STATS_ID) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not convinced this is how we'll be projecting stats from the read path. Ideally, we wouldn't project the whole stats field (and that's just the containing field id).

What we should have is the specific field ids for the individual status required to evaluate the filter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I do agree: we will be projecting only the field IDs we need. This is just a placeholder for now, as the interfaces for content stats are being revamped.

Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java
Comment thread core/src/main/java/org/apache/iceberg/Partitioning.java
* @param specs the partition specs to unify
*/
static StructType partitionType(Schema schema, Collection<PartitionSpec> specs) {
return buildPartitionProjectionType("table partition", specs, allActiveFieldIds(schema, specs));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think using allActiveFieldIds is correct here.

That method will filter out partition fields when the source is no longer part of the table schema. In some cases (bucket joins), that is fine because the data is still partitioned by a subset of its partition fields.

However, for a partition type that can hold any partition tuple, it doesn't work. Equality deletes are matched using partition tuple equality. For example, say I have file_a.parquet in partition (id_bucket=7, is_test=false), and I also have a delete file deletes.parquet in partition (id_bucket=7, is_test=true). The delete file does not apply to the data file because they are in different partitions.

With the use of allActiveFieldIds here, dropping the is_test identity partition and then the is_test column from the table would result in a unified partition type that only has id_bucket. As a result, checking whether the partition matches (and the deletes should be applied) will pass for file_a.parquet and deletes.parquet.

This needs to union all partition fields so that we don't drop tuple values that define equality.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for pointing this out - I didn't think about this scenario. We can fix it by dropping the filter on active fields, but there might be another problem: partition specs don't store the type, and we derive it based on the type of the source column. So if the source column doesn't exist in the schema anymore, we can't derive the partition type. I think this might be working in v3 because of the way the reader is implemented: the reader gets the schema from the Avro schema, and uses it instead of the schema from the metadata.

Maybe the Parquet reader can also work that way (can verify), but is this by design? Does the other Iceberg implementations actually work in this scenario in v3 if this detail is not in the spec?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So if the source column doesn't exist in the schema anymore, we can't derive the partition type.

This is a good point to think about, but I think it will not affect what we do in practice. It's possible to remove old partition specs, but in practice this hardly ever happens. In addition, the only cases where we can't determine the output type of the transform are identity and truncate transforms. Truncation isn't very common and identity columns are unlikely to be removed from the table.

Because there are good reasons to think this case is unlikely, I think we should move forward assuming that the partition specs will be there. If we want to be more careful, we can require a metadata check to remove old specs to avoid breaking anything. Also, we can always go back and get the type from existing metadata files at read time if we can't determine the type.

I've also thought about storing the output type of transforms when we add column ranges for partition output values, like we do for UDF result types. We may just need to solve this by storing the output type.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. partitionType now unions all partition fields across specs and the reader's builder no longer takes a table schema. I left the existing partitionType(Table) unchanged since fixing it has wider implications; we can revisit separately. Added a test that a partition field survives dropping its source column.

I agree the output type is only underivable for identity and truncate, but PartitionSpec.partitionType() currently returns unknown whenever the source column is missing so today even a bucket field degrades. If it makes sense, I can do a follow-up that only falls back to unknown for source-dependent transforms, which shrinks this to the rare case you described. I also did some code reading in Iceberg rust, which seems to have a hard failure, possibly because unknown type is not supported yet in Iceberg Rust.

BTW +1 to storing the output type in metadata. v4 seems like the right time to add it since we're already revising the spec. It seems like a good overall improvement. Happy to write that up.

Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
@anoopj
anoopj force-pushed the v4-manifest-reader branch from 5c6803c to 6927581 Compare July 14, 2026 17:44
@anoopj
anoopj requested review from rdblue and stevenzwu July 14, 2026 17:52
Comment thread core/src/main/java/org/apache/iceberg/Partitioning.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/TrackedFile.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/TrackedFileStruct.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated

@ParameterizedTest
@FieldSource("FORMATS")
public void testPartitionFilterKeepsFileWithNullSpecId(FileFormat format) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't this tested elsewhere? I seem to remember it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i don't think it's covered elsewhere. We have testPartitionFilterKeepsFileWithUnknownSpec for a file whose spec ID isn't in the reader's specsById while this one has a null spec ID. they follow different code paths, so worth having a test.

stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 25, 2026
Squashed twin of stevenzwu/v4_write_direction_wrappers for use as a stable
phase 1 base in the v4 read+write reset stack. Mirrors apache PR apache#16936 tip
259a3c4.

Includes a compat fixup for stacking on top of PR apache#16958 (Phase 0), which
renames TrackedFile.schemaWithContentStats(...): StructType to
TrackedFile.schema(...): Schema. TrackedFileAdapters.TRACKED_FILE_FIELD_COUNT
is updated to use the new name (with .asStruct() to keep field-counting
semantics). Delta from PR apache#16936 alone is one line.
stevenzwu added a commit to stevenzwu/iceberg that referenced this pull request Jul 25, 2026
…ce 3)

Additive port of the reader unification from v4_adaptive_assembly (commit
7c7f5c0de0) into the current stack, without disrupting Anoop's apache#16958 scan
Builder API. V4ManifestReader gains commit-side views (forData/forDelete
factories + dataEntries/deleteEntries/dataFileChanges/colocatedDVDeleteFiles/
colocatedDVChanges/rawRows) that materialize GenericDataFile/GenericDeleteFile
from content_entry rows and apply InheritableMetadata. V4ManifestReaderAdapter
bridges V4ManifestReader to the ManifestReader<F> contract, preserving ordinal
and firstRowId assignment/nullification semantics.

ManifestFiles.read / readDeleteManifest gain v4 dispatch: when
manifest.formatVersion() >= 4, build V4ManifestReader + adapter. The reader
dispatch is exercised today only when callers pass an InputFile-backed v4
ManifestFile directly (e.g. TestV4ManifestReader); a v4 manifest read via
Snapshot.dataManifests() still round-trips through the legacy manifest-list
schema (which does not carry format_version) and takes the legacy read path.
That gap closes in slice 2.5+ when SnapshotProducer.applyV4 writes v4 root
manifests and BaseSnapshot.cacheManifests dispatches to RootManifests.read.

ManifestFiles.newWriter / writeDeleteManifest v4 dispatch remains on
ManifestWriter.V4Writer / V4DeleteWriter (legacy manifest_entry format).
Flipping the write dispatch to V4LeafWriter is deferred until the companion
root-manifest commit path lands — otherwise v4 content_entry manifests get
committed through v3-style manifest lists and reads fail.

Supporting additions: GenericManifestEntry.wrapAppendPreservingFirstRowId
(v4+ per-entry firstRowId preservation on ADDED); BaseFile.setFileOrdinal
(adapter uses this to assign ordinals during iteration).

Full :iceberg-core:test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
}

@Test
public void testReplaceFieldTypes() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor oversight here: we only test for struct/list, but not a primitive field (it does work, but there's no test)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point. Added testReplaceFieldTypesPrimitive.

Comment on lines +94 to +98
Pair<Evaluator, StructProjection> partitionFilter = partitionFilters.get(specId);
if (partitionFilter == null) {
// the row filter does not project to a partition filter for this spec
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocker as result would be correct, but it's also overly restrictive. If there's a partition filter that has fields compatible with the tracked file's spec, then some amount of filtering could still be performed. This would trigger a lot of over-reads for schema evolution.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think there's an over-read here beyond the unavoidable case. Inclusive projection is per-predicate, so id = 1 AND data = 'z' against a spec that partitions on id (but not data) still prunes by id. So any field compatible with a spec does contribute to filtering for that spec's files.

The only files kept unfiltered are those whose spec partitions on none of the filter's columns, where there's no partition info to prune on - this is the same as v3 ManifestReader. Added testPartialFilterStillPrunesOnCompatibleField to verify this.

Comment thread core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/V4ManifestReader.java Outdated
Comment thread api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java Outdated
Comment thread api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java Outdated
Comment thread api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java Outdated
Comment thread api/src/main/java/org/apache/iceberg/types/ReplaceTypeById.java
import org.apache.iceberg.Schema;
import org.apache.iceberg.relocated.com.google.common.collect.Lists;

class ReplaceTypeById extends TypeUtil.SchemaVisitor<Type> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor: I think it might be worth pulling this out into a separate PR in order to reduce complexity in this one and harden the impl + have more extensive tests (especially around default value handling)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are correct. That would have been a better choice. Keeping it here because the code has been reviewed. Will add the tests.

}

/** Sets the exact schema to read; used in place of {@link #select(Collection)}. */
Builder project(Schema newProjection) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: project(null) would read everything. If that is a supported case, then we should have a test for it or add a newProjection != null check

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is by design (there was a long thread with Ryan and Gabor) to be compatible with v3 reader. Added a test.

Comment thread api/src/main/java/org/apache/iceberg/types/TypeUtil.java
return matches;
}

private void incrementSkipCount(FileContent content) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this can be shortened/simplified via

private void incrementSkipCount(FileContent content) {
    switch (content) {
      case DATA -> scanMetrics.skippedDataFiles().increment();
      case EQUALITY_DELETES -> scanMetrics.skippedDeleteFiles().increment();
      case DATA_MANIFEST -> scanMetrics.skippedDataManifests().increment();
      case DELETE_MANIFEST -> scanMetrics.skippedDeleteManifests().increment();
      default -> throw new UnsupportedOperationException("Unsupported content type: " + content);
    }
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

moved to the arrow form

Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated

@ParameterizedTest
@FieldSource("FORMATS")
public void testStatusFiltering(FileFormat format) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe for new tests we usually told folks to omit the test prefix because it's not really adding any value and is a legacy thing from JUnit3. Also test methods can be easily identified, since they either have a @Test or another annotation

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are correct. I will send a followup PR to clean this up. (too much churn in this PR)

Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated

TrackedFile actual = Iterables.getOnlyElement(read(manifest, UNPARTITIONED_SPECS));
// unpartitioned manifests omit the partition field, which is read as null
assertThat(actual.partition()).isNull();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm guessing this behavior is to be consistent with previous readers, but it seems like not materializing the empty partition creates a special case we'll have to deal with when working with mixed partitions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, this is consistent with v2/v3 behavior. Good news is that it doesn't leak into mixed partitions. The reader builds its read schema from the union partition type across all specs in the manifest. The null partition case only occurs when that union is empty. With mixed spes, the union is non empty so even a file with an unpartitioned spec produces a PartitionData over the union type. So there is no special case scenario.

Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
ScanMetrics metrics = ScanMetrics.of(new DefaultMetricsContext());
try (V4ManifestReader reader =
V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
.filter(Expressions.equal("id", 1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, is this test actually testing anything? I guess technically it produces the correct result, but we're not actually evaluating the filter.

I guess this shields against breaking this behavior in the future, but it's not actually testing what we intend it to test just yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The test is structured that way because we didn't incorporate content stats yet. We can change it when we do the followup cc @nastra

Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
Comment thread core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API core Iceberg V4 Iceberg Table Format Version 4

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

8 participants