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 f06ebd6e3c [python][torch] Make map-style reads lazy (#9486)
f06ebd6e3c is described below

commit f06ebd6e3c0d2977830342cbda8e423f1fc342e5
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Aug 31 16:58:12 2026 +0800

    [python][torch] Make map-style reads lazy (#9486)
---
 docs/docs/pypaimon/pytorch.md                      |   5 +-
 .../pypaimon/filesystem/caching_file_io.py         |  10 +-
 .../pypaimon/read/datasource/torch_dataset.py      | 270 ++++++++++++++++++++-
 paimon-python/pypaimon/read/table_read.py          |   4 +-
 .../pypaimon/tests/caching_file_io_test.py         |  16 ++
 paimon-python/pypaimon/tests/torch_read_test.py    | 235 ++++++++++++++++++
 6 files changed, 525 insertions(+), 15 deletions(-)

diff --git a/docs/docs/pypaimon/pytorch.md b/docs/docs/pypaimon/pytorch.md
index 8961d76434..af9189bbae 100644
--- a/docs/docs/pypaimon/pytorch.md
+++ b/docs/docs/pypaimon/pytorch.md
@@ -54,8 +54,9 @@ for batch_idx, batch_data in enumerate(dataloader):
 #   {'user_id': tensor([7, 8]), 'behavior': ['g', 'h']}
 ```
 
-When the `streaming` parameter is true, it will iteratively read;
-when it is false, it will read the full amount of data into memory.
+When the `streaming` parameter is true, it will iteratively read. When it is
+false, eligible data-evolution reads fetch each DataLoader batch lazily by row
+ID; other reads retain an Arrow table in memory for map-style access.
 
 **`prefetch_concurrency`** (default: 1): In streaming row mode, controls
 reader threads per DataLoader worker. It has no effect in non-streaming mode.
diff --git a/paimon-python/pypaimon/filesystem/caching_file_io.py 
b/paimon-python/pypaimon/filesystem/caching_file_io.py
index 8aa64e227f..8b10166461 100644
--- a/paimon-python/pypaimon/filesystem/caching_file_io.py
+++ b/paimon-python/pypaimon/filesystem/caching_file_io.py
@@ -341,6 +341,11 @@ class CachingFileIO(FileIO):
         else:
             self._whitelist = whitelist
 
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        state['_cache'] = None
+        return state
+
     # Fallback caps when local-cache.max-size is unset (memory shares the 
heap).
     _DEFAULT_MEMORY_CACHE_MAX_SIZE = 256 * 1024 * 1024
     _DEFAULT_DISK_CACHE_MAX_SIZE = 10 * 1024 * 1024 * 1024
@@ -456,7 +461,10 @@ class CachingFileIO(FileIO):
         return self._delegate.write_row(*args, **kwargs)
 
     def __getattr__(self, name):
-        return getattr(self._delegate, name)
+        delegate = self.__dict__.get('_delegate')
+        if delegate is None:
+            raise AttributeError(name)
+        return getattr(delegate, name)
 
     def close(self):
         self._delegate.close()
diff --git a/paimon-python/pypaimon/read/datasource/torch_dataset.py 
b/paimon-python/pypaimon/read/datasource/torch_dataset.py
index daf435887b..de4bb2cace 100644
--- a/paimon-python/pypaimon/read/datasource/torch_dataset.py
+++ b/paimon-python/pypaimon/read/datasource/torch_dataset.py
@@ -18,6 +18,8 @@
 """
 Module to read a Paimon table into PyTorch Dataset.
 """
+import bisect
+import operator
 import os
 import queue
 import random
@@ -26,13 +28,18 @@ import warnings
 from typing import Any, Callable, Iterator, List, Optional
 
 import pyarrow as pa
+import pyarrow.compute as pc
 import torch
 from torch.utils.data import Dataset, IterableDataset
 
+from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.reader.concat_batch_reader import (
     _MAX_ARROW_OFFSET, _batch_offset_usage)
-from pypaimon.read.split import Split
+from pypaimon.read.split import DataSplit, Split
 from pypaimon.read.table_read import TableRead
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.utils.range import Range
 
 
 def _share_epoch_with_torch_workers(value):
@@ -111,12 +118,75 @@ def _balanced_slice(values: List[Any], shard_id: int, 
shard_count: int):
     return values[start:start + size]
 
 
+class _RowIdRangeIndex:
+
+    def __init__(self, ranges, limit=None):
+        self.ranges = []
+        self.ends = []
+        remaining = limit
+        size = 0
+        for row_range in ranges:
+            count = row_range.count()
+            if remaining is not None:
+                if remaining <= 0:
+                    break
+                count = min(count, remaining)
+                remaining -= count
+            if count == 0:
+                continue
+            self.ranges.append(Range(
+                row_range.from_, row_range.from_ + count - 1))
+            size += count
+            self.ends.append(size)
+
+    def __len__(self):
+        return self.ends[-1] if self.ends else 0
+
+    def take(self, indices):
+        row_ids = []
+        for index in indices:
+            range_index = bisect.bisect_right(self.ends, index)
+            previous_end = self.ends[range_index - 1] if range_index else 0
+            row_ids.append(
+                self.ranges[range_index].from_ + index - previous_end)
+        return row_ids
+
+
+class _SplitRangeIndex:
+
+    def __init__(self, ranges_by_split):
+        self.intervals = sorted(
+            (row_range.from_, row_range.to, split_index)
+            for split_index, ranges in enumerate(ranges_by_split)
+            for row_range in ranges
+        )
+        self.starts = [interval[0] for interval in self.intervals]
+        self.max_ends = []
+        max_end = -1
+        for _, end, _ in self.intervals:
+            max_end = max(max_end, end)
+            self.max_ends.append(max_end)
+
+    def find(self, ranges):
+        split_indices = set()
+        for row_range in ranges:
+            right = bisect.bisect_right(self.starts, row_range.to)
+            left = bisect.bisect_left(
+                self.max_ends, row_range.from_, 0, right)
+            for position in range(left, right):
+                _, end, split_index = self.intervals[position]
+                if end >= row_range.from_:
+                    split_indices.add(split_index)
+        return sorted(split_indices)
+
+
 class TorchDataset(Dataset):
     """
-    PyTorch Dataset implementation for reading Paimon table data.
+    Map-style PyTorch Dataset for Paimon table data.
 
-    This class enables Paimon table data to be used directly with PyTorch's
-    training pipeline, allowing for efficient data loading and batching.
+    Eligible data-evolution reads are fetched lazily by DataLoader batch.
+    Other reads retain their Arrow representation instead of expanding all
+    rows into Python objects.
     """
 
     def __init__(self, table_read: TableRead, splits: List[Split]):
@@ -127,11 +197,104 @@ class TorchDataset(Dataset):
             table_read: TableRead instance for reading data
             splits: List of splits to read
         """
-        arrow_table = table_read.to_arrow(splits)
-        if arrow_table is None or arrow_table.num_rows == 0:
-            self._data = []
+        self.table_read = table_read
+        self.splits = splits
+        self._data = None
+        self._row_ids = None
+        self._split_ranges = None
+        self._split_range_index = None
+        if self._supports_lazy_row_id_read():
+            self._split_ranges = [
+                self._row_ranges_for_split(split) for split in splits
+            ]
+            self._split_range_index = _SplitRangeIndex(self._split_ranges)
+            self._row_ids = self._compact_row_id_index()
+            if self._row_ids is None:
+                row_id_read = TableRead(
+                    table_read.table,
+                    table_read.predicate,
+                    [SpecialFields.ROW_ID],
+                    limit=table_read.limit,
+                )
+                row_id_table = row_id_read.to_arrow(splits)
+                self._row_ids = row_id_table.column(
+                    SpecialFields.ROW_ID.name).combine_chunks()
+                if pc.count_distinct(self._row_ids).as_py() != len(
+                        self._row_ids):
+                    self._materialize()
         else:
-            self._data = arrow_table.to_pylist()
+            self._materialize()
+
+    def _supports_lazy_row_id_read(self) -> bool:
+        if not self.table_read.table.options.row_tracking_enabled():
+            return False
+        if not self.table_read.table.options.data_evolution_enabled():
+            return False
+        if self.table_read.include_row_kind:
+            return False
+        if self.table_read.nested_name_paths:
+            return False
+        if any(self._row_id_is_masked(split) for split in self.splits):
+            return False
+        return all(self._supports_indexed_split(split) for split in 
self.splits)
+
+    @staticmethod
+    def _row_id_is_masked(split) -> bool:
+        if not isinstance(split, QueryAuthSplit):
+            return False
+        masking = getattr(split.auth_result, "column_masking", None)
+        return bool(masking and SpecialFields.ROW_ID.name in masking)
+
+    @staticmethod
+    def _supports_indexed_split(split) -> bool:
+        if isinstance(split, QueryAuthSplit):
+            split = split.split
+        if isinstance(split, IndexedSplit):
+            return split.scores() is None
+        return isinstance(split, DataSplit)
+
+    def _compact_row_id_index(self):
+        if self.table_read.predicate is not None:
+            return None
+        ranges = []
+        for original, split_ranges in zip(
+                self.splits, self._split_ranges):
+            split = original
+            if isinstance(split, QueryAuthSplit):
+                if getattr(split.auth_result, "filter", None):
+                    return None
+                split = split.split
+            deletion_files = split.data_deletion_files or []
+            if any(deletion is not None for deletion in deletion_files):
+                return None
+            ranges.extend(split_ranges)
+        merged = Range.sort_and_merge_overlap(ranges, True)
+        if sum(r.count() for r in ranges) != sum(
+                r.count() for r in merged):
+            return None
+        return _RowIdRangeIndex(ranges, self.table_read.limit)
+
+    @staticmethod
+    def _row_ranges_for_split(split):
+        if isinstance(split, QueryAuthSplit):
+            split = split.split
+        if isinstance(split, IndexedSplit):
+            ranges = split.row_ranges()
+        else:
+            ranges = [
+                data_file.row_id_range()
+                for data_file in split.files
+                if data_file.first_row_id is not None
+            ]
+        return Range.sort_and_merge_overlap(ranges, True)
+
+    def _materialize(self):
+        self._row_ids = None
+        self._data = self.table_read.to_arrow(self.splits)
+        self.table_read = None
+        self.splits = None
+        self._split_ranges = None
+        self._split_range_index = None
 
     def __len__(self) -> int:
         """
@@ -140,7 +303,9 @@ class TorchDataset(Dataset):
         Returns:
             Total number of rows across all splits
         """
-        return len(self._data)
+        if self._row_ids is not None:
+            return len(self._row_ids)
+        return 0 if self._data is None else self._data.num_rows
 
     def __getitem__(self, index: int):
         """
@@ -152,10 +317,93 @@ class TorchDataset(Dataset):
         Returns:
             Dictionary containing the row data
         """
-        if not self._data:
+        if len(self) == 0:
             return None
+        if isinstance(index, slice):
+            return self.__getitems__(range(*index.indices(len(self))))
+        return self.__getitems__([index])[0]
+
+    def __getitems__(self, indices) -> List[dict]:
+        normalized = [self._normalize_index(index) for index in indices]
+        if not normalized:
+            return []
+        if self._row_ids is None:
+            return self._data.take(pa.array(
+                normalized, type=pa.int64())).to_pylist()
 
-        return self._data[index]
+        if isinstance(self._row_ids, _RowIdRangeIndex):
+            row_ids = self._row_ids.take(normalized)
+        else:
+            row_ids = self._row_ids.take(pa.array(
+                normalized, type=pa.int64())).to_pylist()
+        ranges = Range.sort_and_merge_overlap(
+            [Range(row_id, row_id) for row_id in set(row_ids)], True)
+        splits = self._select_splits(ranges)
+
+        output_has_row_id = any(
+            field.name == SpecialFields.ROW_ID.name
+            for field in self.table_read.read_type
+        )
+        read_type = list(self.table_read.read_type)
+        if not output_has_row_id:
+            read_type.append(SpecialFields.ROW_ID)
+        batch_read = TableRead(
+            self.table_read.table,
+            self.table_read.predicate,
+            read_type,
+            include_row_kind=self.table_read.include_row_kind,
+        )
+        rows = batch_read.to_arrow(splits).to_pylist()
+        by_row_id = {}
+        for row in rows:
+            row_id = row[SpecialFields.ROW_ID.name]
+            if not output_has_row_id:
+                del row[SpecialFields.ROW_ID.name]
+            by_row_id[row_id] = row
+        missing = set(row_ids) - set(by_row_id)
+        if missing:
+            raise RuntimeError(
+                "Paimon rows disappeared while reading TorchDataset: %s"
+                % sorted(missing)
+            )
+        return [by_row_id[row_id] for row_id in row_ids]
+
+    def _normalize_index(self, index) -> int:
+        index = operator.index(index)
+        if index < 0:
+            index += len(self)
+        if index < 0 or index >= len(self):
+            raise IndexError("TorchDataset index out of range")
+        return index
+
+    def _select_splits(self, ranges) -> List[Split]:
+        selected = []
+        split_indices = self._split_range_index.find(ranges)
+        for split_index in split_indices:
+            original = self.splits[split_index]
+            auth_result = None
+            split = original
+            if isinstance(split, QueryAuthSplit):
+                auth_result = split.auth_result
+                split = split.split
+
+            if isinstance(split, IndexedSplit):
+                split = split.data_split()
+            allowed = Range.and_(
+                ranges, self._split_ranges[split_index])
+            if not allowed:
+                continue
+
+            indexed = IndexedSplit(
+                split,
+                allowed,
+                exact_merged_row_count=sum(r.count() for r in allowed),
+            )
+            selected.append(
+                QueryAuthSplit(indexed, auth_result)
+                if auth_result is not None else indexed
+            )
+        return selected
 
 
 class _BaseTorchIterDataset(IterableDataset):
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index cfe13fd75b..78b2d26f41 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -669,7 +669,9 @@ class TableRead:
 
         Args:
             splits: Splits to read.
-            streaming: Whether to stream data.
+            streaming: Whether to return an iterable dataset. Non-streaming
+                eligible data-evolution reads fetch map-style batches lazily
+                by row ID.
             prefetch_concurrency: Reader threads per DataLoader worker in row
                 format.
             batch_format: ``"row"``, ``"pyarrow"``, or ``"torch"``. Batch
diff --git a/paimon-python/pypaimon/tests/caching_file_io_test.py 
b/paimon-python/pypaimon/tests/caching_file_io_test.py
index 12288ef120..940a4f4ab2 100644
--- a/paimon-python/pypaimon/tests/caching_file_io_test.py
+++ b/paimon-python/pypaimon/tests/caching_file_io_test.py
@@ -20,6 +20,7 @@
 
 import io
 import os
+import pickle
 import shutil
 import tempfile
 import threading
@@ -385,6 +386,21 @@ class CachingFileIOTest(unittest.TestCase):
         caching_io.new_output_stream("/out")
         delegate.new_output_stream.assert_called_once_with("/out")
 
+    def test_pickle_drops_cache(self):
+        from pypaimon.filesystem.local_file_io import LocalFileIO
+        from pypaimon.utils.file_type import FileType
+
+        cache = LocalDiskCacheManager(
+            self.cache_dir, 2 ** 63 - 1, block_size=64)
+        caching_io = CachingFileIO(
+            LocalFileIO(), cache, {FileType.META, FileType.DATA})
+
+        restored = pickle.loads(pickle.dumps(caching_io))
+
+        self.assertIsNone(restored._cache)
+        self.assertEqual({FileType.META, FileType.DATA}, restored._whitelist)
+        self.assertIsInstance(restored._delegate, LocalFileIO)
+
     def test_to_filesystem_path_forwarded_to_delegate(self):
         delegate = MagicMock()
         delegate.to_filesystem_path.side_effect = lambda p: 
p.replace("oss://", "")
diff --git a/paimon-python/pypaimon/tests/torch_read_test.py 
b/paimon-python/pypaimon/tests/torch_read_test.py
index 809fe582ac..0dc269110d 100644
--- a/paimon-python/pypaimon/tests/torch_read_test.py
+++ b/paimon-python/pypaimon/tests/torch_read_test.py
@@ -33,14 +33,18 @@ import torch
 from torch.utils.data import DataLoader
 
 from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
 from pypaimon.multimodal.table import MultimodalTable
 
 from pypaimon.read.datasource.torch_dataset import (
+    _SplitRangeIndex,
     TorchIterDataset,
     TorchShuffledIterDataset,
     _resolve_distributed_context,
 )
+from pypaimon.read.table_read import TableRead
 from pypaimon.table.file_store_table import FileStoreTable
+from pypaimon.utils.range import Range
 
 
 def _collect_spawned_worker_splits(dataset, output):
@@ -500,8 +504,239 @@ class TorchReadTest(unittest.TestCase):
         self.assertEqual(sorted_behaviors, expected_behaviors,
                          f"Behaviors mismatch. Expected {expected_behaviors}, 
got {sorted_behaviors}")
 
+        if not is_streaming:
+            self.assertIsInstance(dataset._data, pa.Table)
+
         print(f"✓ Test passed: Successfully read {len(all_user_ids)} rows with 
correct data")
 
+    def test_non_streaming_row_tracking_reads_batches_lazily(self):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            partition_keys=['dt'],
+            options={
+                'data-evolution.enabled': 'true',
+                'row-tracking.enabled': 'true',
+            },
+        )
+        identifier = 'default.test_torch_lazy_row_tracking'
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        self._write_test_table(table)
+
+        read_builder = table.new_read_builder().with_projection(
+            ['user_id', 'behavior'])
+        splits = read_builder.new_scan().plan().splits()
+        self.assertGreater(len(splits), 1)
+        table_read = read_builder.new_read()
+        expected = table_read.to_arrow(splits).to_pylist()
+
+        with patch.object(
+                TableRead, 'to_arrow', side_effect=AssertionError(
+                    'dataset construction must not read table data')):
+            dataset = table_read.to_torch(splits, streaming=False)
+
+        self.assertIsNone(dataset._data)
+        self.assertEqual(len(expected), len(dataset))
+        indices = [len(dataset) - 1, 1, 1, 0]
+        self.assertEqual(
+            [expected[index] for index in indices],
+            dataset.__getitems__(indices),
+        )
+        self.assertEqual(expected[-1], dataset[-1])
+        restored = pickle.loads(pickle.dumps(dataset))
+        self.assertEqual(expected[1], restored[1])
+
+        row_id_builder = table.new_read_builder().with_projection(
+            ['user_id', '_ROW_ID'])
+        row_id_splits = row_id_builder.new_scan().plan().splits()
+        row_id_read = row_id_builder.new_read()
+        expected_row_id = row_id_read.to_arrow(
+            row_id_splits).to_pylist()[0]
+        row_id_dataset = row_id_read.to_torch(
+            row_id_splits, streaming=False)
+        self.assertEqual(expected_row_id, row_id_dataset[0])
+
+        loader = DataLoader(
+            dataset,
+            batch_size=3,
+            num_workers=2,
+            shuffle=True,
+            generator=torch.Generator().manual_seed(7),
+        )
+        actual_ids = []
+        for batch in loader:
+            actual_ids.extend(batch['user_id'].tolist())
+        self.assertEqual(
+            sorted(row['user_id'] for row in expected),
+            sorted(actual_ids),
+        )
+
+    def 
test_non_streaming_row_tracking_without_data_evolution_materializes(self):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            options={'row-tracking.enabled': 'true'},
+        )
+        identifier = 'default.test_torch_row_tracking_without_data_evolution'
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        self._write_test_table(table)
+
+        read_builder = table.new_read_builder().with_projection(
+            ['user_id', 'behavior'])
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+        expected = table_read.to_arrow(
+            splits, parallelism=1).to_pylist()
+        with patch.object(
+                table_read, 'to_arrow', wraps=table_read.to_arrow) as read:
+            dataset = table_read.to_torch(splits, streaming=False)
+            read.assert_called_once_with(splits)
+
+        self.assertIsInstance(dataset._data, pa.Table)
+        self.assertIsNone(dataset.table_read)
+        self.assertIsNone(dataset.splits)
+        with patch.object(
+                TableRead, 'to_arrow', side_effect=AssertionError(
+                    'materialized dataset must not read another batch')):
+            self.assertEqual(expected, dataset[:])
+
+    def test_non_streaming_row_id_masking_materializes(self):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            options={
+                'data-evolution.enabled': 'true',
+                'row-tracking.enabled': 'true',
+            },
+        )
+        identifier = 'default.test_torch_masked_row_id'
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        self._write_test_table(table)
+        auth = TableQueryAuthResult(
+            filter=None,
+            column_masking={
+                '_ROW_ID': json.dumps({'name': 'NULL'}),
+            },
+        )
+        table.catalog_environment.table_query_auth = (
+            lambda options, table_identifier: lambda select: auth
+        )
+
+        read_builder = table.new_read_builder().with_projection(
+            ['user_id', 'behavior'])
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+        expected = table_read.to_arrow(
+            splits, parallelism=1).to_pylist()
+        dataset = table_read.to_torch(splits, streaming=False)
+
+        self.assertIsInstance(dataset._data, pa.Table)
+        self.assertEqual(expected, dataset[:])
+
+    def test_non_streaming_dataset_with_cache_is_pickleable(self):
+        with tempfile.TemporaryDirectory() as tempdir:
+            catalog = CatalogFactory.create({
+                'warehouse': os.path.join(tempdir, 'warehouse'),
+                'local-cache.enabled': 'true',
+                'local-cache.whitelist': 'meta,global-index,data',
+            })
+            catalog.create_database('default', True)
+            for suffix, options, lazy in [
+                ('arrow', {}, False),
+                ('lazy', {
+                    'data-evolution.enabled': 'true',
+                    'row-tracking.enabled': 'true',
+                }, True),
+            ]:
+                with self.subTest(suffix=suffix):
+                    identifier = 'default.test_torch_cache_' + suffix
+                    schema = Schema.from_pyarrow_schema(
+                        self.pa_schema, options=options)
+                    catalog.create_table(identifier, schema, False)
+                    table = catalog.get_table(identifier)
+                    self._write_test_table(table)
+                    read_builder = table.new_read_builder().with_projection(
+                        ['user_id', 'behavior'])
+                    splits = read_builder.new_scan().plan().splits()
+                    dataset = read_builder.new_read().to_torch(
+                        splits, streaming=False)
+                    self.assertEqual(lazy, dataset._data is None)
+
+                    restored = pickle.loads(pickle.dumps(dataset))
+                    self.assertEqual(dataset[0], restored[0])
+
+    def test_split_range_index(self):
+        index = _SplitRangeIndex([
+            [Range(split * 10, split * 10 + 9)]
+            for split in range(10000)
+        ])
+
+        self.assertEqual([5000], index.find([Range(50003, 50005)]))
+        self.assertEqual(
+            [0, 9999], index.find([Range(0, 0), Range(99999, 99999)]))
+
+    def test_non_streaming_row_tracking_preserves_filter_and_limit(self):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            options={
+                'data-evolution.enabled': 'true',
+                'row-tracking.enabled': 'true',
+            },
+        )
+        identifier = 'default.test_torch_lazy_filter_limit'
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        self._write_test_table(table)
+
+        read_builder = table.new_read_builder().with_projection(
+            ['user_id', 'behavior'])
+        predicate = read_builder.new_predicate_builder().greater_than(
+            'user_id', 2)
+        read_builder.with_filter(predicate).with_limit(3)
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+        expected = table_read.to_arrow(
+            splits, parallelism=1).to_pylist()
+        dataset = table_read.to_torch(splits, streaming=False)
+
+        self.assertIsNone(dataset._data)
+        self.assertEqual(3, len(dataset))
+        self.assertEqual(
+            expected,
+            dataset.__getitems__(range(len(dataset))),
+        )
+
+    def test_non_streaming_row_tracking_respects_deletion_vectors(self):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            options={
+                'data-evolution.enabled': 'true',
+                'row-tracking.enabled': 'true',
+                'deletion-vectors.enabled': 'true',
+            },
+        )
+        identifier = 'default.test_torch_lazy_deletion_vector'
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        self._write_test_table(table)
+
+        write_builder = table.new_batch_write_builder()
+        messages = write_builder.new_update().delete_by_row_id([2])
+        table_commit = write_builder.new_commit()
+        table_commit.commit(messages)
+        table_commit.close()
+
+        read_builder = table.new_read_builder().with_projection(
+            ['user_id', 'behavior'])
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+        expected = table_read.to_arrow(splits).to_pylist()
+        dataset = table_read.to_torch(splits, streaming=False)
+
+        self.assertIsNone(dataset._data)
+        self.assertIsInstance(dataset._row_ids, pa.Array)
+        self.assertEqual(expected, dataset[:])
+
     def test_torch_streaming_prefetch_concurrency(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['user_id'])
         self.catalog.create_table('default.test_torch_prefetch_concurrency', 
schema, False)

Reply via email to