Repository navigation
perf(update): skip reading the columns an update assigns - #10396
Conversation
An update scanned every column of the rows it rewrites, so the old values of the columns it assigns were read and then discarded. For large values, such as binary or blob columns, that read dominated the update. The scan now leaves out assigned columns that no SET expression reads, and apply_updates lays its results out in dataset order. This removes the placeholder blob v2 assignments used to avoid the same read.
|
Measured against Time (1024 rows, median of 5 runs):
Both versions write the same new values, so the gap is the read of the old ones; the blob v2 case was already mostly avoided by the placeholder this removes. Bytes read (64 rows of 16 KiB, 32 updated, counted by the object store's IO tracker):
Benchmark (place at
|
When an update on a table with blob v2 columns assigns every column from literals, the scan reads no data columns, and the blob rewrite built a zero-column batch that Arrow rejects without a row count. The rewrite now carries the input's row count.
| let scan_columns = self | ||
| .dataset | ||
| .schema() | ||
| .fields | ||
| .iter() | ||
| .map(|field| field.name.clone()) | ||
| .filter(|name| !self.updates.contains_key(name) || read_columns.contains(name.as_str())) | ||
| .collect::<Vec<_>>(); | ||
| let scan_schema = self.dataset.schema().project(&scan_columns)?; | ||
| let planner = Planner::new(Arc::new((&scan_schema).into())); |
There was a problem hiding this comment.
Could we rebuild the scan projection when retrying against a newer dataset? If another writer adds column c after the job is built, the retry uses the new schema but retains the old scan_columns, causing the update to fail with update scan is missing 'c'.
| let columns = self | ||
| .scan_columns | ||
| .iter() | ||
| .map(String::as_str) | ||
| .chain(spill_budget.map(|_| ROW_CREATED_AT_VERSION)) | ||
| .collect::<Vec<_>>(); | ||
| scanner.project(&columns)?; |
There was a problem hiding this comment.
Is that make sense that use project_with_schema() here to preserve literal field names?
The scan columns and SET expressions were planned once, at build, against the dataset the update started from. A retry after a concurrent add column reused that plan against the newer dataset and failed with "update scan is missing" for the added column. Each attempt now plans against its own dataset version. The scan also projects by schema rather than by column name.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
Per-attempt planning resolves the schema-change retry failure, and the blob-only update fix remains intact. No blocking issues remain.
|
@zhangyue19921010 thanks, updated |
|
@claude review |
LuQQiu
left a comment
There was a problem hiding this comment.
See if claude find other small issues.
Looks good.
Guess it also depends on the table shape, if the table has other even heavier columns, then reading out other even heavier columns may become a high cost.
But very heavy column can also go through blob V2.
Not matter what, this PR is an improvement
| .map(|field| match assigned.remove(field.name().as_str()) { | ||
| Some(values) => Ok(values), | ||
| None => batch.column_by_name(field.name()).cloned().ok_or_else(|| { | ||
| DataFusionError::Internal(format!("update scan is missing '{}'", field.name())) |
There was a problem hiding this comment.
🔴 A caller who passes a backtick-quoted column name to set now gets a silent no-op that still reports the rows as updated. On the base branch the update fails. apply_updates keys results by the raw set argument but fills columns by field.name(), so an unmatched result is dropped and the old value is copied through. Fix: store the resolved field.name as the key at update.rs:223 rather than column.as_ref(), so every name-keyed lookup sees the real name. Also return an internal error when assigned still holds entries after the output columns are assembled.
Why this was flagged
UpdateBuilder::set (update.rs:155) looks the column up with dataset.schema().field(column). That goes through parse_field_path, which strips backticks, so set("a", "1") finds field a. update.rs:223 still stores the key as the raw string "a". In UpdatePlan::try_new (update.rs:376), updates.contains_key(&field.name) is false for a, so the old column is scanned. In apply_updates, the new values go into assigned under "a". The output loop at update.rs:795 calls assigned.remove("a"), finds nothing, and copies the scanned old column instead. The rows are rewritten unchanged, and rows_updated reports them as updated. On the base branch, batch.replace_column_by_name("a", ...) failed with "Field a does not exist", so the caller got an error. Blob v2 columns are affected the same way: the blob_columns key is also the quoted string. Nothing checks that assigned is empty after assembly. The Python binding (python/src/dataset.rs:2095) passes user column strings straight to set.
Verification: Trigger: a caller passes a backtick-quoted column name to UpdateBuilder::set, e.g. set("a", "1"). update.rs:223 still stores the raw key. The output loop (update.rs:792-800) calls assigned.remove(field.name()) with "a" and falls back to the old values. The leftover "a" entry is never checked, so no error is raised. On the base branch the update fails.
`set` resolves a backtick-quoted name to its field but stored the assignment under the quoted string, so the output never matched it and the update silently kept the old values. Assignments are now keyed by the field name, and an assignment the output does not consume is an internal error.
An update scanned every column of the rows it rewrites, so the old values of the columns it assigns were read and then discarded. For large values, such as binary or blob columns, that read dominated the update.
The scan now leaves out assigned columns that no SET expression reads, and apply_updates lays its results out in dataset order. This removes the placeholder blob v2 assignments used to avoid the same read.