Skip to content

API, Core: Add ManifestFile adapter for v4 tracked files - #17932

Merged
rdblue merged 6 commits into
apache:mainfrom
anoopj:manifestfile-adapter-2
Sep 5, 2026
Merged

API, Core: Add ManifestFile adapter for v4 tracked files#17932
rdblue merged 6 commits into
apache:mainfrom
anoopj:manifestfile-adapter-2

Conversation

@anoopj

Copy link
Copy Markdown
Member

Reintroduces the ManifestFile adapter closed in #16867, which stalled because ManifestFile had no way to represent a manifest deletion vector. The V4 scan task planner (#17541) plans over manifests through the ManifestFile API, so this adds ManifestFile.deletionVector() and has the adapter expose the tracked file's manifest DV.

Reintroduces the ManifestFile adapter closed in apache#16867, which stalled because ManifestFile had no way to represent a manifest deletion vector. The V4 scan task planner (apache#17541) plans over manifests through the ManifestFile API, so this adds ManifestFile.deletionVector() and has the adapter expose the tracked file's manifest DV.
Comment on lines +423 to +425
Preconditions.checkArgument(
tracking.dataSequenceNumber() != null, "Invalid data sequence number: null");
Preconditions.checkArgument(

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Note to reviewers: It is unclear whether we need to do these validations during read time here. Also, we don't validate snapshot IDs currently. I'm OK to just remove it.

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 has been unclear for me too. I'm not sure how we guard against data corruption on the read side and how we guarantee our internal invariants like this. On the read side we apparently assume that these invariants are satisfied, however, I'm not sure this is safe against some malformed data files.
Anyway, even if we want to check against these constraints, I think we should introduce those checks on the reader, not on the adapter. Reaching this point, I guess we should just assume that TrackedFile is correct and invariants are satisfied.
So probably we are fine not checking them here.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Removing them.

Comment threadcore/src/main/java/org/apache/iceberg/TrackedFileAdapters.java Outdated
}

/** Returns the manifest deletion vector, or null if absent. */
default ByteBuffer deletionVector() {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Another design decision is whether we just have a ByteBuffer or whether we introduce a class for MDVs.

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 fine for now. We have other places that will need to be updated as well.

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

Thanks for the PR, @anoop!

long length();

/** Returns iD of the {@link PartitionSpec} used to write the manifest file. */
/** Returns ID of the {@link PartitionSpec} used to write the manifest file. */

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.

Not sure about the general opinion on this, but I was asked on code reviews multiple occasions to remove all the tiny nitpicking that are unrelated to the PR itself. I know it seems an overkill to open a separate PR to these, but following that logic, this and the same below should be removed from this PR.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seemed a bit of al overkill. :) I will remove it if there is objection from another reviewer

private final TrackedFile file;

