Skip to content

Core: V4 write direction wrappers - #16936

Open
stevenzwu wants to merge 10 commits into
apache:mainfrom
stevenzwu:v4_write_direction_wrappers
Open

stevenzwu wants to merge 10 commits into
apache:mainfrom
stevenzwu:v4_write_direction_wrappers

Conversation

@stevenzwu

@stevenzwu stevenzwu commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds v4 write-direction TrackedFile adapters: reusable forwarding wrappers that present a legacy v2/v3 ContentFile (DataFile/DeleteFile) as a v4 manifest-entry row on the write path, without materializing a fresh struct per row. A single adapter is allocated per writer and re-pointed at each file; content stats are served by a map-backed view (MapBackedContentStats) over the file's stat maps, with copy() producing stable snapshots.

Benchmark summary

JMH microbenchmarks compared two strategies for presenting a legacy file as a v4 row: convert (materialize a fresh struct per row) vs wrap (a reusable wrapper allocated once per writer and re-pointed per row, with zero per-row allocation). Throughput in ops/ms (higher is better); allocation via the GC profiler in B/op (lower is better).

  • Per-column content stats: wrap runs ~1.6–1.9x faster than convert across all column counts and allocates far less (39,280 vs 98,984 B/op at 200 columns). Convert pays per-column allocation and eager bound decode that the map-backed view avoids.
  • Fixed envelope (null stats): convert and wrap are within measurement error (9130 vs 8680 ops/ms); wrap still allocates less (256 vs 688 B/op). The envelope's small, fixed field count means the wrapper's positional dispatch neither meaningfully helps nor hurts.
  • Combined (envelope + stats): wrap-both is fastest or statistically tied at every column count with the lowest per-row allocation. The column-stats cost dominates and scales with table width, so wrapping stats is what matters; the fixed envelope edge is swamped once real column stats are present.

Net: the reusable wrap model is adopted because it wins or ties everywhere and minimizes per-row allocation — the cost that grows with table width.

See the benchmark doc for full methodology and per-column numbers.

Comment thread api/src/main/java/org/apache/iceberg/ManifestFile.java

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

Hey @stevenzwu ,
I went through mostly for my own understanding. Left some comments, mostly I'm a bit hesitant to expose setting status and sequence numbers directly through the builders. Let me know what you think!

Comment thread core/src/main/java/org/apache/iceberg/ContentEntryAdapters.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/ContentEntryAdapters.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/ContentEntryAdapters.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/ContentEntryAdapters.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/ContentEntryAdapters.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/TrackedFileBuilder.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/TrackingBuilder.java Outdated
@stevenzwu
stevenzwu force-pushed the v4_write_direction_wrappers branch 5 times, most recently from c78d6b1 to 828e0c7 Compare June 25, 2026 21:35
@stevenzwu
stevenzwu force-pushed the v4_write_direction_wrappers branch from 828e0c7 to fa86dc1 Compare June 25, 2026 22:18
@github-actions github-actions Bot added the API label Jun 25, 2026
@stevenzwu
stevenzwu force-pushed the v4_write_direction_wrappers branch 5 times, most recently from fa46d3d to 55f5573 Compare June 26, 2026 05:57
Comment thread core/src/main/java/org/apache/iceberg/TrackedFileBuilder.java Outdated
@stevenzwu
stevenzwu force-pushed the v4_write_direction_wrappers branch 8 times, most recently from b5c92a0 to d5c696b Compare June 28, 2026 06:09
Keep cached field stats when rebinding a file, and return null from a factory when the field has no stats struct. Look up the bound type by name.

Generated-by: Cursor Grok 4.7
Co-authored-by: Cursor <cursoragent@cursor.com>
return (FieldStats<T>) statsById.get(fieldId);
}

FieldStats<?> createFieldStats(int fieldId) {

@rdblue rdblue Oct 6, 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.

Nit: private? Or is this exposed for testing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

throw new UnsupportedOperationException("copy is not implemented");
}

