feat: coalesce physically-stored _last_updated_sequence_number - #2985
feat: coalesce physically-stored _last_updated_sequence_number#2985anoopj wants to merge 4 commits into
Conversation
The prior change rejected files that physically carry a per-row _last_updated_sequence_number column. Read such columns instead and coalesce per the spec. i.e. use the per-row value where non-null, fall back to the file's data sequence number only where null. _row_id, whose fallback is positional, is a later PR.
laskoviymishka
left a comment
There was a problem hiding this comment.
Good stuff, unifying the constant, null, and coalesce paths on the run-end-encoded type so batches concat cleanly is the right call, and the three-file concat test is exactly the guard I'd want on it. I'd hold this before merging though, since the _row_id follow-up is going to build directly on this coalesce shape and a couple of the gating decisions are worth settling first.
The one I care most about is the coalesce filter: when first_row_id is None, a file physically carrying non-null per-row _last_updated_sequence_number values comes back all-null, because we drop the leaf before projecting it. Java does the same thing, so it's not an interop regression — but it's silent data loss for a spec-legal file, and nothing in the code marks it as a deliberate Java-parity choice. I'd either pass non-null per-row values through, or keep the behavior and say explicitly that it matches Java plus file a follow-up. Right now the divergence is invisible.
Things I'd like to settle in this PR before the follow-ups build on it:
- Decide and document the
first_row_id = None/data_sequence_number = Nonecoalesce behavior — pass non-null physical values through, or note it as intentional Java parity. - Hoist the
last_updated_seq_present_by_name_onlyreject before thematchso it fires in every state, not just(Some, Some), and add a test for the(Some, None)+ physical-column case. - Make the
Coalescevariant's Long-only constraint explicit (rename or validate at registration), and switch the source-type mismatch toDataInvalidwithDisplay. - Add an all-null coalesce test to lock the
is_not_nullmask polarity, and confirm the_postest's requiredidfield actually resolves against the written file.
The doc/invariant touch-ups are genuinely optional — flagged inline as nits. Once the gating and the Long-pinning are settled, happy to take another pass and approve.
| // `build_field_id_map` is all-or-nothing (`None` if any column lacks an id), so a | ||
| // file mixing id-bearing and id-less columns is rejected below rather than | ||
| // coalesced -- safe, and consistent with the rest of the reader. | ||
| let phys_last_updated_seq_leaf = if project_last_updated_seq { |
There was a problem hiding this comment.
build_field_id_map is all-or-nothing, so a single id-less sibling column turns a file that does carry _last_updated_sequence_number by its reserved id into a hard FeatureUnsupported — the name-only branch fires and we reject a file we could actually coalesce.
The comment frames this as safe, and it's not wrong, but it's a behavior choice (reject a readable file), not a pure safety guard. Would a targeted scan of the parquet leaves for just RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER be worth it, or do we accept the all-or-nothing and just make the comment say plainly that a mixed-id file is rejected? wdyt?
There was a problem hiding this comment.
Went with accept + simpler comment. Reworded the comment to state the mixed-id file is rejected as a behavior choice.
| // is row-lineage-bearing (first_row_id set) with a data sequence number to fall | ||
| // back to. When first_row_id is null the column is nulled, so we must not read it. | ||
| let coalesce_last_updated_seq_leaf = phys_last_updated_seq_leaf | ||
| .filter(|_| task.first_row_id.is_some() && task.data_sequence_number.is_some()); |
There was a problem hiding this comment.
This .filter(...) couples two separate decisions: whether we can compute a fallback (needs data_sequence_number) and whether we should read the physical column at all. When first_row_id is None we drop the leaf entirely, so a file that physically carries non-null per-row values comes back all-null:
file column: [Some(5), None, Some(8)] (first_row_id = None)
reader output: [null, null, null] (leaf not projected -> (None,_) arm)
Java does the same (ValueReaders.lastUpdated nulls when the base row id is null), so this isn't an interop regression — but it's silent data loss for a file the spec doesn't forbid. The data_sequence_number = None half is more anomalous, but the same shape: non-null per-row values vanish.
I'd either project the column and pass non-null values through (leaving null rows null when there's no fallback), or keep the current behavior but say in the comment that it matches Java and file a follow-up tracking the divergence. Right now the divergence is invisible.
There was a problem hiding this comment.
Kept the behavior, documented it as a deliberate Java-parity choice. Passing values through would diverge from Java because a file with no row lineage has no meaningful per-row sequence numbers, so I'd rather keep them consistent.
One comment about the data_sequence_number=None half: that one isn't silent. It lands in the (Some, None) arm which returns DataInvalid. Added a test for it.
| RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER, | ||
| datum, | ||
| ) | ||
| } else if last_updated_seq_present_by_name_only { |
There was a problem hiding this comment.
The last_updated_seq_present_by_name_only reject only fires inside the (Some, Some) arm. For (Some, None) or (None, _) with a name-only column present, the flag is computed but never evaluated, so that file gets silently nulled instead of the loud FeatureUnsupported.
I'd hoist this check to just before the match so it fires regardless of the first_row_id / data_sequence_number state. The (Some, None) arm also isn't exercised — could we add a test for first_row_id = Some, data_sequence_number = None with a physical column, so the intended behavior there is pinned down?
There was a problem hiding this comment.
Added the (Some, None) + physical-column test which asserts DataInvalid. We should not hoist because the transformer keys the source column by field id and can't thread an id-less column. But that only matters when we actually read the column, which only the (Some, Some) arm does. In (None, _) we synthesize an all-null column without touching the physical data, so an ordinary v2/null-lineage file that happens to carry a same-named, id-less column is correctly nulled today.
Hoisting the reject would turn that correct null into a hard FeatureUnsupported. So the reject is arm-local by design; I added a comment stating that. If you still want it hoisted I can, but I think arm-local seems correct to me. wdyt?
| Schema::builder() | ||
| .with_schema_id(1) | ||
| .with_fields(vec![ | ||
| NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(), |
There was a problem hiding this comment.
This task schema declares id as required and projects field 1, but write_plain_parquet only writes the physical _last_updated_sequence_number column here — no id column. A required field absent from the file usually surfaces as DataInvalid("Missing required field: id"), yet the test expects success.
Either write_plain_parquet is quietly inserting an id column (in which case the test passes for a reason it doesn't state), or this should be optional / carry an initial default. Could we double-check which, so the test verifies what it claims?
There was a problem hiding this comment.
Double-checked. write_plain_parquet always writes id=[1,2,3] as its first column, so the required id field does resolve against the file. The test is correctly passing.
| target_type: arrow_type.clone(), | ||
| }); | ||
| } | ||
| Some(ColumnConstant::Coalesce(datum)) => { |
There was a problem hiding this comment.
Nit: this arm returns ColumnSource::Coalesce unconditionally, skipping the is_metadata_field || !present_in_file guard the Scalar arm applies. Correct today since _last_updated_sequence_number is always a metadata field — nothing enforces it, though. A one-line comment noting the invariant (or a debug_assert!) would be plenty; not worth more than that.
| child_values, | ||
| } => Self::create_struct_column(fields, child_values, num_rows)?, | ||
|
|
||
| ColumnSource::Coalesce { |
There was a problem hiding this comment.
ColumnSource::Coalesce carries a generic fallback: PrimitiveLiteral and target_type: DataType, but create_coalesce_column silently pins both to Long / Int64. Combined with with_coalesced_metadata_column being infallible, a future caller with a non-Long datum gets no signal at registration and instead hits ErrorKind::Unexpected deep in batch processing.
I'd make the constraint explicit — either rename the variant to something like CoalesceLastUpdatedSeq so no one reuses it for another primitive, or validate the datum type in with_coalesced_metadata_column so it fails at the call site. Renaming is less code and states the intent.
There was a problem hiding this comment.
That's fair. Renamed both variants and the builder. I didn't see a need to generalize at the moment.
| fallback: &PrimitiveLiteral, | ||
| target_type: &DataType, | ||
| ) -> Result<ArrayRef> { | ||
| if source.data_type() != &DataType::Int64 { |
There was a problem hiding this comment.
A file could legitimately carry the reserved field id on an Int32 column — that's a data-validity problem, not an internal invariant violation, so I'd use ErrorKind::DataInvalid here (and {} rather than {:?}, since DataType implements Display).
The PrimitiveLiteral::Long guard just below is genuinely internal wiring, so Unexpected is fine there.
| )); | ||
| }; | ||
| let scalar = Int64Array::new_scalar(*seq); | ||
| let mask = is_not_null(source)?; |
There was a problem hiding this comment.
The coalesce tests all use mixed [Some(5), None, Some(8)], which would still pass if this mask polarity were flipped to is_null. An all-null [None, None, None] case (expecting [fallback, fallback, fallback]) would lock this line down — it's the only input that distinguishes is_not_null from is_null.
An all-non-null case is worth adding too, to confirm the REE cast produces a valid array when nothing falls back.
| let result = transformer.process_record_batch(batch).unwrap(); | ||
|
|
||
| // Per-row value where non-null; the fallback (9) where null. | ||
| let seq_col = cast(result.column(1), &DataType::Int64).unwrap(); |
There was a problem hiding this comment.
Nit: these assertions index by result.column(1), so they'd silently test the wrong column if projection order ever shifts. result.column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER) would be sturdier — same for assert_last_updated_seq_column if it also goes by position.
There was a problem hiding this comment.
Switched 6 tests to a shared helper that looks up column by name.
|
Thanks for the through review! |
Which issue does this PR close?
Refs #2879
What changes are included in this PR?
The prior change rejected files that physically carry a per-row _last_updated_sequence_number column.
Read such columns instead and coalesce per the spec. i.e. use the per-row value where non-null, fall back to the file's data sequence number only where null.
_row_idis a later PR.Are these changes tested?
Added tests.