Conversation
| // resolves stored locations against the table location | ||
| private TrackedFile copyResolved(TrackedFile trackedFile) { | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copy(); | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copyWithStats(requestedStatsFieldIds); |
There was a problem hiding this comment.
Only requested stats should be copied.
There was a problem hiding this comment.
reader also auto select stats for filter referenced fields. won't this change skip the filter stats during copy.
copyResolved is cloning the trackedFile after projection read from Parquet. do we need the redundant stats projection in copyResolved?
There was a problem hiding this comment.
Empty requestedStatsFieldIds makes this "copy without stats". The default (no select/project/forScanPlanning) path still projects statsWriteSchema, so full stats are decoded and then dropped. Rewrite callers that used to keep all stats will lose them unless they pass projectStats for every field.
There was a problem hiding this comment.
do we need the redundant stats projection in copyResolved?
Yes, this should return only the stats that are requested in the forScanPlanning case. The behavior in the existing scan planning path is to remove stats once they are used for filtering, unless they were requested by the caller.
Rewrite callers that used to keep all stats will lose them unless they pass projectStats for every field.
This is a good catch. When this is using the table's manifest schema with full content stats, all stats should be passed back to the caller, unless projectStats is called to narrow them.
Here's what I propose for each case:
forScanPlanning: project stats for all filter fields andprojectStatsfields, copy only theprojectStatsfields- No projection (rewrite case): project all stats in the table's manifest schema. If
projectStatsis called, pass the IDs tocopyWithStats selectandproject: all columns, including stats are projected based on the table's manifest schema.projectStatsis not allowed because stats projection is controlled by the caller.
| // content_stats is missing from the read schema when no stats are read | ||
| Types.NestedField statsField = readSchema.findField(TrackedFile.CONTENT_STATS_ID); | ||
| if (statsField != null) { | ||
| if (statsField != null && statsField.type().isStructType()) { |
There was a problem hiding this comment.
Rather than rewriting the schema to partition and stats fields when their type is unknown, this checks that the type is a struct before registering custom types for the fields. This is simpler overall and removes a schema rewrite.
| this.tableLocation = tableLocation; | ||
| } | ||
|
|
||
| static Builder builder( |
There was a problem hiding this comment.
Public static methods should be at the top of the file, not in the main body of the class.
|
|
||
| /** Reader that reads a v4+ manifest file as {@link TrackedFile}s. */ | ||
| class V4ManifestReader extends CloseableGroup implements CloseableIterable<TrackedFile> { | ||
| private static final Set<Integer> REQUIRED_COLUMN_IDS = |
There was a problem hiding this comment.
Required columns are now tracked as a set of IDs, with documentation.
| * @param newTableLocation active table location | ||
| * @return this for method chaining | ||
| */ | ||
| Builder tableLocation(String newTableLocation) { |
There was a problem hiding this comment.
This is not required. If it is needed but not supplied, path resolution throws a NullPointerException.
| /** Sets the exact schema to read; used in place of {@link #select(Collection)}. */ | ||
| /** Sets the exact schema to read; used in place of {@link #select(Iterable)}. */ | ||
| Builder project(Schema newProjection) { | ||
| Preconditions.checkArgument(newProjection != null, "Invalid projection: null"); |
There was a problem hiding this comment.
There is no need to reset the projection by passing null here.
|
|
||
| /** | ||
| * Sets the metrics config that determines which stats the manifest holds. Defaults to the | ||
| * config produced by the table's default metrics properties. |
There was a problem hiding this comment.
The metrics config is no longer defaulted. It looks like this default was intended to avoid rewriting test cases that did not pass the metrics config. This is a bad choice because metrics config is needed to determine the table's current manifest schema. Using an incorrect schema can easily lead to dropping content stats or storing extra content stats, and there is no way to detect the error.
| scanMetrics); | ||
| } | ||
|
|
||
| private Map<Integer, Pair<Evaluator, StructProjection>> projectFilters() { |
| private Schema readSchema(boolean includePartition) { | ||
| if (scanPlanning) { | ||
| // scan planning does not read the change-tracking fields omitted by SCAN_TYPE | ||
| Types.StructType statsProjection = StatsUtil.statsReadSchema(tableSchema, statsFieldIds()); |
There was a problem hiding this comment.
statsFieldIds() combines requested stats IDs and filter IDs for the scan planning case.
| if (scanPlanning || fieldIdsWithRequestedStats != null) { | ||
| return requiredStatsType; | ||
| } | ||
| Types.StructType tableStatsSchema = StatsUtil.statsWriteSchema(tableSchema, metricsConfig); |
There was a problem hiding this comment.
All cases other than scan planning are projections of the table's current manifest schema, which is derived from the table schema and metrics config.
| if (requestedProjection != null) { | ||
| return union(requiredStatsType, projectedStatsType(requestedProjection)); | ||
| return RestoreColumns.restore( | ||
| tableManifestSchema, requestedProjection, idsToRestore(includePartition)); |
There was a problem hiding this comment.
RestoreColumns is a better way to ensure columns from the table manifest schema are present, instead of gathering all leaf field IDs and depending on select. The required IDs are known ahead of time and added to the projection.
| import org.mockito.Mockito; | ||
|
|
||
| class TestV4ManifestReader { | ||
| private static final InputFile UNUSED_IN_FILE = Mockito.mock(InputFile.class); |
There was a problem hiding this comment.
Used for cases where the file is not read.
| TrackedFile.TRACKING.fieldId(), | ||
| Types.StructType.of(Tracking.STATUS, Tracking.SNAPSHOT_ID))) | ||
| .asStruct(); | ||
| private static final Comparator<TrackedFile> FILE_COMPARATOR = comparator(FILE_VALIDATION_TYPE); |
There was a problem hiding this comment.
This comparator is used to validate TrackedFile contents, excluding Tracking because tracking has inherited fields that do not match the file that was written.
| "s3://bucket/table/id=2/eq-deletes-b.parquet", idPartition(2)); | ||
| private static final TrackedFile DATA_MANIFEST_REF = | ||
| manifestRef(FileContent.DATA_MANIFEST, "data-leaf.parquet"); | ||
| manifestRef(FileContent.DATA_MANIFEST, "s3://bucket/table/data-leaf.parquet"); |
There was a problem hiding this comment.
All tests that are not related to path resolution use full URIs. Path resolution tests embed locations so that the test cases are more readable.
| @ParameterizedTest | ||
| @FieldSource("MANIFEST_FORMATS") | ||
| public void equalityDeleteRoundTrip(FileFormat format) throws IOException { | ||
| public void readEqualityDelete(FileFormat format) throws IOException { |
There was a problem hiding this comment.
There are now read tests for data file, delete file, and manifest file with all expected fields set. There are also read tests to validate that requested stats are copied correctly.
| fileWithStatus(EntryStatus.DELETED, "s3://bucket/deleted.parquet"), | ||
| fileWithStatus(EntryStatus.REPLACED, "s3://bucket/replaced.parquet")); | ||
| @Test | ||
| public void projectionFullByDefault() { |
There was a problem hiding this comment.
Most projection tests get the reader's projection and validate it directly. This was important with stats because stats may be removed at the copy phase.
| } | ||
| Types.StructType readSchema = builder.build().readSchema().asStruct(); | ||
|
|
||
| assertThat(readSchema.field(TrackedFile.LOCATION.fieldId())) |
There was a problem hiding this comment.
Tests for select and project don't validate all fields. They validate that the projected field is present, at least one required field is present, and that fields needed for filters are (or are not) present.
| import org.apache.iceberg.relocated.com.google.common.collect.Lists; | ||
| import org.apache.iceberg.schema.SchemaWithPartnerVisitor; | ||
|
|
||
| public class RestoreColumns extends SchemaWithPartnerVisitor<Type, Type> { |
There was a problem hiding this comment.
It could be, but this is in the types package and we need to use it in the reader, which isn't in the same package. We have two options here: add a method to TypeUtil or expose RestoreColumns.restore.
We have precedent for both of these patterns in the codebase (like Binder.bind) and I've opted to use the second one because TypeUtil is getting to be large.
There was a problem hiding this comment.
I was initially thinking the same thing but SchemaWithPartnerVisitor itself is public, so by making RestoreColumns public we don't accidentally risk exposing methods that shouldn't be public. Therefore I think it's fine to keep RestoreColumns as public
| // resolves stored locations against the table location | ||
| private TrackedFile copyResolved(TrackedFile trackedFile) { | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copy(); | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copyWithStats(requestedStatsFieldIds); |
There was a problem hiding this comment.
reader also auto select stats for filter referenced fields. won't this change skip the filter stats during copy.
copyResolved is cloning the trackedFile after projection read from Parquet. do we need the redundant stats projection in copyResolved?
| // resolves stored locations against the table location | ||
| private TrackedFile copyResolved(TrackedFile trackedFile) { | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copy(); | ||
| TrackedFileStruct copy = (TrackedFileStruct) trackedFile.copyWithStats(requestedStatsFieldIds); |
There was a problem hiding this comment.
Empty requestedStatsFieldIds makes this "copy without stats". The default (no select/project/forScanPlanning) path still projects statsWriteSchema, so full stats are decoded and then dropped. Rewrite callers that used to keep all stats will lose them unless they pass projectStats for every field.
| } | ||
|
|
||
| @Test | ||
| void unknownToStructProjection() { |
There was a problem hiding this comment.
do we need the test coverage for the other direction: structToUnknownProjection()?
There was a problem hiding this comment.
I don't think so. Structs can't change type to unknown, but unknown can be evolved to a struct.
I'm also not sure that we would be using a schema that was evolved, rather than just restoring columns in a projection. I'd leave this as-is for now.
| */ | ||
| Builder tableLocation(String newTableLocation) { | ||
| Preconditions.checkArgument(newTableLocation != null, "Invalid table location: null"); | ||
| this.tableLocation = newTableLocation; |
There was a problem hiding this comment.
The previous builder stored LocationUtil.stripTrailingSlash(tableLocation) at line 220. resolveLocation documents that tableLocation must not end with /, or the result gets //. Worth stripping here again.
There was a problem hiding this comment.
This was fixed in a concurrent update. It is not the responsibility of the manifest reader to alter the table location.
|
|
||
| @ParameterizedTest | ||
| @FieldSource("MANIFEST_FORMATS") | ||
| void geoStatsAreCorrectWithContainerReuse(FileFormat format) throws IOException { |
There was a problem hiding this comment.
tests around variant/geo with/without container reuse reproduced actual bugs but appear to not be copied over to TestV4ManifestReader and seem to be removed
There was a problem hiding this comment.
The reason for removing these is that it isn't the right place to test the fully copy behavior of TrackedFile. We have tests in TrackedFile, ContentStats, and FieldStats to validate the copy behavior for each type.
In this suite, what we need to test is that copy or copyWithStats is called.
There was a problem hiding this comment.
fair point. let me extract those tests into a separate PR and test the container reuse bugs in the files you mentioned so that we have proper coverage for those scenarios
There was a problem hiding this comment.
@rdblue I've pulled the fixes and tests out into apache#18060. Please take a look
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.FieldSource; | ||
|
|
||
| class TestV4ManifestReaderStats { |
There was a problem hiding this comment.
the reason I moved the stats-related tests into TestV4ManifestReaderStats was because TestV4ManifestReader is already really large and to also align with how we tested these for v1-v3, where we also have TestManifestReader/TestManifestReaderStats
There was a problem hiding this comment.
I thought that the separation left bugs in the reader suite. And there were a ton of tests here that weren't needed because they were testing other components, like the stats tests for specific types.
|
|
||
| @ParameterizedTest | ||
| @FieldSource("MANIFEST_FORMATS") | ||
| void readStatsForNestedFields(FileFormat format) throws IOException { |
There was a problem hiding this comment.
looks like this hasn't been moved over
There was a problem hiding this comment.
Field stats are all tracked at the same level. Does it matter whether the original field was nested or not? I don't see why this would be any different.
There was a problem hiding this comment.
the main motivation was to be explicit and verify that this behavior actually works instead of assuming that it would work. We can also move this to a different place/class if you think it's at the wrong place here
|
|
||
| @ParameterizedTest | ||
| @FieldSource("MANIFEST_FORMATS") | ||
| void selectStatsByName(FileFormat format) throws IOException { |
There was a problem hiding this comment.
would be good to keep this test as well
There was a problem hiding this comment.
Don't stats columns fall in the same category as any other requested column? Why would this be different?
There was a problem hiding this comment.
they do but I feel like we'd rather have an explicit test to make sure this does what we think it does rather than assuming it would do the right thing
|
@nastra and @stevenzwu, I've updated this. The main change was to only filter stats when The reason this took longer than expected was that some tests started failing when the stats were empty. I tracked down the problem and it was that the schema used to build the |
|
|
||
| return tableLocation + PATH_SEPARATOR + location; | ||
| // call toString to cause a NullPointerException if the string is null | ||
| return tableLocation.toString() + PATH_SEPARATOR + location; |
There was a problem hiding this comment.
would it make sense to use Preconditions.checkArgument to be more explicit here or did you want to avoid using it because this method is on the hot path?
There was a problem hiding this comment.
I think it's also a weird idiom in a public util method that we don't really do anywhere in the codebase. The other issue with this is that not every JDK version would actually produce a helpful NPE with an actual message (usually < JDK 17). But this can be even true for JDK >= 17 depending on the JIT compiler. Also the message can be different depending on the actual JDK vendor being used. That being said, I think we're relying here too much on the underlying behavior of a particular JDK
There was a problem hiding this comment.
What is the behavior that we are relying on in a particular JDK? Won't any JDK need to throw a NullPointerException if tableLocation is null?
There was a problem hiding this comment.
the thing that's relying on the JDK is the actual error msg and its format. In certain cases the compiler can just omit the underlying error msg. Other JDK implementations can choose to have a different msg format or also completely omit it
| private Set<String> requestedColumns = null; | ||
| private Schema requestedProjection = null; | ||
| private Set<Integer> fieldIdsWithRequestedStats = null; | ||
| private Set<Integer> requestedStatsFieldIds = null; |
There was a problem hiding this comment.
nit: I still feel like fieldIdsWithRequestedStats slightly better describes that these are field IDs for which stats where requested. requestedStatsFieldIds implies that those are the actual stats field IDs, but the user of the projectStats API always supplies actual table field IDs and so to me reading requestedStatsFieldIds always requires one additional mental step in order to be sure this actually carries table field IDs. Would be good to know how others read this field name
There was a problem hiding this comment.
I think that this distinction is going to be lost for anyone reading this, so we should use the name that is more straightforward. We are keeping the stats field IDs encapsulated in stats utils as much as possible to avoid this kind of issue.
| STATS_TYPE.fieldType("data").asStructType(), "a", "z", false, RECORD_COUNT, 20, 0, null); | ||
| private static final ContentStatsStruct CONTENT_STATS = new ContentStatsStruct(STATS_TYPE); | ||
|
|
||
| static { |
There was a problem hiding this comment.
it's a bit weird to have a static initializer in tests. I think it's probably better to have a method annotated with @BeforeAll
57053a4 to
81102ce
Compare
This simplifies the logic in V4ManifestReader and updates to address the use cases from this comment.
This also rewrites the tests and makes some behavior changes that were found by updating the tests.
TestV4ManifestReadersuite, which now also tests content stats cases.TestV4ManifestReaderStatsis removed and deduplicated with the original reader suite.read,inheritance(mostly empty),projection,statsFilter(mostly empty),partitionFilter,validation, andresolution(path resolution)Behavior changes:
metricsConfigis now required. Inferring the metrics config is dangerous and can easily drop metadatatableLocationis used to configure the builder;NullPointerExceptionis thrown if it is required but missingproject(null)no longer resets the schema; this is invalid