Core: Add v4 manifest reader - #16958
Conversation
| .specId(0); | ||
| } | ||
|
|
||
| private static TrackedFile fileWithStatus(EntryStatus status, String location) { |
There was a problem hiding this comment.
Not using the builder here so that we can set any status here.
gaborkaszab
left a comment
There was a problem hiding this comment.
I'm in the process of getting familiar with the details here. Asked early questions for my benefit.
Thanks @anoopj !
| import org.apache.iceberg.util.StructProjection; | ||
|
|
||
| /** Reader that reads a v4 manifest file as {@link TrackedFile}s. */ | ||
| class V4ManifestReader extends CloseableGroup implements CloseableIterable<TrackedFile> { |
There was a problem hiding this comment.
this name V4ManifestReader would become stale when V5 rolls in. maybe TrackedFileManifestReader or just TrackedFileReader.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
That is actually a good point. Leaving it open to hear from others.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is a good point, but I don't have a name suggestion that is better right now. Let's keep it in mind.
| import org.apache.iceberg.util.StructProjection; | ||
|
|
||
| /** Reader that reads a v4 manifest file as {@link TrackedFile}s. */ | ||
| class V4ManifestReader extends CloseableGroup implements CloseableIterable<TrackedFile> { |
There was a problem hiding this comment.
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.
|
|
||
| if (field.fieldId() == TrackedFile.TRACKING.fieldId()) { | ||
| fields.add(trackingWithRowPosition()); | ||
| } else if (field.fieldId() == TrackedFile.CONTENT_STATS_ID) { |
There was a problem hiding this comment.
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
| TrackedFile.TRACKING.name(), | ||
| fullTracking ? TrackingStruct.BASE_TYPE : STATUS_TRACKING, | ||
| TrackedFile.TRACKING.doc())); | ||
| } else if (field.fieldId() == TrackedFile.CONTENT_STATS_ID) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| * @param specs the partition specs to unify | ||
| */ | ||
| static StructType partitionType(Schema schema, Collection<PartitionSpec> specs) { | ||
| return buildPartitionProjectionType("table partition", specs, allActiveFieldIds(schema, specs)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
5c6803c to
6927581
Compare
|
|
||
| @ParameterizedTest | ||
| @FieldSource("FORMATS") | ||
| public void testPartitionFilterKeepsFileWithNullSpecId(FileFormat format) throws IOException { |
There was a problem hiding this comment.
Isn't this tested elsewhere? I seem to remember it.
There was a problem hiding this comment.
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.
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.
…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() { |
There was a problem hiding this comment.
Minor oversight here: we only test for struct/list, but not a primitive field (it does work, but there's no test)
There was a problem hiding this comment.
Good point. Added testReplaceFieldTypesPrimitive.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| import org.apache.iceberg.Schema; | ||
| import org.apache.iceberg.relocated.com.google.common.collect.Lists; | ||
|
|
||
| class ReplaceTypeById extends TypeUtil.SchemaVisitor<Type> { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
This is by design (there was a long thread with Ryan and Gabor) to be compatible with v3 reader. Added a test.
| return matches; | ||
| } | ||
|
|
||
| private void incrementSkipCount(FileContent content) { |
There was a problem hiding this comment.
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);
}
}
|
|
||
| @ParameterizedTest | ||
| @FieldSource("FORMATS") | ||
| public void testStatusFiltering(FileFormat format) throws IOException { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
You are correct. I will send a followup PR to clean this up. (too much churn in this PR)
|
|
||
| TrackedFile actual = Iterables.getOnlyElement(read(manifest, UNPARTITIONED_SPECS)); | ||
| // unpartitioned manifests omit the partition field, which is read as null | ||
| assertThat(actual.partition()).isNull(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ScanMetrics metrics = ScanMetrics.of(new DefaultMetricsContext()); | ||
| try (V4ManifestReader reader = | ||
| V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS) | ||
| .filter(Expressions.equal("id", 1)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
The reader supports:
Content stats reading and metadata inheritance are not yet implemented.