wzx140 opened a new issue, #10671:
URL: https://github.com/apache/paimon/issues/10671
# Paimon Partial-Update Validity Column Design
## Motivation
Ordinary partial update on primary-key tables uses NULL to mean "do not
update" and cannot distinguish an explicitly supplied NULL from a NULL inserted
to fill a missing column. This proposal explicitly declares an ordinary column
`_VALIDITY_FIELDS` in the table schema, using a bitmap to record the column
indexes participating in the update for each row. This column is visible to
users and included in `SELECT *`.
Assume an existing row `(1, A, B, C)`:
```sql
-- Update only b; the result is (1, A, B2, C).
INSERT INTO t (id, b) VALUES (1, 'B2');
-- Explicitly set a and c to NULL; the result is (1, NULL, B2, NULL).
INSERT INTO t (id, a, b, c) VALUES (1, NULL, 'B2', NULL);
```
The corresponding incremental records have the following storage layout. The
value column positions are `_VALIDITY_FIELDS=0, id=1, a=2, b=3, c=4`; the
bitmap does not mark column 0:
```text
[_KEY_id, _SEQUENCE_NUMBER, _VALUE_KIND, _VALIDITY_FIELDS, id, a, b, c]
Update only b:
[1, 100, INSERT, serialize({1, 3}), 1, NULL, B2, NULL]
Update a, b, and c, explicitly setting a and c to NULL:
[1, 101, INSERT, serialize({1, 2, 3, 4}), 1, NULL, B2, NULL]
```
## Public Interfaces
Add `merge-engine=partial-update-validity` (tentative name). Explicitly
declare `_VALIDITY_FIELDS VARBINARY` in the table.
```sql
CREATE TABLE t (
_VALIDITY_FIELDS VARBINARY,
id INT,
a STRING,
b STRING,
c STRING,
PRIMARY KEY (id) NOT ENFORCED
) WITH ('merge-engine' = 'partial-update-validity');
```
## Proposed Changes
### Core
#### Row and Storage Layout
`_VALIDITY_FIELDS` is an ordinary column in the complete value RowType. The
merge function reads its binary value from `kv.value()` by column position and
decodes it into an in-memory `RoaringBitmap32`.
```text
value schema: [_VALIDITY_FIELDS, id, a, b]
File layout:
[_KEY_<pk fields>, _SEQUENCE_NUMBER, _VALUE_KIND, <value fields>]
Example:
[1, 100, INSERT, serialize({1, 2}), 1, NULL, NULL]
```
The column uses ordinary `VARBINARY` encoding and is stored as a separate
Parquet `BYTE_ARRAY` column. It is copied, spilled, and serialized together
with the value.
Bitmap indexes correspond to target column positions in the complete value
schema; `_VALIDITY_FIELDS` does not mark itself. The merge function handles
this column separately and outputs the merged bitmap rather than applying
ordinary per-column overwrite logic to it.
#### Projection and Schema Evolution
Indexes in `validityFields` correspond to the complete business value schema
used when the file was written. Before the reader outputs an `InternalRow`, the
value and bitmap must both be mapped to the actual read schema.
`KeyValueFileReaderFactory` / `FormatReaderMapping` construct a position
mapping using stable field IDs from the file's business schema and the actual
read schema. Decode the bitmap from the ordinary column, transform it using the
mapping, and encode the result back into the corresponding value column:
```java
// mapping[newPos] = oldPos; -1 if the old column does not exist.
projectValidity(oldValid, mapping) {
newValid = new RoaringBitmap32();
for (newPos = 0; newPos < mapping.length; newPos++) {
oldPos = mapping[newPos];
if (oldPos >= 0 && oldValid.contains(oldPos)) {
newValid.add(newPos);
}
}
return newValid;
}
```
#### Read and Merge
The new merge engine is implemented by `ValidityPartialUpdateMergeFunction`,
which extends `PartialUpdateMergeFunction` to merge validity bits and preserve
the result. Merge each column from older to newer records in sequence order:
| Previous record valid | Incoming record valid | Result value | Result
record valid |
| --- | --- | --- | --- |
| 0 | 0 | Incoming value | 0 |
| 0 | 1 | Incoming value | 1 |
| 1 | 0 | Previous value | 1 |
| 1 | 1 | Incoming value | 1 |
```java
class ValidityPartialUpdateMergeFunction extends PartialUpdateMergeFunction {
// Merge records for the same primary key from older to newer sequence
numbers;
// delete and advanced aggregation branches are omitted.
reset() {
result = allNullRow();
resultValid = new RoaringBitmap32();
}
add(kv) {
row = kv.value();
valid = decodeOrNull(row.getBinary(validityColumnPos));
for (i = 0; i < row.getFieldCount(); i++) {
if (i == validityColumnPos) continue;
incomingValid = valid == null ? !row.isNullAt(i) :
valid.contains(i);
if (incomingValid || !resultValid.contains(i)) {
result[i] = valueAt(row, i); // Preserve values carried by
invalid columns; NULL can also be assigned.
}
if (incomingValid) {
resultValid.add(i);
}
}
}
getResult() {
result[validityColumnPos] = resultValid.serialize();
return KeyValue(..., result);
}
}
```
#### DEFAULT
Current behavior: DEFAULT converts NULL to the column default only during
writes. Reads and merges do not fill in defaults.
For example, the following SQL illustrates a table using `partial-update`,
where a has default D and omitted columns are filled with NULL by the upper
layer:
```sql
-- Schema: (id INT, a STRING DEFAULT 'D', b STRING), with id as the primary
key.
INSERT INTO t (id, a, b) VALUES (1, 'A', 'B');
SELECT id, a, b FROM t;
-- result: (1, 'A', 'B')
-- Only b is intended to change, but the filled-in a=NULL becomes D during
writing.
INSERT INTO t (id, b) VALUES (1, 'B2');
SELECT id, a, b FROM t;
-- result: (1, 'D', 'B2'); the previous value A is overwritten.
```
With `_VALIDITY_FIELDS`, NULL values are converted to DEFAULT according to
column configuration during writes for both `valid=0` and `valid=1`; this
conversion does not change the validity bits. During merging, valid values take
precedence over invalid values; when both the previous and incoming records are
invalid, the incoming value is used.
Using the new merge implementation `ValidityPartialUpdateMergeFunction`,
execute the same SQL on an empty table:
```sql
-- Schema: (id INT, a STRING DEFAULT 'D', b STRING), with id as the primary
key.
INSERT INTO t (id, a, b) VALUES (1, 'A', 'B');
SELECT id, a, b FROM t;
-- result: (1, 'A', 'B'); a=(A, valid=1).
-- Omit a: the filled-in NULL still becomes D, but a has valid=0.
INSERT INTO t (id, b) VALUES (1, 'B2');
SELECT id, a, b FROM t;
-- result: (1, 'A', 'B2'); the previous a is valid, so A is preserved rather
than overwritten by D.
```
### Lookup Changelog and Deletion Vectors
DV and changelog are not supported for now.
### Spark SQL
Add an analysis rule for INSERT / UPDATE / MERGE using the new merge engine.
Before filling missing columns and aligning assignments, preserve the actually
supplied columns, map them to column indexes in the complete table schema, and
automatically generate and populate the ordinary column `_VALIDITY_FIELDS`.
This column is the first column of the output row, and the bitmap does not mark
itself. MERGE generates the bitmap according to the action actually matched.
### PyPaimon
In `TableWrite.with_write_type(write_cols)`, map the business columns
supplied by the caller to column indexes in the complete table schema, generate
the validity bitmap, and automatically add `_VALIDITY_FIELDS` as the first
column in the internal write column list. Subsequent writes follow this plan to
automatically populate the encoded Arrow binary column, fill missing business
columns with NULL, and pass the complete row to Python's write and merge paths.
Callers do not need to manually add this column to write_cols or the input data.
## Compatibility and Migration
### Old Versions Reading New Files
Old Paimon clients do not recognize the new merge-engine value and fail when
opening the table with its configuration. A generic Parquet reader can still
read `_VALIDITY_FIELDS` as an ordinary binary column, but does not perform
validity-based merging.
### New Versions Reading Old Files
After the ordinary column is added to an existing table through schema
evolution, the reader fills the missing `_VALIDITY_FIELDS` in old files with
NULL. The merge function then follows the old semantics: non-NULL columns
participate in updates, while NULL columns do not. When reading old and new
files together, column positions are still mapped according to each file's
schema.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]