Uh oh!
There was an error while loading. Please reload this page.
Core: V4 write direction wrappers - #16936
Conversation
| * <p>REPLACED files are the prior-state entries of v4 REPLACED/MODIFIED pairs and are not live. | ||
| * Returns null for manifest files written by pre-v4 writers. | ||
| */ | ||
| default Integer replacedFilesCount() { |
There was a problem hiding this comment.
Generally, public-interface changes should be minimized and delayed. Justification for keeping these two on the interface in this PR:
In-tree consumers (this PR + close follow-ups):
TrackedFileAdapters.WrappedManifestInfo(this PR) forwardsmanifest.replacedFilesCount()/replacedRowsCount()when a v4 root manifest reference row is written.MergingSnapshotProducer.validateAddedDVs(follow-up phase) filters concurrent data manifests byreplacedFilesCount > 0to detect v4 colocated-DV write conflicts — without it, every concurrent data manifest must be deep-scanned.
Convention. The interface-default pattern matches v3's existing extensions for spec-defined optional fields on ManifestFile: firstRowId (row lineage), keyMetadata (encryption), containsNaN (partition NaN). v4 spec-defined manifest_info counts belong on the same interface in the same shape.
A TrackedFile-based parallel v4 API was considered. Would require rewriting ManifestGroup and every engine that consumes FileScanTask. The cost is disproportionate to the savings.
There was a problem hiding this comment.
Projected new default methods across the full v4 stack, all spec-defined additions backing v4 manifest_info / content_entry fields:
In this PR:
default Integer replacedFilesCount()default Long replacedRowsCount()default int formatVersion()
when root-level manifest DV bitmaps are implemented:
default ByteBuffer deletedPositions()default ByteBuffer replacedPositions()
gaborkaszab
left a comment
There was a problem hiding this comment.
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!
| private ContentEntryAdapters() {} | ||
| static TrackedFile fromDataFile( |
There was a problem hiding this comment.
I don't see how the user will use these functions, but would it make sense to merge the fromDataFile and fromDeleteFile into a common fromManifestEntry? Then the user doesn't have to decide if it's data or delete, the adapter can do it instead.
There was a problem hiding this comment.
I actually think we should keep them separate. The validation for delete files is different from data files and so is the error handling and messages, I think it's cleaner that way.
The callers also always know which type they have so it should be easier.
There was a problem hiding this comment.
I agree with keeping these separate for data and delete file cases.
What I think we need to separate is ManifestEntry and DataFile. We accept DataFile instances through the API and create entires in operations like AppendFiles. The best use case is when accepting data files through the public API. There's a secondary use case for wrapping/adapting ManifestEntry for internal uses like rewriting v3 files to v4, but these are more narrow.
For now, I think we need the DataFile to TrackedFile adapter, not ManifestEntry.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
c78d6b1 to
828e0c7Compare828e0c7 to
fa86dc1Comparefa46d3d to
55f5573CompareUh oh!
There was an error while loading. Please reload this page.
b5c92a0 to
d5c696bCompare| null); | ||
| } | ||
| private static DeletionVector deletionVector() { |
There was a problem hiding this comment.
this moved to a static variable near top.
Add reusable write-direction wrappers that present a legacy DataFile, DeleteFile, or ManifestFile as a v4 TrackedFile row for manifest serialization, re-pointed per row to avoid per-row allocation. Content stats are served by a reusable map-backed view over the file's stat maps, with copy() producing a stable snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ManifestFile: simplify LEGACY_FORMAT_VERSION and recordCount javadocs; clarify that recordCount is entries, not file records - GenericManifestFile: drop field-level comments (interface javadoc covers them); make the pre-v4 and v4+ constructors self-contained per the codebase convention (BaseSnapshot / GenericPartitionFieldSummary) instead of the pre-v4 delegating to the v4+ variant; drop stale arg-order-conflict note - TrackedFileAdapters: trim class javadoc to the one-line intent; drop redundant field-count and eq-delete validation comments; document the in-place re-point + fluent-return semantics on wrap(...); note that forDataFile / forEqualityDeleteFile accept pre-v4 source files (the formatVersion param gates the target manifest format only); invert sortOrderId so the base returns null and DataTrackedFile overrides; add TODO for wiring REPLACED aggregates on WrappedManifestInfo Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move MapBackedContentStats out of TrackedFileAdapters into its own file, mirroring the top-level ContentStatsBackedMap on the read side (and its already top-level test). MapBackedFieldStats stays a private nested helper since it reads the parent's stat maps directly. Pure relocation with no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Drop Serializable from MapBackedContentStats/MapBackedFieldStats. These reusable write-direction wrappers are never Java-serialized (the writer consumes them via StructLike, and their holders ContentTrackedFile / ManifestTrackedFile are not Serializable), so they don't need it and don't require a round-trip serialization test. - Carry recordCount and formatVersion through GenericManifestFile.CopyBuilder for non-GenericManifestFile inputs. The else branch built via the pre-v4 constructor and silently dropped both new v4 fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eldStats apache/main (via apache#17322) added presence methods to FieldStats. Implement hasValueCount / hasNullValueCount / hasNanValueCount in MapBackedFieldStats, mirroring FieldStatsStruct. Also drop the redundant final class modifiers and make MapBackedFieldStats a non-static inner class, so it reads the enclosing stats view's maps directly instead of carrying an explicit parent reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production: - Implement the new FieldStats presence methods (hasValueCount / hasNullValueCount / hasNanValueCount) in MapBackedFieldStats. - Drop the -1 sentinel from the count getters; valueCount / nullValueCount / nanValueCount unbox directly and callers must check has*Count() first (matches FieldStatsStruct after apache#17322). - Drop redundant final modifiers and make MapBackedFieldStats a non-static inner class, reading the enclosing view's maps directly. Tests: - Assert upperBound type in testBoundDecodingPerType. - Add testSetNotSupported for the outer set(); split testFieldStatsCopyAndSetNotSupported into two focused tests. - Sharpen testContentStructLikeGetReturnsChildrenOrNull to verify the null slot maps to source field 5, not just that some position is null. - Split the StructLike surface out of testCountsOnlyColumnOmitsBounds into two symmetric tests (testDefaultStructLike / testCountsOnlyStructLike) using Comparators.forType with TestHelpers.Row expectations. - Extend testCountsAndDefaults to also cover the absent-count throw path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TrackedFileAdapters: - Move final manifestInfo field to the top of ManifestTrackedFile so all final fields precede non-final ones. - Trim redundant Javadoc: drop "Instantiate once per writer / call wrap on each row" and "returns this for fluent usage" phrasing from the factory and wrap() methods, and tighten the @PARAM docs (formatVersion "must be 4+"; drop "table schema for building X from the file's stats" style restatements while keeping the caller guidance on partitionType). TestMapBackedContentStats: - Add message checks on the auto-unbox NullPointerException assertions so checkstyle's AssertThatThrownByWithMessageCheck rule accepts them. TestTrackedFileAdapters: - Consolidate STATS_SCHEMA and TABLE_SCHEMA into one TABLE_SCHEMA (optional int id + optional float score) that backs both the read-side stat fixtures and the write-side wrapper factories. - Replace the local EMPTY_PARTITION_DATA constant with the shared PartitionData.EMPTY singleton. - Drop the V4_AND_ABOVE parameterization from testDataFileWrapperAdded; every other test in this file uses FORMAT_VERSION_V4 directly. - Move the assertNullTrackingFields / specsById / partition / dummyTrackedFile helpers to the end of the class so all private-static helpers live together after the tests. - Fill assertWriteDataFields coverage (sortOrderId, keyMetadata, manifestInfo/deletionVector/equalityIds nulls). - Fill testDataFileDoubleWrapRoundTrip DataFile-API coverage (content, partition via Comparators.forType, sortOrderId, splitOffsets, keyMetadata, firstRowId, nanValueCounts); document the two round-trip losses (columnSizes -> null, empty nan map -> null). - Reorder v4WriteManifestFile's last two params to match the trailing positions of the v4 GenericManifestFile constructor; add the missing status assertion in testManifestReferenceWrapperForV4Manifest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Added earlier in this PR for a parameterized test that was later converted to a plain @test; the constant now has no callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…avadoc The "reusable wrapper" phrasing in the summary sentence already conveys the one-per-writer usage; the trailing sentence just restates it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The class summary "Reusable view over a legacy ContentFile's stat maps" already conveys the reuse contract; drop the follow-up sentence that restated it and described the wrap() mechanism / perf rationale. Keep the lazy-decode note and the copy-unsupported guidance since those are load- bearing for callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lazy-decode note and the copy-unsupported guidance describe internals of a package-private class. The copy() method itself throws with the "materialize via a writer instead" guidance, so callers who try it get the same message at runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
site/docs/contribute.md asks newly added test methods to omit the `test` prefix. The class already omits `public` on the class and its methods, so this brings the remaining half of that convention in line. testType becomes typeMatchesStatsReadSchema rather than a bare type(): the mechanical strip would leave a name that describes no behavior and reads confusingly next to the stats.type() call it asserts on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing PR 16936 onto main picked up two incompatible upstream changes: - TrackedFile.schemaWithContentStats(StructType, StructType) was renamed to TrackedFile.schema(...) and now returns Schema instead of Types.StructType, so TRACKED_FILE_FIELD_COUNT goes through asStruct(). - The TrackedFileStruct constructor moved the partition parameter from position 6 to position 9, after recordCount/fileSizeInBytes/specId. The dummyTrackedFile test helper is moved to the end of the file by this PR, which kept the pre-reorder argument order past the rebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1922cb0 to
4852d17Compare| // Presents a TrackedFile as its persisted StructLike, shared by the reusable write-direction | ||
| // wrappers. | ||
| private static Object getByPos(TrackedFile file, int pos) { |
There was a problem hiding this comment.
The getByPos() here mirrors TrackedFileStruct.getByPos(). Since both track TrackedFile.schema() field order, is there a risk they drift when a field is added?
I don't have great solutions to avoid this duplication though. We probably don't want TrackedFile.getByPos to call into this method with a self pointer since we want the structs to be self contained without any dependencies on adapter.
There was a problem hiding this comment.
Worth fixing. One clarification on the failure mode: adding a field is the loud case — size() derives from TrackedFile.schema() on both sides, so a new ordinal falls through to default -> throw. Reordering is the silent one: sizes still match, every ordinal resolves, and values land in the wrong columns.
The mapping shouldn't live on TrackedFile — positional access is a StructLike concern the interface doesn't model. TrackedFileStruct is the better home, since it owns BASE_TYPE, which defines the ordinal domain. Plan is a package-private static Object getByPos(TrackedFile file, int pos) there, called from both. The dependency then runs adapter -> struct, so the structs keep no dependency on the adapter.
Close to mechanical, since the copy here is already static getByPos(TrackedFile, int) invoked as getByPos(this, pos), and the struct's accessors for ordinals 0-12 are plain field returns (partition() returns partitionData, exactly what case 8 reads), with 13-15 already delegating to accessors.
Consolidating also settles the coverage question without new tests: TestTrackedFileStruct.getByPosition already resolves all 16 positions by field name from the schema, so the single remaining switch stays pinned to the schema order. Worth noting internalSet and the hand-written BASE_TYPE still restate that order separately, so this removes one duplicate encoding rather than all of them — though both then sit next to the consolidated getter.
TrackedFileAdapters and TrackedFileStruct each carried a switch mapping positions to TrackedFile fields, so the two could drift as the schema changes. Adding a field is the loud case, since size() derives from TrackedFile.schema() on both sides and a new ordinal falls through to the default branch. Reordering is the silent one: sizes still match and every ordinal resolves, so values would land in the wrong columns. Keep the mapping in TrackedFileStruct, which owns BASE_TYPE and therefore defines the ordinal domain, as a package-private static taking a TrackedFile. Positional access is a StructLike concern that the TrackedFile interface does not model, so the interface is not the right home. The dependency now runs adapter -> struct, leaving the structs free of any adapter dependency. TestTrackedFileStruct.getByPosition already resolves all 16 positions by field name from the schema, so the single remaining switch stays pinned to schema order without new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| @Override | ||
| public TrackedFile copy() { | ||
| throw new UnsupportedOperationException( | ||
| "Reusable content-file wrapper does not support copy(); materialize via a writer instead"); |
There was a problem hiding this comment.
Trying to understand better here: does this mean the writer needs to materialize the adapter before writing the current tracked file, so the adapter can be reused by the next writer? This means the writer will need to use the adapter synchronously, correct?
| int PARTITION_SUMMARIES_ELEMENT_ID = 508; | ||
| /** Format version for pre-v4 manifest files. */ | ||
| int LEGACY_FORMAT_VERSION = 0; |
There was a problem hiding this comment.
Does this need to be public? Another location would probably be better to avoid polluting the public API.
| * manifests. | ||
| */ | ||
| default Integer replacedFilesCount() { | ||
| return null; |
There was a problem hiding this comment.
Isn't the pre-v4 value 0 because that status did not exist? That seems like the best thing to return to me. Should this also be required since it is always known for v4 manifests?
| } | ||
| /** Returns the number of entries in the manifest file, or null for pre-v4 manifests. */ | ||
| default Long recordCount() { |
There was a problem hiding this comment.
Why is this needed? We should have all of the more specific counts.
| } | ||
| /** v4+ constructor variant that accepts recordCount and formatVersion. */ | ||
| GenericManifestFile( |
There was a problem hiding this comment.
I don't think that this should be used for v4. Manifests are now stored as TrackedFile. Why would we update the v3 class used to read from manifest lists?
| private byte[] keyMetadata = null; | ||
| private Long firstRowId = null; | ||
| private Long recordCount = null; | ||
| private int formatVersion = LEGACY_FORMAT_VERSION; |
There was a problem hiding this comment.
Why update this class when these values are correctly provided by the interface?
| static final int MIN_FORMAT_VERSION_ROW_LINEAGE = 3; | ||
| static final int MIN_FORMAT_VERSION_PARQUET_MANIFESTS = 4; | ||
| static final int MIN_FORMAT_VERSION_OPTIONAL_LOCATION = 4; | ||
| static final int MIN_FORMAT_VERSION_ADAPTIVE_MANIFEST_TREE = 4; |
There was a problem hiding this comment.
Can we combine this with the min version for Parquet manifests? I don't think that we need multiple constants for different things added in v4.
| * @param partitionType target partition struct type; use one spec's partition type for a | ||
| * single-spec manifest, or the union across live specs for a multi-spec manifest | ||
| */ | ||
| static DataTrackedFile forDataFile( |
There was a problem hiding this comment.
It looks like this has a different API than the adapters from TrackedFile to ManifestFile and DataFile. Those create a wrapper on each call, while this creates a wrapper that can be reused. I like the reuse, but we should be consistent.
Another issue is that we need to check whether a file being wrapped is already wrapped. For example, if passed a TrackedDataFile, I think this class should simply unwrap it and return the original.
| /** | ||
| * Returns a reusable wrapper that presents a {@link DataFile} as a {@link TrackedFile} row. | ||
| * | ||
| * @param formatVersion the target table's format version (must be 4+) |
There was a problem hiding this comment.
I don't think this should be passed. We can always add it later when needed. It also conflicts with how we typically handle writes, which is to have a version-specific wrapper and keep objects in memory using the latest version's expectations.
| * | ||
| * @param formatVersion the target table's format version (must be 4+) | ||
| * @param tableSchema table schema used to build {@link ContentStats} from the file's stats | ||
| * @param metricsConfig metrics config used to prune the content stats schema |
There was a problem hiding this comment.
If a schema is needed, I think that this should use the current table manifest schema instead of passing in parts. Do we actually need a schema though? It seems like we want to adapt to any content stats schema in the write wrapper, rather than passing one here.
| * @param tableSchema table schema used to build {@link ContentStats} from the file's stats | ||
| * @param metricsConfig metrics config used to prune the content stats schema | ||
| * @param partitionType target partition struct type; use one spec's partition type for a | ||
| * single-spec manifest, or the union across live specs for a multi-spec manifest |
There was a problem hiding this comment.
Similar to my comment above, I would prefer to handle schema in the wrappers.
What
Adds v4 write-direction
TrackedFileadapters: reusable forwarding wrappers that present a legacy v2/v3ContentFile(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, withcopy()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).
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.