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 f2aad3f8a8 [python][torch] Support shuffle for Arrow and tensor batch 
streaming (#10012)
f2aad3f8a8 is described below

commit f2aad3f8a82b0404afcf5972ecbaa8cf7030c730
Author: chaoyang <[email protected]>
AuthorDate: Mon Sep 21 10:41:58 2026 +0800

    [python][torch] Support shuffle for Arrow and tensor batch streaming 
(#10012)
---
 docs/docs/pypaimon/pytorch.md                      |  52 +++++-
 .../pypaimon/read/datasource/torch_dataset.py      | 192 +++++++++++++++-----
 paimon-python/pypaimon/read/table_read.py          |  17 +-
 paimon-python/pypaimon/tests/torch_read_test.py    | 197 ++++++++++++++++++++-
 4 files changed, 397 insertions(+), 61 deletions(-)

diff --git a/docs/docs/pypaimon/pytorch.md b/docs/docs/pypaimon/pytorch.md
index 26770bdbba..320b824c85 100644
--- a/docs/docs/pypaimon/pytorch.md
+++ b/docs/docs/pypaimon/pytorch.md
@@ -133,12 +133,58 @@ for batch in dataloader:
 converter supports non-null numeric, boolean, and numeric fixed-size-list
 columns. Use `to_tensor_fn` for other types or custom conversion.
 
-Omit `batch_size` to preserve native reader batches. Otherwise, batches are
-combined or sliced to the requested size. Use `DataLoader(batch_size=None)` to
-disable a second batching step. Batch streaming does not support 
`shuffle=True`.
+Without shuffle, omit `batch_size` to preserve native reader batches. 
Otherwise,
+batches are combined or sliced to the requested size. Use
+`DataLoader(batch_size=None)` to disable a second batching step.
 Numeric tensors may share read-only Arrow buffers; clone them before in-place
 mutation. Batch formats currently require `prefetch_concurrency=1`.
 
+### Shuffled Batch Streaming
+
+Set `shuffle=True` to mix rows across input batches while keeping values in
+Arrow until the final Tensor conversion. The same options work with
+`batch_format="pyarrow"` and custom `to_tensor_fn` converters:
+
+```python
+dataset = table_read.to_torch(
+    splits,
+    streaming=True,
+    batch_format="torch",
+    batch_size=256,
+    shuffle=True,
+    seed=42,
+    buffer_size=4096,
+    max_buffer_input_splits=4,
+)
+loader = DataLoader(dataset, batch_size=None, num_workers=2)
+
+for epoch in range(10):
+    dataset.set_epoch(epoch)
+    for batch in loader:
+        train(batch["features"], batch["label"])
+```
+
+Each worker retains at most `buffer_size` rows in a rolling shuffle buffer and
+replaces randomly selected slots with rows from incoming Arrow blocks. Input
+blocks contain at most `buffer_size` rows; gathering replacements, output
+batching, and each open format reader use additional memory. This is a row
+bound, not a byte bound. The buffer drains early if combining Arrow blocks
+would overflow a 32-bit offset. Without `batch_size`, output blocks contain at
+most `buffer_size` rows.
+
+`max_buffer_input_splits` bounds the number of interleaved split readers per
+worker; `1` reads splits in order. A binding limit keeps the existing ordered
+read path so it selects the same rows before shuffling. Filters and distributed
+worker/rank sharding also precede shuffle. Each selected row is emitted once.
+
+The shuffle is local to each worker's buffer, not a uniform permutation of the
+whole dataset. The same seed, epoch, input batches and worker/rank 
configuration
+reproduce the same order; `set_epoch()` also reaches persistent workers. 
Changing
+reader batching or worker configuration can change the order. Arrow and Tensor
+formats share the same shuffle path, while row-format split interleaving can
+produce a different order. The existing `prefetch_concurrency=1` requirement
+also applies to shuffled batches.
+
 ## Video frame descriptors
 
 For a multimodal frame table, use the higher-level scan API:
diff --git a/paimon-python/pypaimon/read/datasource/torch_dataset.py 
b/paimon-python/pypaimon/read/datasource/torch_dataset.py
index 541dd1d987..c83d6da630 100644
--- a/paimon-python/pypaimon/read/datasource/torch_dataset.py
+++ b/paimon-python/pypaimon/read/datasource/torch_dataset.py
@@ -25,6 +25,7 @@ import queue
 import random
 import threading
 import warnings
+from collections import deque
 from typing import Any, Callable, Iterator, List, Optional
 
 import pyarrow as pa
@@ -763,6 +764,52 @@ def _default_to_tensor(batch: pa.RecordBatch) -> dict:
     return tensors
 
 
+class _TorchShuffleMixin:
+
+    def _init_shuffle(self, seed, buffer_size, max_buffer_input_splits):
+        self.seed = self._require_int(seed, "seed")
+        self.buffer_size = self._require_positive_int(buffer_size, 
"buffer_size")
+        self.max_buffer_input_splits = self._require_positive_int(
+            max_buffer_input_splits, "max_buffer_input_splits")
+        self._epoch = _share_epoch_with_torch_workers(0)
+
+    def __setstate__(self, state):
+        self.__dict__ = state
+        self._epoch = _share_epoch_with_torch_workers(self._epoch)
+
+    @property
+    def epoch(self) -> int:
+        return int(self._epoch)
+
+    @epoch.setter
+    def epoch(self, epoch: int) -> None:
+        epoch = self._require_int(epoch, "epoch")
+        self._epoch += epoch - self._epoch
+
+    @staticmethod
+    def _require_int(value: int, name: str) -> int:
+        if not isinstance(value, int):
+            raise ValueError("%s must be an int" % name)
+        return value
+
+    @staticmethod
+    def _require_positive_int(value: int, name: str) -> int:
+        if not isinstance(value, int) or value <= 0:
+            raise ValueError("%s must be a positive int" % name)
+        return value
+
+    def set_epoch(self, epoch: int):
+        self.epoch = epoch
+        return self
+
+    def _shuffle_rng(self, worker_id):
+        rank, world_size = self._distributed_context()
+        rng_seed = self.seed + self.epoch * 1000003 + worker_id
+        if world_size > 1:
+            rng_seed = "%d:%d" % (rng_seed, rank)
+        return random.Random(rng_seed)
+
+
 class TorchBatchIterDataset(_BaseTorchIterDataset):
     """Streaming IterableDataset which yields Arrow or Tensor batches."""
 
@@ -792,16 +839,20 @@ class TorchBatchIterDataset(_BaseTorchIterDataset):
         worker_info = torch.utils.data.get_worker_info()
         splits_to_process = self._worker_splits(worker_info)
         raw_batches = self._arrow_batches_for_splits(splits_to_process)
+        worker_id = worker_info.id if worker_info is not None else 0
+        try:
+            for batch in self._prepare_batches(raw_batches, worker_id):
+                if self.batch_format == "torch":
+                    converter = self.to_tensor_fn or _default_to_tensor
+                    yield converter(batch)
+                else:
+                    yield batch
+        finally:
+            raw_batches.close()
 
-        batches = _sized_record_batches(
-            self._limit_batches(raw_batches), self.batch_size
-        )
-        for batch in batches:
-            if self.batch_format == "torch":
-                converter = self.to_tensor_fn or _default_to_tensor
-                yield converter(batch)
-            else:
-                yield batch
+    def _prepare_batches(self, raw_batches, worker_id):
+        return _sized_record_batches(
+            self._limit_batches(raw_batches), self.batch_size)
 
     def _arrow_batches_for_splits(
         self, splits: List[Split]
@@ -830,7 +881,87 @@ class TorchBatchIterDataset(_BaseTorchIterDataset):
             yield batch
 
 
-class TorchShuffledIterDataset(_BaseTorchIterDataset):
+class TorchShuffledBatchIterDataset(_TorchShuffleMixin, TorchBatchIterDataset):
+    """Shuffle Arrow rows before sizing batches and converting to tensors."""
+
+    def __init__(self, table_read, splits, seed=0, buffer_size=1000,
+                 max_buffer_input_splits=10, **kwargs):
+        super().__init__(table_read, splits, **kwargs)
+        self._init_shuffle(seed, buffer_size, max_buffer_input_splits)
+
+    def _prepare_batches(self, raw_batches, worker_id):
+        shuffled = _shuffle_record_batches(
+            self._limit_batches(raw_batches), self.buffer_size,
+            self._shuffle_rng(worker_id))
+        return _sized_record_batches(shuffled, self.batch_size)
+
+    def _arrow_batches_for_splits(self, splits):
+        # Keep the selected prefix of a binding limit unchanged before shuffle.
+        if self.max_buffer_input_splits == 1 or not 
self._limit_covers_all_splits():
+            yield from super()._arrow_batches_for_splits(splits)
+            return
+
+        split_iter = iter(splits)
+        active = deque()
+        read_batches = super()._arrow_batches_for_splits
+
+        def add_next():
+            split = next(split_iter, None)
+            if split is not None:
+                active.append(read_batches([split]))
+
+        try:
+            for _ in range(min(self.max_buffer_input_splits, len(splits))):
+                add_next()
+            while active:
+                try:
+                    yield next(active[0])
+                except StopIteration:
+                    active.popleft().close()
+                    add_next()
+                else:
+                    active.rotate(-1)
+        finally:
+            for batches in active:
+                batches.close()
+
+
+def _shuffle_record_batches(batches, buffer_size, rng):
+    # Only row positions enter Python. Payloads, including nested values and
+    # nulls, stay in Arrow. Each input block replaces random reservoir slots.
+    buffer = None
+    for batch in _sized_record_batches(batches, buffer_size):
+        if buffer is None:
+            buffer = batch
+            continue
+        usage = _batch_offset_usage(buffer)
+        if any(usage.get(path, 0) + value > _MAX_ARROW_OFFSET
+               for path, value in _batch_offset_usage(batch).items()):
+            # Drain before concatenation would overflow a 32-bit Arrow offset.
+            yield _shuffled_record_batch(buffer, rng)
+            buffer = batch
+            continue
+        combined = _concat_record_batches([buffer, batch])
+        retained = list(range(buffer.num_rows))
+        selected = []
+        for row in range(batch.num_rows):
+            slot = rng.randrange(buffer.num_rows)
+            selected.append(retained[slot])
+            retained[slot] = buffer.num_rows + row
+        output = combined.take(pa.array(selected, type=pa.int64()))
+        buffer = combined.take(pa.array(retained, type=pa.int64()))
+        yield output
+    if buffer is not None:
+        yield _shuffled_record_batch(buffer, rng)
+
+
+def _shuffled_record_batch(batch, rng):
+    order = list(range(batch.num_rows))
+    rng.shuffle(order)
+    return batch.take(pa.array(order, type=pa.int64()))
+
+
+class TorchShuffledIterDataset(_TorchShuffleMixin, _BaseTorchIterDataset):
     """
     PyTorch IterableDataset with Paimon-controlled streaming shuffle.
 
@@ -857,40 +988,7 @@ class TorchShuffledIterDataset(_BaseTorchIterDataset):
             sharding_rank,
             sharding_world_size,
         )
-        self.seed = self._require_int(seed, "seed")
-        self.buffer_size = self._require_positive_int(buffer_size, 
"buffer_size")
-        self.max_buffer_input_splits = self._require_positive_int(
-            max_buffer_input_splits, "max_buffer_input_splits")
-        self._epoch = _share_epoch_with_torch_workers(0)
-
-    def __setstate__(self, state):
-        self.__dict__ = state
-        self._epoch = _share_epoch_with_torch_workers(self._epoch)
-
-    @property
-    def epoch(self) -> int:
-        return int(self._epoch)
-
-    @epoch.setter
-    def epoch(self, epoch: int) -> None:
-        epoch = self._require_int(epoch, "epoch")
-        self._epoch += epoch - self._epoch
-
-    @staticmethod
-    def _require_int(value: int, name: str) -> int:
-        if not isinstance(value, int):
-            raise ValueError("%s must be an int" % name)
-        return value
-
-    @staticmethod
-    def _require_positive_int(value: int, name: str) -> int:
-        if not isinstance(value, int) or value <= 0:
-            raise ValueError("%s must be a positive int" % name)
-        return value
-
-    def set_epoch(self, epoch: int) -> "TorchShuffledIterDataset":
-        self.epoch = epoch
-        return self
+        self._init_shuffle(seed, buffer_size, max_buffer_input_splits)
 
     def __iter__(self):
         worker_info = torch.utils.data.get_worker_info()
@@ -957,11 +1055,7 @@ class TorchShuffledIterDataset(_BaseTorchIterDataset):
         rows: Iterator[dict],
         worker_id: int,
     ) -> Iterator[dict]:
-        rank, world_size = self._distributed_context()
-        rng_seed = self.seed + self.epoch * 1000003 + worker_id
-        if world_size > 1:
-            rng_seed = "%d:%d" % (rng_seed, rank)
-        rng = random.Random(rng_seed)
+        rng = self._shuffle_rng(worker_id)
         buffer = []
         for row in rows:
             if len(buffer) < self.buffer_size:
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index c003a828e9..48b8c1127c 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -1264,9 +1264,10 @@ class TableRead:
                 format.
             batch_format: ``"row"``, ``"pyarrow"``, or ``"torch"``. Batch
                 formats require streaming.
-            batch_size: Rows per batch; ``None`` preserves reader batches.
+            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.
-            shuffle: Whether to shuffle rows; supported only in row format.
+            shuffle: Whether to shuffle rows in a bounded buffer before output.
             auto_detect_rank: Whether streaming reads shard by DDP rank.
             sharding_rank: Explicit rank in the intended DDP process group.
             sharding_world_size: Explicit size of that process group.
@@ -1301,10 +1302,6 @@ class TableRead:
                 raise ValueError(
                     "batch_format=%r requires streaming=True" % batch_format
                 )
-            if shuffle:
-                raise ValueError(
-                    "shuffle=True only supports batch_format='row'"
-                )
             if batch_format == "pyarrow" and to_tensor_fn is not None:
                 raise ValueError("to_tensor_fn requires batch_format='torch'")
             if to_tensor_fn is not None and not callable(to_tensor_fn):
@@ -1320,8 +1317,13 @@ class TableRead:
 
             from pypaimon.read.datasource.torch_dataset import (
                 TorchBatchIterDataset,
+                TorchShuffledBatchIterDataset,
             )
-            return TorchBatchIterDataset(
+            dataset_type = TorchShuffledBatchIterDataset if shuffle else 
TorchBatchIterDataset
+            shuffle_options = (dict(seed=seed, buffer_size=buffer_size,
+                                    
max_buffer_input_splits=max_buffer_input_splits)
+                               if shuffle else {})
+            return dataset_type(
                 self,
                 splits,
                 batch_format=batch_format,
@@ -1330,6 +1332,7 @@ class TableRead:
                 auto_detect_rank=auto_detect_rank,
                 sharding_rank=sharding_rank,
                 sharding_world_size=sharding_world_size,
+                **shuffle_options,
             )
 
         if shuffle:
diff --git a/paimon-python/pypaimon/tests/torch_read_test.py 
b/paimon-python/pypaimon/tests/torch_read_test.py
index bb14bd0320..620c90fff5 100644
--- a/paimon-python/pypaimon/tests/torch_read_test.py
+++ b/paimon-python/pypaimon/tests/torch_read_test.py
@@ -19,6 +19,7 @@ import json
 import multiprocessing
 import os
 import pickle
+import random
 import shutil
 import subprocess
 import sys
@@ -39,8 +40,11 @@ from pypaimon.multimodal.table import MultimodalTable
 from pypaimon.read.datasource.torch_dataset import (
     SplitRangeIndex,
     TorchIterDataset,
+    TorchBatchIterDataset,
+    TorchShuffledBatchIterDataset,
     TorchShuffledIterDataset,
     _resolve_distributed_context,
+    _shuffle_record_batches,
 )
 from pypaimon.read.table_read import TableRead
 from pypaimon.table.file_store_table import FileStoreTable
@@ -428,6 +432,92 @@ class TorchDistributedShardingTest(unittest.TestCase):
         )
 
 
+class TorchBatchShuffleTest(unittest.TestCase):
+    @parameterized.expand([1, 3, 8, 50])
+    def test_columnar_shuffle_matches_existing_row_shuffle(self, buffer_size):
+        rows = [{"id": i, "nested": None if i % 4 == 0 else {"values": [i, 
None]},
+                 "text": None if i % 3 == 0 else str(i)} for i in range(25)]
+        table = pa.Table.from_pylist(rows)
+        source = table.to_batches(max_chunksize=4)
+        source.insert(1, source[0].slice(0, 0))
+        actual = pa.Table.from_batches(list(_shuffle_record_batches(
+            iter(source), buffer_size, random.Random(17))))
+
+        legacy = TorchShuffledIterDataset(
+            SimpleNamespace(read_type=[], limit=None), [], seed=17, 
buffer_size=buffer_size)
+        expected = list(legacy._iter_buffer_shuffled_rows(iter(rows), 0))
+        self.assertEqual(actual.schema, table.schema)
+        self.assertEqual(actual.to_pylist(), expected)
+
+    def test_shuffle_does_not_collect_whole_input(self):
+        consumed = []
+
+        def source():
+            for start in range(0, 300, 3):
+                consumed.extend(range(start, start + 3))
+                yield pa.record_batch([pa.array(range(start, start + 3))], 
names=["id"])
+
+        batches = _shuffle_record_batches(source(), 5, random.Random(0))
+        self.assertEqual(next(batches).num_rows, 5)
+        self.assertLessEqual(len(consumed), 12)
+        batches.close()
+
+    def test_shuffle_drains_before_arrow_offset_overflow(self):
+        table = pa.table({"text": [str(i).zfill(4) for i in range(12)]})
+        with patch("pypaimon.read.datasource.torch_dataset._MAX_ARROW_OFFSET", 
12):
+            batches = list(_shuffle_record_batches(
+                iter(table.to_batches(max_chunksize=3)), 3, random.Random(3)))
+        self.assertEqual(sorted(row["text"] for b in batches for row in 
b.to_pylist()),
+                         table["text"].to_pylist())
+        self.assertTrue(all(sum(len(v) for v in b["text"].to_pylist()) <= 12 
for b in batches))
+
+    @parameterized.expand(["complete", "close", "read_error", "convert_error"])
+    def test_interleaved_readers_are_bounded_and_closed(self, mode):
+        opened, closed, active = [], [], set()
+        maximum = [0]
+
+        def read(dataset, splits):
+            split = splits[0]
+            opened.append(split)
+            active.add(split)
+            maximum[0] = max(maximum[0], len(active))
+            try:
+                for i in range(10):
+                    if mode == "read_error" and split == 1 and i == 1:
+                        raise RuntimeError("read failed")
+                    yield pa.record_batch([pa.array([split * 10 + i])], 
names=["id"])
+            finally:
+                active.remove(split)
+                closed.append(split)
+
+        def convert(batch):
+            if mode == "convert_error":
+                raise RuntimeError("conversion failed")
+            return batch
+
+        dataset = TorchShuffledBatchIterDataset(
+            SimpleNamespace(read_type=[], limit=None), list(range(8)),
+            buffer_size=3, max_buffer_input_splits=2, batch_size=2,
+            batch_format="torch", to_tensor_fn=convert)
+        with patch.object(TorchBatchIterDataset, "_arrow_batches_for_splits", 
read):
+            batches = iter(dataset)
+            if mode == "close":
+                next(batches)
+                batches.close()
+            elif mode.endswith("error"):
+                with self.assertRaisesRegex(RuntimeError, "failed"):
+                    list(batches)
+            else:
+                rows = [row["id"] for batch in batches for row in 
batch.to_pylist()]
+                self.assertEqual(sorted(rows), list(range(80)))
+        self.assertFalse(active)
+        self.assertCountEqual(opened, closed)
+        self.assertEqual(maximum[0], 2)
+
+    def test_empty_input(self):
+        self.assertEqual(list(_shuffle_record_batches(iter([]), 5, 
random.Random(0))), [])
+
+
 class TorchReadTest(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
@@ -1084,12 +1174,13 @@ class TorchReadTest(unittest.TestCase):
             )
         with self.assertRaisesRegex(ValueError, 'batch_size requires'):
             table_read.to_torch(splits, streaming=True, batch_size=2)
-        with self.assertRaisesRegex(ValueError, 'only supports batch_format'):
+        with self.assertRaisesRegex(ValueError, 'buffer_size'):
             table_read.to_torch(
                 splits,
                 streaming=True,
                 batch_format='torch',
                 shuffle=True,
+                buffer_size=0,
             )
         for invalid in [0, -1, 1.9, True, 2]:
             with self.subTest(prefetch_concurrency=invalid):
@@ -1128,13 +1219,14 @@ class TorchReadTest(unittest.TestCase):
                     splits,
                     streaming=True,
                     batch_format=batch_format,
-                    shuffle=batch_format == 'row' and shuffle,
+                    shuffle=shuffle,
                     auto_detect_rank=True,
                 )
                 for batch_format, shuffle in [
                     ('row', False),
                     ('row', True),
                     ('pyarrow', False),
+                    ('pyarrow', True),
                 ]
             ]
             expected = splits[(len(splits) + 1) // 2:]
@@ -1718,6 +1810,107 @@ class TorchReadTest(unittest.TestCase):
         print("✓ All predicate test cases passed!")
         print(f"{'=' * 60}\n")
 
+    @parameterized.expand([(1, None), (7, None), (7, 3), (11, 17)])
+    def test_torch_batch_shuffle_formats_and_epochs(self, buffer_size, 
batch_size):
+        table = self._create_shuffle_append_table(
+            'default.batch_shuffle_%s_%s' % (buffer_size, batch_size))
+        builder = table.new_read_builder().with_projection(['user_id', 
'item_id'])
+        splits = builder.new_scan().plan().splits()
+        read = builder.new_read()
+        outputs = []
+        for batch_format in ['pyarrow', 'torch']:
+            dataset = read.to_torch(
+                splits, streaming=True, shuffle=True, 
batch_format=batch_format,
+                batch_size=batch_size, seed=23, buffer_size=buffer_size,
+                max_buffer_input_splits=3)
+
+            def collect():
+                batches = list(dataset)
+                if batch_format == 'pyarrow':
+                    return pa.Table.from_batches(batches).to_pydict(), 
[b.num_rows for b in batches]
+                return {name: torch.cat([b[name] for b in batches]).tolist()
+                        for name in ['user_id', 'item_id']}, 
[len(b['user_id']) for b in batches]
+
+            with patch.object(TableRead, 'to_arrow', 
side_effect=AssertionError('whole table')), \
+                    patch.object(TableRead, 'to_iterator', 
side_effect=AssertionError('row conversion')):
+                data, sizes = collect()
+                self.assertEqual(data, collect()[0])
+                self.assertEqual(sorted(data['user_id']), list(range(80)))
+                self.assertEqual(data['item_id'], [1000 + i for i in 
data['user_id']])
+                self.assertTrue(all(size <= (batch_size or buffer_size) for 
size in sizes))
+                if batch_size:
+                    self.assertTrue(all(size == batch_size for size in 
sizes[:-1]))
+                outputs.append(data)
+                dataset.set_epoch(1)
+                if buffer_size > 1:
+                    self.assertNotEqual(data, collect()[0])
+                dataset.set_epoch(0)
+                self.assertEqual(data, collect()[0])
+        self.assertEqual(outputs[0], outputs[1])
+
+    def test_torch_batch_shuffle_filter_limit_and_empty(self):
+        table = 
self._create_shuffle_append_table('default.batch_shuffle_limit')
+        builder = table.new_read_builder().with_projection(['user_id'])
+        predicate = 
builder.new_predicate_builder().greater_or_equal('user_id', 10)
+        builder.with_filter(predicate).with_limit(9)
+        splits = builder.new_scan().plan().splits()
+        read = builder.new_read()
+        expected = read.to_arrow(splits)['user_id'].to_pylist()
+        dataset = read.to_torch(splits, streaming=True, shuffle=True, 
batch_format='pyarrow',
+                                batch_size=4, buffer_size=3, 
max_buffer_input_splits=3)
+        actual = [row['user_id'] for batch in dataset for row in 
batch.to_pylist()]
+        self.assertCountEqual(actual, expected)
+        for limit in [0, 9]:
+            read.limit = limit
+            empty = read.to_torch([], streaming=True, shuffle=True, 
batch_format='pyarrow', buffer_size=3)
+            self.assertEqual(list(empty), [])
+
+    def test_torch_batch_shuffle_persistent_spawn_workers(self):
+        table = 
self._create_shuffle_append_table('default.batch_shuffle_spawn', 
partition_keys=['dt'])
+        builder = table.new_read_builder().with_projection(['user_id'])
+        splits = builder.new_scan().plan().splits()
+        self.assertGreaterEqual(len(splits), 4)
+        all_ids = []
+        for rank in range(2):
+            dataset = builder.new_read().to_torch(
+                splits, streaming=True, shuffle=True, batch_format='torch',
+                batch_size=4, buffer_size=7, seed=23,
+                sharding_rank=rank, sharding_world_size=2)
+            loader = DataLoader(dataset, batch_size=None, num_workers=2,
+                                persistent_workers=True, 
multiprocessing_context='spawn')
+            try:
+                epoch0 = self._collect_torch_user_ids_from_dataloader(loader)
+                self.assertEqual(epoch0, 
self._collect_torch_user_ids_from_dataloader(loader))
+                dataset.set_epoch(1)
+                epoch1 = self._collect_torch_user_ids_from_dataloader(loader)
+                self.assertCountEqual(epoch0, epoch1)
+                self.assertNotEqual(epoch0, epoch1)
+                all_ids.extend(epoch0)
+            finally:
+                if loader._iterator is not None:
+                    loader._iterator._shutdown_workers()
+        self.assertEqual(sorted(all_ids), list(range(80)))
+
+    def test_torch_batch_shuffle_vectors_deletions_and_snapshot(self):
+        import pypaimon.multimodal as pm
+
+        schema = pa.schema([('id', pa.int64()), ('vector', 
pa.list_(pa.float32(), 2))])
+        table = pm.connect(options={'warehouse': self.warehouse}).create_table(
+            'batch_shuffle_vectors', schema=schema, options={'file.format': 
'parquet'})
+        table.add(pa.table({'id': list(range(8)), 'vector': [[i, i + 1] for i 
in range(8)]}, schema=schema))
+        snapshot = table.raw_table.snapshot_manager().get_latest_snapshot().id
+        table.delete('id = 2')
+        for snapshot_id, expected in [(snapshot, list(range(1, 8))), (None, 
[1, 3, 4, 5, 6, 7])]:
+            dataset = table.scan(snapshot_id=snapshot_id).where('id >= 
1').to_torch(
+                streaming=True, batch_format='torch', batch_size=3, 
shuffle=True,
+                seed=7, buffer_size=4, max_buffer_input_splits=1)
+            batches = list(dataset)
+            ids = torch.cat([batch['id'] for batch in batches]).tolist()
+            vectors = torch.cat([batch['vector'] for batch in batches])
+            self.assertEqual(vectors.dtype, torch.float32)
+            self.assertEqual(vectors.tolist(), [[i, i + 1] for i in ids])
+            self.assertEqual(sorted(ids), expected)
+
     def test_torch_streaming_shuffle_single_worker(self):
         table = 
self._create_shuffle_append_table('default.test_torch_shuffle_single')
         read_builder = table.new_read_builder().with_projection(['user_id'])

Reply via email to