This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new c64ca86c6e [format][python] State every block index invariant in the
row format spec (#10069)
c64ca86c6e is described below
commit c64ca86c6ebcb62c22812bd9a4aba829afe591e3
Author: jackylee <[email protected]>
AuthorDate: Tue Sep 22 10:43:07 2026 +0800
[format][python] State every block index invariant in the row format spec
(#10069)
---
docs/docs/concepts/spec/rowformat.md | 16 +++++++++++++++-
.../java/org/apache/paimon/format/row/RowBlockIndex.java | 10 ++++++++++
.../paimon/format/row/RowFileIndexConsistencyTest.java | 10 ++++++++++
paimon-python/pypaimon/read/reader/format_row_reader.py | 13 ++++++++++---
.../pypaimon/tests/test_format_row_reader_writer.py | 12 ++++++++++++
5 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/docs/docs/concepts/spec/rowformat.md
b/docs/docs/concepts/spec/rowformat.md
index c289432566..698e5deee2 100644
--- a/docs/docs/concepts/spec/rowformat.md
+++ b/docs/docs/concepts/spec/rowformat.md
@@ -159,6 +159,20 @@ The arrays are:
- **blockUncompressedSizes**: Uncompressed size of each block (needed to
allocate decompression buffer)
- **blockRowStarts**: Cumulative row count at the start of each block (for
binary search)
+A well-formed index satisfies all of the following, and a reader rejects a
file that does not:
+
+- The three arrays have the same length, and that length equals the footer's
`blockCount`.
+- `blockCompressedSizes[]` sums to exactly `indexOffset`. Blocks are written
contiguously from
+ position 0 and the index follows the last one, so any other sum means the
two disagree about
+ where the blocks end.
+- No size is negative. The sum alone does not imply this: two sizes can cancel.
+- `blockRowStarts[0]` is 0, and each later start is strictly greater than the
one before it. A
+ reader turns consecutive starts into a block's row range, so a first start
past 0 leaves the rows
+ before it unreachable and a repeated start gives a block an empty range.
+- `blockRowStarts[last]` is less than the footer's `totalRowCount`, so the
last block holds at
+ least one row.
+- An empty index has `blockCount` 0 and `totalRowCount` 0.
+
## Footer
The footer occupies the last 32 bytes of the file. Offsets below are relative
to the start of
@@ -182,7 +196,7 @@ To read a row by its zero-based row number within the file:
1. **Read Footer**: Seek to file end - 32 bytes, read the 32-byte footer.
Validate magic number.
2. **Read Block Index**: Seek to `indexOffset`, read `indexLength` bytes,
decode the three arrays. Compute block offsets by prefix sum of
`blockCompressedSizes[]`.
-3. **Check Consistency**: The three arrays must have the same length, that
length must equal `blockCount`, and `blockCompressedSizes[]` must sum to
`indexOffset`, because the blocks are written contiguously from position 0 and
the index follows the last one. A reader that bounds its block loop by one of
the two — the footer's `blockCount` or the index array length — must reject a
file where they disagree rather than silently reading fewer blocks.
+3. **Check Consistency**: Verify the block index against the footer, as
described under Block Index. A reader that bounds its block loop by one of the
two — the footer's `blockCount` or the index array length — must reject a file
where they disagree rather than silently reading fewer blocks.
4. **Select Block**: Find block `b` where `blockRowStarts[b] <= rowNum <
blockEnd`. For the last block, `blockEnd` is `totalRowCount`; otherwise it is
`blockRowStarts[b + 1]`.
5. **Read Block**: Seek to `blockOffset(b)`, read `blockCompressedSizes[b]`
bytes.
6. **Decompress**: ZSTD decompress into a buffer of size
`blockUncompressedSizes[b]`.
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/row/RowBlockIndex.java
b/paimon-format/src/main/java/org/apache/paimon/format/row/RowBlockIndex.java
index 76c5b00484..4e24a37d0d 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/row/RowBlockIndex.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/row/RowBlockIndex.java
@@ -85,6 +85,16 @@ class RowBlockIndex {
blocksEnd, footer.indexOffset));
}
+ for (int i = 0; i < blockCount(); i++) {
+ // nothing in the footer bounds this one, and it sizes the
decompression buffer
+ if (blockUncompressedSizes[i] < 0) {
+ throw new IOException(
+ String.format(
+ "Row file block %d has a negative uncompressed
size %d.",
+ i, blockUncompressedSizes[i]));
+ }
+ }
+
if (blockCount() == 0) {
if (footer.totalRowCount != 0) {
throw new IOException(
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/row/RowFileIndexConsistencyTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/row/RowFileIndexConsistencyTest.java
index 815394f6b7..ffc54f0cdc 100644
---
a/paimon-format/src/test/java/org/apache/paimon/format/row/RowFileIndexConsistencyTest.java
+++
b/paimon-format/src/test/java/org/apache/paimon/format/row/RowFileIndexConsistencyTest.java
@@ -128,6 +128,16 @@ class RowFileIndexConsistencyTest {
.hasMessageContaining("block 1 has a negative compressed size
-100");
}
+ @Test
+ void testNegativeUncompressedSizeIsRejected() {
+ // the footer bounds the compressed sizes through the sum, but nothing
bounds these
+ RowBlockIndex index =
+ new RowBlockIndex(new long[] {10, 20}, new long[] {100, -1},
new long[] {0, 5});
+ assertThatThrownBy(() -> index.validate(new RowFileFooter(9, 2, 30,
7)))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("block 1 has a negative uncompressed
size -1");
+ }
+
private static void validateRowStarts(long[] rowStarts, long
totalRowCount) throws IOException {
long[] sizes = new long[rowStarts.length];
Arrays.fill(sizes, 10);
diff --git a/paimon-python/pypaimon/read/reader/format_row_reader.py
b/paimon-python/pypaimon/read/reader/format_row_reader.py
index cca6369e3c..56ddd15fc0 100644
--- a/paimon-python/pypaimon/read/reader/format_row_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_row_reader.py
@@ -243,9 +243,11 @@ class FormatRowReader(RecordBatchReader):
"""Cross-check the index against the footer, as the row format spec
requires.
Blocks are written contiguously from position 0 and the index follows
the last one, so the
- compressed sizes sum to exactly index_offset. Row starts become the
row range of a block,
- and a block whose range a selection does not intersect is skipped, so
a first start past 0,
- a repeated start or a last start at the row count would drop rows
silently.
+ compressed sizes sum to exactly index_offset. Row starts have to start
at 0 and increase
+ strictly because _compute_blocks_for_indices bisects them: a first
start past 0 makes
+ bisect_right return 0 for the rows before it, so block_idx is -1,
row_starts[-1] is the last
+ start, and the local row goes negative — this reader would decode a
row from the wrong place
+ in the block rather than skip it.
"""
counts = (len(self._block_compressed_sizes),
len(self._block_uncompressed_sizes),
len(self._block_row_starts))
@@ -266,6 +268,11 @@ class FormatRowReader(RecordBatchReader):
raise IOError(f"Row file blocks end at {blocks_end}, but the
footer puts the "
f"block index at {self._index_offset}")
+ for i, size in enumerate(self._block_uncompressed_sizes):
+ # nothing in the footer bounds this one, and it sizes the
decompression buffer
+ if size < 0:
+ raise IOError(f"Row file block {i} has a negative uncompressed
size {size}")
+
if self._block_count == 0:
if self._total_row_count != 0:
raise IOError(f"Row file block index is empty, but the footer
declares "
diff --git a/paimon-python/pypaimon/tests/test_format_row_reader_writer.py
b/paimon-python/pypaimon/tests/test_format_row_reader_writer.py
index b14f7a2b53..2023dd3fdd 100644
--- a/paimon-python/pypaimon/tests/test_format_row_reader_writer.py
+++ b/paimon-python/pypaimon/tests/test_format_row_reader_writer.py
@@ -603,6 +603,18 @@ class TestRowFileIndexConsistency:
with pytest.raises(IOError, match="row count 5 does not reach"):
reader._validate_block_index()
+ # one block and no declared rows: the block would hold nothing
+ reader = self._reader_with(compressed=[10], row_starts=[0],
total_rows=0)
+ with pytest.raises(IOError, match="row count 0 does not reach"):
+ reader._validate_block_index()
+
+ def test_negative_uncompressed_size_is_rejected(self):
+ # the footer bounds the compressed sizes through the sum, but nothing
bounds these
+ reader = self._reader_with(compressed=[10, 20], row_starts=[0, 5],
total_rows=30)
+ reader._block_uncompressed_sizes = [100, -1]
+ with pytest.raises(IOError, match="block 1 has a negative uncompressed
size -1"):
+ reader._validate_block_index()
+
def test_compressed_sizes_must_sum_to_the_index_offset(self):
# two blocks of 10 and 20 compressed bytes occupy [0, 30), so the
index starts at 30
reader = self._reader_with(compressed=[10, 20], row_starts=[0, 5],
total_rows=30,