boolean containsFieldInMaps(int fieldId) {

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: private?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

new PartitionData(PartitionSpec.unpartitioned().partitionType());

/**
* Stats on fields 1-4; field 5 is absent. Field 3 has no value_count entry so the absent-count

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.

What is an "absent-count" getter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded the comment. Field 3 has no value-count entry, so valueCount() throws when it unboxes a null Long.

4, buf(Types.StringType.get(), "zzz")));

@Test
void contentStatsTypeBuiltLazilyFromMapIds() {

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 doesn't test that the type was built lazily and probably shouldn't. Correctness is what should be tested, not laziness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed this to typeMatchesIdsPresentInMaps. It checks the struct before wrap and the fields after wrap. It does not assert laziness.


assertThat(stats.type().fields())
.extracting(field -> StatsUtil.toFieldId(field.fieldId()))
.containsExactlyInAnyOrder(1, 2, 3, 4);

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 that this should validate against statsReadSchema so that all fields are tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The type is now compared to statsReadSchema for the ids in the file. The comparison is order-independent because the id set is a HashSet. wrapInvalidatesType does the same for the single remaining id.

}

@Test
void boundDecodingPerType() {

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 that this should test all data types. We have similar tests in TestFieldStatsStruct that you can copy: https://github.com/apache/iceberg/blob/main/core/src/test/java/org/apache/iceberg/TestFieldStatsStruct.java#L203-L228

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

boundDecodingPerType is now a parameterized round trip over the same type and bound pairs as TestFieldStatsStruct.TYPES_AND_BOUNDS is private, so the tuples are copied here. Unknown still expects null bounds.

Geometry stays in geoBoundsDecode.

}

@Test
void copyNotSupported() {

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 alright, but I don't think that we need to test it. We may choose to implement this later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed copyNotSupported and fieldStatsCopyNotSupported.

}

@Test
void reuseRebindsBounds() {

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.

The assertions here are mixed. Only the lower bound is tested for the example file, but lower bound, upper bound, value count, and another field entirely have assertions for file2. This could be "reuseRebindsFields` if you want to test them all and a negative case (id=2), but I would do either both bounds or all fields, not half-way between.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to reuseRebindsFields. Both files now assert lower bound, upper bound, and value count for field 1. The second file still expects statsFor(2) to be null.

}

@Test
void wrapReusesFieldStats() {

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 would remove this. Correctness doesn't depend on reusing stats and I think the tests should validate correctness, not that a particular implementation choice was made.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed wrapReusesFieldStats. absentIdIsRereadWhenPresentAgain no longer checks instance identity. It still checks that the id is absent, then present again with the new file's values.

}

@Test
void outOfRangeFieldStatsAreNullAndCached() {

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 that this is needed, especially if it requires extra code injected to validate caching.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed outOfRangeFieldStatsAreNullAndCached and CountingContentStats.

Map<Integer, ByteBuffer> upperBounds) {
Metrics metrics =
new Metrics(
100L, null, valueCounts, nullValueCounts, nanValueCounts, lowerBounds, upperBounds);

@rdblue rdblue Oct 6, 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.

I think it would be better to pass in 100 rather than assuming it matches value counts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dataFile helper now takes the record count and passes it into Metrics. Each call site passes 100L.


@Test
void manifestFileAdapterKeepsZeroFormatVersion() {
TrackedFile original = dummyTrackedFile(FileContent.DATA_MANIFEST, 0);

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 is unclear what is being tested here because of the use of dummyTrackedFile. Is this a tracked file? Why would a tracked file have a format version of 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed manifestFileAdapterKeepsZeroFormatVersion. dataManifestTrackedFileAdapter already wraps a real manifest whose format version is 0.

PartitionData.EMPTY,
1024L,
new Metrics(100L, null, 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.

What are these null values and why should they not be tested?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DATA_FILE now uses new Metrics(100L), KEY_METADATA, a sort order id, and FIRST_ROW_ID, and the field helper asserts those values. firstRowId is checked on the existing-entry path. wrapAppend still suppresses it.

}

@Test
void dataTrackedFileAdapterHasNoTracking() {

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 is too specific should be combined with other cases. There should be a test that validates the behavior for DATA_FILE and this should be part of that test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept this as the single wrap(DataFile) test and renamed it to dataTrackedFileAdapterFromDataFile. It still expects tracking to be null.


@Test
void dataTrackedFileAdapterFromAddedManifestEntry() {
TrackedFile result = TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(addedEntry(DATA_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.

Entry is pretty small so it would be great to just embed the information here rather than needing to go look at what addedEntry sets for defaults.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed addedEntry, existingEntry, and deletedEntry. The remaining tests call wrapExisting directly.


assertThat(result.tracking().status()).isEqualTo(EntryStatus.ADDED);
assertThat(result.tracking().snapshotId()).isEqualTo(SNAPSHOT_ID);
assertThat(result.tracking().dataSequenceNumber()).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.

Why are the sequence numbers null? Are they not mapped properly or are they set to null in Tracking? If it is that they are null in tracking, I think this should pass real values because they should be non-null when converting from a manifest entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Those nulls came from wrapAppend, which stores a null data sequence number. The existing-entry test now asserts DATA_SEQUENCE_NUMBER and FILE_SEQUENCE_NUMBER.

assertThat(result.tracking().snapshotId()).isEqualTo(SNAPSHOT_ID);
assertThat(result.tracking().dataSequenceNumber()).isNull();
assertThat(result.tracking().fileSequenceNumber()).isNull();
assertThat(result.tracking().firstRowId()).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.

Should be tested

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The existing-entry test builds the file with FIRST_ROW_ID and asserts tracking().firstRowId().

TrackedFile result = TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(DATA_FILE);

assertThat(result.tracking()).isNull();
assertWrappedDataFileMatchesFileFields(result, DATA_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.

Good that this is validated. That means that this test is correct and it's just the name that is wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to dataTrackedFileAdapterFromDataFile.

assertWrappedDataFileMatchesFileFields(result, DATA_FILE);
assertThatThrownBy(result::formatVersion)
.isInstanceOf(IllegalStateException.class)
.hasMessage("Format version is assigned at write time");

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.

Hm. Should this be 0 since it is converted from v3 or earlier?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this should keep throwing. A v3 DataFile has no format version to copy. On the manifest path, 0 is forwarded from ManifestFile.formatVersion when the manifest predates format tracking.

We also discussed on Monday's sync that format version should be moved to manifest_info struct that should be set at write time.

}

@Test
void dataTrackedFileAdapterFromAddedManifestEntryWithNullSnapshotId() {

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 this is needed. We know from other tests that the snapshot ID is plumbed through correctly. We don't need to validate that it is plumbed correctly when it takes a specific value (null).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed this. wrapAppend stores the snapshot id it is given, and the existing-entry test already checks a non-null id.

}

@Test
void dataTrackedFileAdapterFromExistingManifestEntry() {

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 is a better test than the added version because it checks values. But we don't need two. The point here is to check that fields are mapped or defaulted correctly in the adapter, not to check that specific cases work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept dataTrackedFileAdapterFromExistingManifestEntry and removed the added-entry test. The existing-entry test inlines wrapExisting and checks status, snapshot id, both sequence numbers, first row id, manifest position, and the file fields.

}

@Test
void dataTrackedFileAdapterFromDeletedManifestEntry() {

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed dataTrackedFileAdapterFromDeletedManifestEntry.

TrackedFileAdapters.DataTrackedFile adapter = TrackedFileAdapters.forDataFile(TABLE_SCHEMA);

adapter.wrap(DATA_FILE);
assertThat(adapter.location()).isEqualTo(DATA_FILE_LOCATION);

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.

There's a helper to validate all fields, so we should use it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dataTrackedFileAdapterReuse now calls assertWrappedDataFileMatchesFileFields after each wrap. The test only asserts tracking itself: null, then EXISTING and the manifest position.

TrackedFileAdapters.asDataFile(result, specsById(PARTITIONED_SPEC))
.partition()
.get(0, String.class))
.isEqualTo("books");

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.

What is this assertion doing?

I would expect this to use a struct comparator to verify that PARTITION matches result.partition().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The partition is compared with Comparators.forType on the tracked file and again after asDataFile.


DataFile roundTripped = TrackedFileAdapters.asDataFile(tracked, specs);

assertThat(roundTripped.content()).isEqualTo(FileContent.DATA);

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 should validate that the returned file is the same as the original. That is, it should assert that the file was unwrapped, not equal.

The reason to validate this is to ensure that we can rely on the assumption that double wrapping does not occur so you are always wrapping a real DataFile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed this to trackedFileDoubleWrapRoundTrip. It builds a fresh TrackedFile, converts it with asDataFile, and asserts wrap returns that same TrackedFile.

asDataFile always allocates a new TrackedDataFile, so the DataFile to TrackedFile to DataFile path is not identity. The original-file check is only in wrap, when the argument is already a TrackedDataFile.


private static ManifestFile writeManifestFile(
ManifestContent content, long sequenceNumber, long minSequenceNumber) {
return writeManifestFile(content, sequenceNumber, minSequenceNumber, 200L);

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'd prefer having constants defined at the top and to minimize these helper methods since you have to look at how they work in order to see if a test is valid or correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the unused 3-arg writeManifestFile and hoisted the manifest counts and sequence numbers the tests assert. The 4-arg overload stays because one rejection test passes a null added-rows count. manifestWithCounts stays for those rejection cases.

return dummyTrackedFile(contentType, FORMAT_VERSION_V4);
}

private static TrackedFileStruct dummyTrackedFile(FileContent contentType, int formatVersion) {

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: Why "dummy" here but almost nowhere else? I don't think this naming convention is a good one. Dummy is not telling the reader anything significant. And in fact, it is misleading because this creates a TrackedFileStruct rather than something else.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed dummyTrackedFile to trackedFile.

stevenzwu and others added 2 commits October 6, 2026 18:39
Assert mapped values instead of implementation details, matching review feedback on the v4 write-direction wrappers.

Generated-by: Cursor Grok 4.7
Co-authored-by: Cursor <cursoragent@cursor.com>
The helpers are only used inside MapBackedContentStats. The manifest count errors stay valid for a null count.

Generated-by: Cursor Grok 4.7
Co-authored-by: Cursor <cursoragent@cursor.com>

This branch has not been deployed

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

Labels

API build core docs Iceberg V4 Iceberg Table Format Version 4

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

7 participants