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 8441f2ae3b [python] Support Variant projections in streaming Torch row 
datasets (#10858)
8441f2ae3b is described below

commit 8441f2ae3b428b1e0b066a4bd15a19594c29ce12
Author: QuakeWang <[email protected]>
AuthorDate: Thu Oct 29 11:08:11 2026 +0800

    [python] Support Variant projections in streaming Torch row datasets 
(#10858)
---
 .../pypaimon/read/datasource/torch_dataset.py      |   6 +-
 paimon-python/pypaimon/read/table_read.py          |  11 +-
 .../tests/named_projection_iterator_test.py        |  13 +-
 .../pypaimon/tests/native_torch_variant_test.py    | 218 +++++++++++++++++++++
 paimon-python/pypaimon/tests/torch_read_test.py    |  16 ++
 paimon-python/pypaimon/tests/variant_test.py       |  24 ++-
 6 files changed, 271 insertions(+), 17 deletions(-)

diff --git a/paimon-python/pypaimon/read/datasource/torch_dataset.py 
b/paimon-python/pypaimon/read/datasource/torch_dataset.py
index cfb102a0b6..9bebc92772 100644
--- a/paimon-python/pypaimon/read/datasource/torch_dataset.py
+++ b/paimon-python/pypaimon/read/datasource/torch_dataset.py
@@ -588,7 +588,8 @@ class TorchIterDataset(_BaseTorchIterDataset):
         worker_info = torch.utils.data.get_worker_info()
         splits_to_process = self._worker_splits(worker_info)
 
-        if self.prefetch_concurrency > 1:
+        # A binding limit needs one reader quota across all selected splits.
+        if self.prefetch_concurrency > 1 and self._limit_covers_all_splits():
             yield from self._iter_rows(splits_to_process)
             return
 
@@ -1004,7 +1005,8 @@ class TorchShuffledIterDataset(_TorchShuffleMixin, 
_BaseTorchIterDataset):
         worker_id = worker_info.id if worker_info is not None else 0
         splits_to_process = self._worker_splits(worker_info)
 
-        if self.max_buffer_input_splits == 1:
+        # Select the limited prefix before shuffling, as in the batch dataset.
+        if self.max_buffer_input_splits == 1 or not 
self._limit_covers_all_splits():
             rows = self._iter_ordered_rows(splits_to_process)
         else:
             rows = self._iter_interleaved_rows(splits_to_process)
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 45f50bd5da..90abe65126 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -483,10 +483,12 @@ class TableRead:
                 eligible data-evolution reads fetch map-style batches lazily
                 by row ID.
             prefetch_concurrency: Reader threads per DataLoader worker in row
-                format.
+                format. Binding limits read serially to preserve the selected
+                prefix.
             batch_format: ``"row"``, ``"pyarrow"``, or ``"torch"``. Batch
                 formats require streaming. Streaming row format also supports
-                named column and path projections.
+                named columns, paths and float32 VARIANT extractions through
+                native reading.
             batch_size: Rows per batch; ``None`` preserves reader batches when
                 unshuffled, or emits shuffle blocks of at most buffer_size 
rows.
             to_tensor_fn: Optional RecordBatch converter for Torch batches.
@@ -514,10 +516,7 @@ class TableRead:
         ):
             raise ValueError("batch_size must be a positive int or None")
         if batch_format == "row":
-            from pypaimon.read.variant_read_type import has_variant_extractions
-
-            if (self.output_projection is not None and 
self.output_projection.named
-                    and (not streaming or 
has_variant_extractions(self.read_type))):
+            if not streaming and self.output_projection is not None and 
self.output_projection.named:
                 raise RuntimeError(
                     "Projection expressions are not supported by Torch row 
format; "
                     "use streaming=True with batch_format='pyarrow' or 
'torch'")
diff --git a/paimon-python/pypaimon/tests/named_projection_iterator_test.py 
b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
index 1f54d3dc16..85528d0555 100644
--- a/paimon-python/pypaimon/tests/named_projection_iterator_test.py
+++ b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
@@ -258,7 +258,7 @@ def 
test_named_projection_keeps_non_streaming_torch_row_boundary(projected_table
 @pytest.mark.parametrize('function', ['variant_get', 'try_variant_get'])
 @pytest.mark.parametrize('nested', [False, True])
 @pytest.mark.parametrize('mixed', [False, True])
-def 
test_variant_iterator_requires_native_and_keeps_torch_restriction(tmp_path, 
function, nested, mixed):
+def test_variant_iterator_and_torch_rows_require_native(tmp_path, function, 
nested, mixed):
     catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
     catalog.create_database('db', True)
     payload = DataField(2 if nested else 1, 'payload', AtomicType('VARIANT'))
@@ -275,7 +275,12 @@ def 
test_variant_iterator_requires_native_and_keeps_torch_restriction(tmp_path,
             patch.object(PythonTableRead, 'read_rows', 
side_effect=AssertionError('Python Variant execution')):
         with pytest.raises(RuntimeError, match='Variant extraction requires a 
compatible native reader'):
             list(read.to_iterator([]))
-        for streaming in (False, True):
-            with pytest.raises(RuntimeError, match='not supported by Torch row 
format'):
-                read.to_torch([], streaming=streaming, batch_format='row')
+        with pytest.raises(RuntimeError, match='not supported by Torch row 
format'):
+            read.to_torch([], streaming=False, batch_format='row')
+        native.assert_not_called()
+
+        pytest.importorskip('torch')
+        dataset = read.to_torch([], streaming=True, batch_format='row')
+        with pytest.raises(RuntimeError, match='Variant extraction requires a 
compatible native reader'):
+            list(dataset)
         native.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/native_torch_variant_test.py 
b/paimon-python/pypaimon/tests/native_torch_variant_test.py
new file mode 100644
index 0000000000..30b09c4f47
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_torch_variant_test.py
@@ -0,0 +1,218 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Streaming Torch rows retain Native Variant projection semantics."""
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+pytest.importorskip('torch')
+
+from torch.utils.data import DataLoader
+
+from pypaimon.execution.native.read.table_read import native_read
+from pypaimon.execution.native.runtime import native_runtime_available
+from pypaimon.tests import native_variant_iterator_test
+
+
+pytestmark = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+variant_table = native_variant_iterator_test.variant_table
+variant_builder = native_variant_iterator_test.variant_builder
+
+
[email protected](params=[
+    {}, {'prefetch_concurrency': 2},
+    {'shuffle': True, 'seed': 17, 'buffer_size': 1, 'max_buffer_input_splits': 
1},
+    {'shuffle': True, 'seed': 17, 'buffer_size': 1, 'max_buffer_input_splits': 
3},
+], ids=['serial', 'prefetch', 'shuffle_ordered', 'shuffle_interleaved'])
+def row_options(request):
+    return request.param
+
+
+def _expected_rows():
+    names = ['score', 'identifier', 'score_copy', 'good', 'identifier_copy']
+    values = [(1.25, 1, 1.25, 2.5, 1), (None, 2, None, 3.5, 2)]
+    values += [(None, value, None, None, value) for value in range(3, 7)]
+    return [dict(zip(names, row)) for row in values]
+
+
+def _assert_rows(actual, expected):
+    assert sorted(actual, key=lambda row: row['identifier']) == expected
+    assert all(list(row) == list(_expected_rows()[0]) for row in actual)
+    assert all(row['score'] is None or isinstance(row['score'], float) for row 
in actual)
+
+
+def test_streaming_variant_rows(variant_builder, row_options):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    assert len(splits) > 1
+    dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+    _assert_rows(list(DataLoader(dataset, batch_size=None, num_workers=0)), 
_expected_rows())
+    if row_options.get('shuffle'):
+        assert list(dataset) == list(dataset)
+
+
+def test_streaming_variant_only_projection(variant_builder, row_options):
+    builder, source = variant_builder
+    builder.with_projection({'score': "try_variant_get(%s, '$.score', 
'float')" % source})
+    splits = builder.new_scan().plan().splits()
+    actual = list(builder.new_read().to_torch(splits, streaming=True, 
**row_options))
+    expected = [{'score': row['score']} for row in _expected_rows()]
+    assert sorted(actual, key=repr) == sorted(expected, key=repr)
+
+
[email protected]('limit', [0, 1, 3, 6])
[email protected]('plan_before_limit', [False, True])
+def test_streaming_variant_limit(variant_builder, row_options, limit, 
plan_before_limit):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits() if plan_before_limit else None
+    builder.with_limit(limit)
+    if splits is None:
+        splits = builder.new_scan().plan().splits()
+    dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+    _assert_rows(list(dataset), _expected_rows()[:limit])
+
+
[email protected]('limit', [None, 2])
+def test_streaming_variant_filter(variant_builder, row_options, limit):
+    builder, _ = variant_builder
+    predicate = 
builder.table.new_read_builder().new_predicate_builder().greater_than('id', 2)
+    builder.with_filter(predicate)
+    if limit is not None:
+        builder.with_limit(limit)
+    splits = builder.new_scan().plan().splits()
+    dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+    _assert_rows(list(dataset), _expected_rows()[2:][:limit])
+
+
+def test_streaming_variant_empty_input(variant_builder, row_options):
+    builder, _ = variant_builder
+    assert list(builder.new_read().to_torch([], streaming=True, 
**row_options)) == []
+
+
+def test_streaming_variant_strict_cast_errors(variant_builder, row_options):
+    builder, source = variant_builder
+    builder.with_projection({'score': "variant_get(%s, '$.score', 'float')" % 
source})
+    splits = builder.new_scan().plan().splits()
+    dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+    with pytest.raises(Exception, match='Cannot cast Variant'):
+        list(dataset)
+
+
[email protected]('failure', ['disabled', 'missing_runtime', 'setup', 
'unsupported_split'])
+def test_streaming_variant_requires_native(variant_builder, row_options, 
failure):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    target = 'pypaimon.execution.native.read.table_read.native_read'
+    if failure == 'disabled':
+        builder.table = builder.table.copy({'read.native.enabled': 'false'})
+        options = {'wraps': native_read}
+    elif failure == 'missing_runtime':
+        target = 'pypaimon.execution.native.runtime.native_runtime_available'
+        options = {'return_value': False}
+    elif failure == 'setup':
+        options = {'side_effect': RuntimeError('Native setup failed')}
+    else:
+        target = 
'pypaimon.execution.native.read.table_read.NativeTableRead._split_files_supported'
+        options = {'return_value': False}
+    dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+    with patch(target, **options):
+        with pytest.raises(RuntimeError, match='Variant extraction requires a 
compatible native reader'):
+            list(dataset)
+
+
[email protected]('limit', [None, 0, 1, 6])
+def test_streaming_variant_rank_worker_sharding(variant_builder, row_options, 
limit):
+    builder, _ = variant_builder
+    if limit is not None:
+        builder.with_limit(limit)
+    read, splits = builder.new_read(), builder.new_scan().plan().splits()
+    actual = []
+    for rank in range(2):
+        dataset = read.to_torch(splits, streaming=True, sharding_rank=rank, 
sharding_world_size=2,
+                                **row_options)
+        for worker in range(2):
+            with patch('torch.utils.data.get_worker_info', 
return_value=SimpleNamespace(id=worker, num_workers=2)):
+                if limit == 1:
+                    with pytest.raises(ValueError, match='limit is not 
supported with distributed Torch sharding'):
+                        list(dataset)
+                else:
+                    actual.extend(dataset)
+    if limit != 1:
+        _assert_rows(actual, _expected_rows()[:limit])
+
+
+def test_streaming_variant_dataloader_workers(variant_builder):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    dataset = builder.new_read().to_torch(splits, streaming=True)
+    loader = DataLoader(dataset, batch_size=None, num_workers=2, 
multiprocessing_context='spawn')
+    _assert_rows(list(loader), _expected_rows())
+
+
[email protected]('stop', ['unstarted', 'close', 'exhaust', 'failure'])
[email protected]('limit', [None, 3])
+def test_streaming_variant_closes_native_streams(variant_builder, row_options, 
stop, limit):
+    builder, _ = variant_builder
+    if limit is not None:
+        builder.with_limit(limit)
+    splits = builder.new_scan().plan().splits()
+    opened, closed = [], []
+    failure = OSError('Native stream failed')
+
+    def tracked_native(*args, **kwargs):
+        batches = native_read(*args, **kwargs)
+        token = object()
+        opened.append(token)
+
+        def tracked_batches():
+            try:
+                for batch in batches:
+                    yield batch
+                    if stop == 'failure':
+                        raise failure
+            finally:
+                batches.close()
+                closed.append(token)
+        return tracked_batches()
+
+    with patch('pypaimon.execution.native.read.table_read.native_read', 
side_effect=tracked_native):
+        dataset = builder.new_read().to_torch(splits, streaming=True, 
**row_options)
+        rows = iter(dataset)
+        try:
+            if stop == 'failure':
+                with pytest.raises(OSError) as error:
+                    list(rows)
+                assert error.value is failure
+            elif stop == 'exhaust':
+                _assert_rows(list(rows), _expected_rows()[:limit])
+            elif stop == 'close':
+                assert next(rows) in _expected_rows()[:limit]
+        finally:
+            rows.close()
+    assert set(opened) == set(closed)
+    assert len(opened) == len(closed)
+    assert bool(opened) == (stop != 'unstarted')
+
+
+def test_non_streaming_variant_rows_remain_unsupported(variant_builder):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    with pytest.raises(RuntimeError, match='Projection expressions are not 
supported by Torch row format'):
+        builder.new_read().to_torch(splits)
diff --git a/paimon-python/pypaimon/tests/torch_read_test.py 
b/paimon-python/pypaimon/tests/torch_read_test.py
index 2478d53dbf..192b2b7537 100644
--- a/paimon-python/pypaimon/tests/torch_read_test.py
+++ b/paimon-python/pypaimon/tests/torch_read_test.py
@@ -651,6 +651,22 @@ class TorchReadTest(unittest.TestCase):
             with self.assertRaisesRegex(NotImplementedError, 'with query 
authorization'):
                 list(dataset)
 
+    def test_torch_streaming_row_limit_with_prefetch_and_shuffle(self):
+        table, _ = self._create_named_projection_table('default.row_limit', 
'append')
+        builder = table.new_read_builder().with_projection({'identifier': 
'id'})
+        splits = builder.new_scan().plan().splits()
+        self.assertGreater(len(splits), 1)
+        for limit in [0, 1, 3, 5]:
+            builder.with_limit(limit)
+            read = builder.new_read()
+            expected = read.to_arrow(splits, parallelism=1).to_pylist()
+            for options in [{'prefetch_concurrency': 2},
+                            {'shuffle': True, 'buffer_size': 1, 
'max_buffer_input_splits': 1},
+                            {'shuffle': True, 'buffer_size': 1, 
'max_buffer_input_splits': 3}]:
+                with self.subTest(limit=limit, options=options):
+                    dataset = read.to_torch(splits, streaming=True, **options)
+                    self.assertCountEqual(list(dataset), expected)
+
     @parameterized.expand([True, False])
     def test_torch_read(self, is_streaming: bool = False):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['user_id'])
diff --git a/paimon-python/pypaimon/tests/variant_test.py 
b/paimon-python/pypaimon/tests/variant_test.py
index b83694fca3..c8e2067ff2 100644
--- a/paimon-python/pypaimon/tests/variant_test.py
+++ b/paimon-python/pypaimon/tests/variant_test.py
@@ -41,6 +41,7 @@ Sections
 """
 
 import asyncio
+import importlib.util
 import io
 import json
 import os
@@ -1262,7 +1263,8 @@ class TestVariantPaimonTable(unittest.TestCase):
             pa.field('payload', _variant_arrow_type()),
         ])
 
-    def test_variant_projection_keeps_torch_row_restriction(self):
+    @unittest.skipUnless(importlib.util.find_spec('torch') is not None, 'torch 
is not installed')
+    def test_variant_projection_torch_row_backend_boundary(self):
         for mode, options in (
             ('append', {}),
             ('lazy', {
@@ -1289,17 +1291,29 @@ class TestVariantPaimonTable(unittest.TestCase):
                 writer.close()
                 commit.close()
 
-                builder = table.new_read_builder().with_projection(
-                    {'ratio': "try_variant_get(payload, '$.ratio', 'float')"})
+                projection = {'ratio': "try_variant_get(payload, '$.ratio', 
'float')"}
+                builder = table.copy({'read.native.enabled': 
'false'}).new_read_builder().with_projection(projection)
                 splits = builder.new_scan().plan().splits()
                 self.assertTrue(splits)
                 read = builder.new_read()
 
-                with self.assertRaisesRegex(RuntimeError, 'Torch row format'):
-                    read.to_torch(splits, streaming=True)
                 with self.assertRaisesRegex(RuntimeError, 'Torch row format'):
                     read.to_torch(splits, streaming=False)
 
+                dataset = read.to_torch(splits, streaming=True)
+                with self.assertRaisesRegex(RuntimeError, 'Variant extraction 
requires a compatible native reader'):
+                    list(dataset)
+
+                builder = table.copy({'read.native.enabled': 
'true'}).new_read_builder().with_projection(projection)
+                read = builder.new_read()
+                with 
patch('pypaimon.execution.native.runtime.native_runtime_available', 
return_value=False):
+                    dataset = read.to_torch(splits, streaming=True)
+                    with self.assertRaisesRegex(RuntimeError, 'Variant 
extraction requires a compatible native reader'):
+                        list(dataset)
+
+                if _native_variant_projection_available():
+                    self.assertEqual(list(read.to_torch(splits, 
streaming=True)), [{'ratio': 1.25}])
+
     def _check_variant_projection_literal_top_level_names(
             self, suffix, native_read):
         pa_schema = pa.schema([

Reply via email to