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 e20de7d2ac [python] Speed up video keyframe index deserialization
(#10794)
e20de7d2ac is described below
commit e20de7d2aca62c81bae1dfd289d74970e52b0575
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 25 10:45:29 2026 +0800
[python] Speed up video keyframe index deserialization (#10794)
---
.../pypaimon/table/row/video_keyframe_index.py | 56 ++++++++++++--
.../pypaimon/tests/video_keyframe_index_test.py | 86 ++++++++++++++++++++++
paimon-python/pyproject.toml | 1 +
3 files changed, 138 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/table/row/video_keyframe_index.py
b/paimon-python/pypaimon/table/row/video_keyframe_index.py
index 9d8f34e158..2c38322706 100644
--- a/paimon-python/pypaimon/table/row/video_keyframe_index.py
+++ b/paimon-python/pypaimon/table/row/video_keyframe_index.py
@@ -234,12 +234,58 @@ class VideoKeyframeIndex:
@classmethod
def deserialize(cls, data, payload_length=None):
+ import numpy as np
+
metadata_count, keyframe_count, entries_offset = cls._read_header(data)
- return cls(
- cls._iter_metadata_ranges(data, metadata_count, payload_length),
- cls._iter_keyframes(
- data, entries_offset, keyframe_count, payload_length),
- )
+ metadata_ranges = []
+ for start, length in cls._iter_metadata_ranges(
+ data, metadata_count, payload_length):
+ if (metadata_ranges
+ and sum(metadata_ranges[-1]) == start):
+ previous_start, previous_length = metadata_ranges[-1]
+ metadata_ranges[-1] = (previous_start, previous_length +
length)
+ else:
+ metadata_ranges.append((start, length))
+
+ # The header caps this buffer at 1.5 MiB. One extra byte detects
+ # excess output without expanding an untrusted compressed stream.
+ expected_size = keyframe_count * cls.ENTRY.size
+ decoder = zlib.decompressobj()
+ try:
+ raw = decoder.decompress(
+ memoryview(data)[entries_offset:], expected_size + 1)
+ except zlib.error as error:
+ raise ValueError(
+ "Invalid video keyframe index payload."
+ ) from error
+ if (len(raw) != expected_size or not decoder.eof
+ or decoder.unused_data or decoder.unconsumed_tail):
+ raise ValueError("Invalid video keyframe index entries.")
+
+ # ENTRY '<qqq': ordinal, PTS and packet position are little-endian
int64.
+ entries = np.frombuffer(raw, dtype='<i8').reshape(keyframe_count, 3)
+ if entries[0, 0] != 0:
+ raise ValueError("Video keyframe index requires an initial
keyframe.")
+ # Compare directly: subtraction can overflow for signed PTS values.
+ if np.any(entries[1:, 0] <= entries[:-1, 0]):
+ raise ValueError("Keyframe ordinals must be strictly increasing.")
+ if np.any(entries[1:, 1] <= entries[:-1, 1]):
+ raise ValueError("Keyframe timestamps must be strictly
increasing.")
+ if (entries[0, 2] < 0
+ or np.any(entries[1:, 2] <= entries[:-1, 2])
+ or (payload_length is not None
+ and int(entries[-1, 2]) >= payload_length)):
+ raise ValueError(
+ "Keyframe packet positions must be within the "
+ "video payload and strictly increasing."
+ )
+
+ # Both sections have already been validated. Preserve Python tuples
+ # without repeating per-entry normalization and validation in __init__.
+ result = cls.__new__(cls)
+ result.metadata_ranges = tuple(metadata_ranges)
+ result.keyframes = tuple(cls.ENTRY.iter_unpack(raw))
+ return result
@classmethod
def _read_header(cls, data):
diff --git a/paimon-python/pypaimon/tests/video_keyframe_index_test.py
b/paimon-python/pypaimon/tests/video_keyframe_index_test.py
index 9488432632..b4738d136d 100644
--- a/paimon-python/pypaimon/tests/video_keyframe_index_test.py
+++ b/paimon-python/pypaimon/tests/video_keyframe_index_test.py
@@ -97,6 +97,92 @@ class VideoKeyframeIndexTest(unittest.TestCase):
VideoKeyframeIndex.deserialize(
header + zlib.compress(struct.pack('<qqq', 0, 1, 2)))
+ def test_deserialize_matches_streaming_validation(self):
+ def encode(entries, count=None, compressed=None):
+ return VideoKeyframeIndex.HEADER.pack(
+ VideoKeyframeIndex.VERSION, VideoKeyframeIndex.MAGIC,
+ 0, len(entries) if count is None else count,
+ ) + (zlib.compress(b''.join(
+ VideoKeyframeIndex.ENTRY.pack(*entry) for entry in entries
+ )) if compressed is None else compressed)
+
+ valid = [(0, -(2 ** 63), 0), (2 ** 63 - 1, 2 ** 63 - 1, 99)]
+ for entries in ([(0, -10, 0)], valid):
+ data = encode(entries)
+ VideoKeyframeIndex.validate(data, 100)
+ restored = VideoKeyframeIndex.deserialize(data, 100)
+ self.assertEqual(tuple(entries), restored.keyframes)
+ self.assertTrue(all(type(value) is int
+ for entry in restored.keyframes
+ for value in entry))
+
+ invalid = [
+ encode([(1, 0, 0)]),
+ encode([(0, 0, -1)]),
+ encode([(0, 0, 0), (0, 1, 1)]),
+ encode([(0, 0, 0), (-1, 1, 1)]),
+ encode([(0, 1, 0), (1, 1, 1)]),
+ encode([(0, 1, 0), (1, -1, 1)]),
+ encode([(0, 0, 1), (1, 1, 1)]),
+ encode([(0, 0, 1), (1, 1, 0)]),
+ encode([(0, 0, 100)]),
+ encode(valid, count=1),
+ encode(valid, count=3),
+ encode(valid)[:-1],
+ encode(valid) + b'trailing',
+ encode(valid) + zlib.compress(b''),
+ encode(valid, compressed=b'not zlib'),
+ encode(valid, compressed=zlib.compress(b'partial entry')),
+ ]
+ for data in invalid:
+ with self.subTest(data=data):
+ for read in (VideoKeyframeIndex.validate,
+ VideoKeyframeIndex.deserialize):
+ with self.assertRaises(ValueError):
+ read(data, 100)
+
+ def test_deserialize_preserves_metadata_normalization(self):
+ data = VideoKeyframeIndex.HEADER.pack(
+ VideoKeyframeIndex.VERSION, VideoKeyframeIndex.MAGIC, 3, 1
+ ) + b''.join(VideoKeyframeIndex.METADATA_RANGE.pack(*entry)
+ for entry in [(0, 2), (2, 3), (8, 2)])
+ data += zlib.compress(VideoKeyframeIndex.ENTRY.pack(0, -1, 10))
+ restored = VideoKeyframeIndex.deserialize(data, 11)
+ self.assertEqual(((0, 5), (8, 2)), restored.metadata_ranges)
+ self.assertEqual(((0, -1, 10),), restored.keyframes)
+
+ def test_deserialize_bounds_output_before_materializing_entries(self):
+ data = VideoKeyframeIndex.HEADER.pack(
+ VideoKeyframeIndex.VERSION, VideoKeyframeIndex.MAGIC, 0, 1
+ ) + zlib.compress(b'\0' * (4 * 1024 * 1024))
+ real_decompressobj = zlib.decompressobj
+ output_sizes = []
+
+ class TrackingDecompressor:
+ def __init__(self):
+ self._delegate = real_decompressobj()
+
+ def decompress(self, value, max_length=0):
+ output = self._delegate.decompress(value, max_length)
+ output_sizes.append(len(output))
+ return output
+
+ def __getattr__(self, name):
+ return getattr(self._delegate, name)
+
+ with mock.patch.object(keyframe_index_module.zlib, 'decompressobj',
+ side_effect=TrackingDecompressor):
+ with self.assertRaises(ValueError):
+ VideoKeyframeIndex.deserialize(data)
+ self.assertEqual([VideoKeyframeIndex.ENTRY.size + 1], output_sizes)
+
+ def test_deserialize_at_entry_limit(self):
+ entries = tuple((value, value - 100, value * 2)
+ for value in
range(VideoKeyframeIndex.MAX_KEYFRAME_COUNT))
+ index = VideoKeyframeIndex([], entries)
+ restored = VideoKeyframeIndex.deserialize(index.serialize())
+ self.assertEqual(entries, restored.keyframes)
+
def test_validate_rejects_ranges_outside_video_payload(self):
metadata_outside = VideoKeyframeIndex(
[(8, 4)], [(0, 0, 1)]).serialize()
diff --git a/paimon-python/pyproject.toml b/paimon-python/pyproject.toml
index db1f3ab767..e5c4f3e24a 100644
--- a/paimon-python/pyproject.toml
+++ b/paimon-python/pyproject.toml
@@ -54,6 +54,7 @@ dependencies = [
"isal>=1.8,<2; python_version >= '3.9' and (platform_machine == 'x86_64'
or platform_machine == 'AMD64' or platform_machine == 'aarch64' or
platform_machine == 'arm64')",
"fsspec>=2021.10,<2026; python_version < '3.8'",
"fsspec>=2023,<2026; python_version >= '3.8'",
+ "numpy>=1.19.5,<3",
"packaging>=21",
"pandas>=1.1,<2; python_version < '3.7'",
"pandas>=1.3,<3; python_version >= '3.7' and python_version < '3.9'",