private TrackedManifestFile(TrackedFile file) {
Tracking tracking = file.tracking();

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 see that in TrackedFileAdapter every time we reference a member in Tracking, we branch based on whether Tracking is null or not. Maybe to cover if it's not projected by the read?
Shouldn't we perform that branching in this class too?


@Override
public long minSequenceNumber() {
return file.manifestInfo().minSequenceNumber();

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.

Same question as for Tracking: Are we sure ManifestInfo is not null (part of the projection)?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It should be part of the projection and a reader/caller responsibility. I don't think we should be doing null guards here.

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 agree that we don't want to add a check to account for the case where manifest info wasn't projected.

However, we do need to account for the case where the manifest has not been written into a root manifest and does not yet have Tracking metadata. I think tracking should be: file.tracking() != null ? file.tracking().dataSequenceNumber() : null

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

file.tracking() != null ? file.tracking().dataSequenceNumber() : null

This works for boxed accessors, but won't compile because we need to return a primitive long. ManifestFile.sequenceNumber() and minSequenceNumber() both return long. So should we just return a sentinel value? Perhaps -1?

public abstract class ManifestWriter<F extends ContentFile> implements FileAppender {
// stand-in for the current sequence number that will be assigned when the commit is successful
// this is replaced when writing a manifest list by the ManifestFile wrapper
static final long UNASSIGNED_SEQ = -1L;

Also I assume we need to handle this in sequenceNumber() also.

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.

That constant is always replaced and we should not return it through an API method.

We should just return the value and accept the NPE if it is null. If we can't express that by checking tracking, then it's an NPE either way and we should just return the expression without a null check.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sounds good. So not making any changes here.

private static final String MANIFEST_LOCATION = "s3://bucket/table/manifest.parquet";
private static final String DATA_FILE_LOCATION = "s3://bucket/data/file.parquet";
private static final String DV_LOCATION = "s3://bucket/puffin/dv-file.bin";
private static final String MANIFEST_FILE_LOCATION = "s3://bucket/table/manifest-1.parquet";

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.

Can't we use the existing MANIFEST_LOCATION field?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right. Done.

value = FileContent.class,
names = {"DATA_MANIFEST", "DELETE_MANIFEST"})
void manifestFileAdapterDelegation(FileContent contentType) {
ByteBuffer keyMetadata = ByteBuffer.wrap(new byte[] {7, 8, 9});

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.

keyMetadata is not specific to this test, can it be a private static member?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Yes. Hoisting to a constant.


assertThatThrownBy(manifest::partitionSpecId)
.isInstanceOf(UnsupportedOperationException.class)
.hasMessage("v4 manifests are not bound to a single partition 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.

Is spec not allowed or optional for manifests? I know it they aren't bound to a single partition as the comment says, but in case they happen to, then is it still not allowed to set this?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

My rationale was that the concept doesn't apply anymore, so callers should not rely on it.

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 agree with this. We don't want to alter the method unless we have to. Throwing an exception is the right call for now.

assertThat(copy.keyMetadata()).isEqualTo(original.keyMetadata());
assertThat(copy.keyMetadata().array()).isNotSameAs(original.keyMetadata().array());
assertThat(copy.deletionVector()).isEqualTo(original.deletionVector());
assertThat(copy.deletionVector().array()).isNotSameAs(original.deletionVector().array());

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.

Shouldn't we use the mocking mechanism we do for TrackedFile tests to verify that the nested struct's copy part?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Thank you. Converting it to mocks.

Comment on lines +423 to +425
Preconditions.checkArgument(
tracking.dataSequenceNumber() != null, "Invalid data sequence number: null");
Preconditions.checkArgument(

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 has been unclear for me too. I'm not sure how we guard against data corruption on the read side and how we guarantee our internal invariants like this. On the read side we apparently assume that these invariants are satisfied, however, I'm not sure this is safe against some malformed data files.
Anyway, even if we want to check against these constraints, I think we should introduce those checks on the reader, not on the adapter. Reaching this point, I guess we should just assume that TrackedFile is correct and invariants are satisfied.
So probably we are fine not checking them here.

}

@Override
public ByteBuffer deletionVector() {

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.

It's a little odd that in most places we're using deletionVector that returned DeletionVector but here we're wrapping a dv() call and returning ByteBuffer. I wonder if we should use a different name to signal that it isn't the same thing. What about metadataDV or manifestDeletionVector?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Renamed to manifestDeletionVector. I think it is a bit redundant to have ManifestFile.manifestDeletionVector - let me know if you have a suggestion here.

/* dvSnapshotId= */ null,
FIRST_ROW_ID,
/* deletedPositions= */ null,
/* replacedPositions= */ null);

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.

Most places use // replaced positions rather than using an inline comment before null.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changing to //

null,
null,
null,
null,

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.

It would be helpful to label these as well.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added

FORMAT_VERSION_V4,
MANIFEST_LOCATION,
FileFormat.PARQUET,
0L,

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.

We should not pass values that could easily be disallowed later with more strict validation. Let's pass a real value here instead.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed it to 10


@Test
void manifestFileAdapterPartitionSpecIdUnsupported() {
TrackedFile file =

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 test case could be combined with the previous case. That would avoid creating a nearly identical tracked file that leaves the reader wondering what changed between the two cases.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Folded into manifestFileAdapterDelegation

@anoopj
anoopj requested a review from rdblueSeptember 4, 2026 20:29
}

/** Returns the manifest deletion vector, or null if absent. */
default ByteBuffer manifestDeletionVector() {

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.

Originally, I thought that we could come back and update the return type here, but I was wrong because this is a public API interface. So I think rather than using ByteBuffer as a placeholder we should introduce a type to return.

That means we need to decide on a name and whether we want a generic interface for all bitmaps/DVs or if we want one for the manifest/embedded use case. We already have DeletionVector that tracks DV metadata, and PositionDeleteIndex for DVs that we've loaded into memory.

I don't think we want to reuse PositionDeleteIndex because it's mutable and represents a bitmap that could have positions that are in the range of longs. That's also in core so moving it to API to use it here would be a bigger change.

Instead, I think we should introduce EmbeddedBitmap or ManifestBitmap (suggested by @anoopj) and use int for positions. Using a name based on "bitmap" makes it distinct from DeletionVector. Here's a minimal read-only interface that I think we can use:

packageorg.apache.iceberg;
importjava.nio.ByteBuffer;
/** Interface for small bitmaps that are serialized in metadata files. */publicinterfaceEmbeddedBitmap {
/** Number of bits set in this bitmap. */intcardinality();
/** Return whether the bit at {@code position} is set. */booleanisSet(intposition);
/** Return the serialized bitmap as a {@link ByteBuffer}. */ByteBufferbuffer();
}

We can also make that smaller if we are concerned about releasing any of those methods. All we really need to decide is the name, but I think we are definitely targeting the int position range so this makes sense to me.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sounds great. I went with ManifestBitmap, but open to changing.


@Override
public ByteBuffer manifestDeletionVector() {
return file.manifestInfo().dv();

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.

For now, I think we should replace this with a placeholder implementation or null:

returnnewEmbeddedBitmap() {
@Overridepublicintcardinality() {
thrownewUnsupportedOperationException("Bitmap decoding has not been implemented");
}
@OverridepublicbooleanisSet(intposition) {
thrownewUnsupportedOperationException("Bitmap decoding has not been implemented");
}
@OverridepublicByteBufferbuffer() {
returnfile.manifestInfo().dv();
}
};

I'd be fine returning null for now. Either way, once this is in we will need to get the bitmap implementation in and update the V4ManfiestReader to use it.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Went with the placeholder implementation.

new TrackingStruct(
EntryStatus.ADDED,
SNAPSHOT_ID,
// data and file sequence numbers must be equal

@rdbluerdblueSep 4, 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.

Minor: I don't think this is always true. They can be equal. But I think in the case when you add a new column file, the data sequence number gets updated, but the file sequence number would not.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good point. Fixed.

.addedFilesCount(3)
.existingFilesCount(5)
.deletedFilesCount(2)
.replacedFilesCount(0)

@rdbluerdblueSep 4, 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.

@stevenzwu added these methods to ManifestFile in https://github.com/apache/iceberg/pull/16936/changes. Maybe we should add those here first, since this is a simpler interface?

If we add them, then I think we should test non-zero values. I think that these should default to 0 since that is the case for pre-v4 manifest files. (See my comment: https://github.com/apache/iceberg/pull/16936/changes#r3929689004)

If we don't end up adding them here, we'll need to change these values and test them after that PR is merged.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

To reduce churn in @stevenzwu 's PR, I will leave them out for now. I can do a followup as soon as his PR is merged.

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

This looks good to me. Tests are about ready but I made a couple minor suggestions. The only blocker is introducing an interface for the bitmap since this is returning through a public API (this comment: https://github.com/apache/iceberg/pull/17932/changes#r3938099334)

@anoopj
anoopj requested a review from rdblueSeptember 4, 2026 23:09
@rdblue
rdblue merged commit 3a68b70 into apache:mainSep 5, 2026
43 checks passed
@anoopjanoopj moved this to Done in V4: metadata treeSep 5, 2026
@anoopjanoopj mentioned this pull request Sep 5, 2026
3 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants

@anoopj@rdblue@gaborkaszab