Repository navigation
Core: Compare struct upsert columns field by field in PyArrow - #3862
GaspardMerten wants to merge 1 commit into
Conversation
1d2a776 to
fe4bb00
Compare
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR optimizes upsert row-difference detection for wide tables by moving comparisons into PyArrow Compute (with a structured fallback for complex/nested types), reducing Python scalar materialization and improving performance.
Changes:
- Add column-at-a-time change masking in
get_rows_to_update, including nested struct comparison and a sliced Python fallback for non-comparable types. - Add explicit source/target column-name validation with a new error message.
- Add a focused test suite covering null semantics, nested structs, list/map behavior, casts, and comparison strategy.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
pyiceberg/table/upsert_util.py |
Reworks matched-row comparison to use PyArrow Compute masks per column, adds nested-struct handling and a sliced Python fallback, and introduces explicit column-name validation. |
tests/table/test_upsert.py |
Adds tests verifying new comparison semantics and ensuring comparisons happen per column (and that structs avoid Python comparisons). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
fe4bb00 to
b57ac73
Compare
hedger9487
left a comment
There was a problem hiding this comment.
Great optimization on pushing wide-table comparisons to PyArrow Compute (~130x speedup is awesome)!
A couple of quick thoughts while looking through the implementation:
-
Positional field access in structs: In
_get_changed_struct_mask, using integerindexinpc.struct_field(..., index)assumes identical struct field order between source and target. If field orders differ (e.g.{"a": int, "b": str}vs{"b": str, "a": int}), it compares mismatched types and crashes withArrowNotImplementedError. Would matching subfields byfield.namebe safer here? -
A small thought on structure: I was wondering if it might also be cleaner to check
if pa.types.is_struct(...)up front rather than relying onArrowNotImplementedErrorfor branching? That way we could avoid exception-driven control flow and maybe even unify both helpers into a single recursive function. What do you think?
790d533 to
10a0461
Compare
|
Hey @hedger9487, Thanks for the review. For 1, the function was called only when the types matched; it wouldn't have caused a bug, but if it were used elsewhere, it could, so I followed your advice. On 2, my opinion is to keep the code as is, or, if needed, to refactor the except branch into a separate function, as it really is a fallback for the limitations of |
|
Hey @GaspardMerten, Makes sense! Glad the My thought was just that intentionally hitting exceptions during normal runs felt a bit counter-intuitive, but using Definitely no need to refactor on my account — keeping it as-is makes total sense. Thanks for explaining and for the awesome optimization! |
|
@rambleraptor, would you have time to review this? Thanks a lot, currently had to patch a pipeline in an unfriendly way.. |
|
This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that's incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions. |
|
This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that's incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions. |
rambleraptor
left a comment
There was a problem hiding this comment.
I've got some thoughts, but this broadly looks good. Thanks for the contribution!
|
This pull request has been closed due to lack of activity. This is not a judgement on the merit of the PR in any way. It is just a way of keeping the PR queue manageable. If you think that is incorrect, or the pull request requires review, you can revive the PR at any time. |
10a0461 to
4db96ad
Compare
4db96ad to
ed68cc3
Compare
Since apache#4334 the upsert comparison runs column by column in PyArrow, but struct columns still fall back to Python, a dict per row. Structs are now compared field by field, recursing into nested structs. A scan returns the strings of a struct as large_string while a dataframe holds string, so the two struct types differ in every real upsert. When PyArrow cannot compare two columns of different types, the source column is cast to the target type first, which lets those structs take the PyArrow path. Upserting 30 struct columns over 200k rows goes from 5.5s to 2.9s.
ed68cc3 to
197beb5
Compare
|
Hey @Fokko, @rambleraptor! First of all, thank you for reopening the PR and for your feedback. Since #4334 landed in the meantime, I rebased on main and narrowed the PR to the two things that were left:
This also resolves both review comments: the check on the source and target columns that raised a |
Rationale for this change
Follow-up to #4334, which made
get_rows_to_updatecompare the matched rows column by column. Columns PyArrow cannot compare still fall back toto_pylist(), and struct columns always do: PyArrow has no comparison kernel for structs, and a scan returns the strings inside a struct aslarge_stringwhile the dataframe holdsstring, so the two struct types never match.This PR:
Lists and maps still go through Python.
Before / after
table.upsert()of a table with 30struct<a: long, b: string>columns, where the given share of rows has one changed struct column.SqlCatalog, local files, best of 3 runs (2 for 1M). "Comparison" is the time spent inget_rows_to_update. "main" includes #4334.Tables without struct columns are unchanged, as expected after #4334 (200
longandstringcolumns, 100k rows: ≈1.45s on both).With this change, the comparison is no longer the main cost of an upsert. On the 1M-row struct table, most of the remaining time goes into building the key filters (
create_match_filterand binding a 1M-valueIn), which is what the open #3420 and #4027 address. For context, upserts of the same 1M-row table on top of this PR, with nothing changed, and with every struct column changed in 10% of rows plus 100k new rows (one integer key, contiguous values, local files):Combining #3420 and #4027 needed a one-line conflict resolution.
The cast also changes the result for columns whose types differ but hold the same values. Calling
get_rows_to_updatewith a naive timestamp column against a UTC one holding the same instants flags every row as changed onmain, since the Python fallback compares a naive and an awaredatetime. With this PR none are flagged:table.upsert()itself rejects that schema before getting there, so this only affects direct callers.Are these changes tested?
Yes, new tests in
tests/table/test_upsert.py:stringagainst a struct oflarge_string, as a scan returns itThe unit suite passes, as does
prek runon the changed files.Are there any user-facing changes?
Only speed, for tables with struct columns.