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 ba040653d2 [python][torch] Add lazy contiguous window dataset (#9580)
ba040653d2 is described below
commit ba040653d2c3d21c8b317c8a7546c73840880ac8
Author: Yann Byron <[email protected]>
AuthorDate: Tue Sep 8 16:49:12 2026 +0800
[python][torch] Add lazy contiguous window dataset (#9580)
---
docs/docs/pypaimon/multimodal-api.mdx | 62 ++
docs/docs/pypaimon/pytorch.md | 48 ++
paimon-python/pypaimon/multimodal/query.py | 66 ++
.../pypaimon/multimodal/window_dataset.py | 716 +++++++++++++++++++++
.../pypaimon/read/datasource/torch_dataset.py | 99 +--
.../tests/contiguous_window_dataset_test.py | 664 +++++++++++++++++++
paimon-python/pypaimon/tests/torch_read_test.py | 4 +-
7 files changed, 1613 insertions(+), 46 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index bdf780859f..ddef5d1475 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -905,6 +905,68 @@ Notes:
few large reads); scattered point reads coalesce less.
- Blob reads are available only on `scan()`, not on the `search()` queries.
+### Contiguous windows for PyTorch
+
+Install the `torch` extra, then use `to_contiguous_window_dataset` to expose
+map-style windows without loading the selected rows or BLOB payloads into
Python
+memory up front. The Dataset builds a compact index from the group column,
order
+column, and Paimon row IDs. Each `__getitem__` call fetches only that window
from
+the snapshot recorded in `dataset.snapshot_id`.
+
+```shell
+pip install 'pypaimon[torch]'
+```
+
+```python
+import torch
+
+
+def float32_window(values):
+ return torch.tensor(values, dtype=torch.float32)
+
+
+windows = (
+ frames.scan()
+ .where("split = 'train'")
+ .to_contiguous_window_dataset(
+ window_size=16,
+ columns=["state", "action"],
+ group_key="episode_index",
+ order_key="frame_index",
+ tail="pad",
+ column_transforms={
+ "state": float32_window,
+ "action": float32_window,
+ },
+ )
+)
+
+sample = windows[0]
+assert sample["action"].shape == (16, action_size)
+assert sample["is_pad"].shape == (16,)
+```
+
+The group and order keys in a sample identify the window anchor. Every
projected
+column contains the whole window. With `tail="drop"`, only full windows are
+exposed. With `tail="pad"`, every real row is an anchor; missing suffix values
+repeat the last real value by default and `is_pad` is `True` exactly at those
+positions. With `tail="error"`, construction fails if any scheduled anchor is
+incomplete. Use `pad_values` to override the repeated value for individual
+columns. Anchors advance by `stride`, which defaults to one row.
+
+`column_transforms` receive one padded Python list per projected column. This
is
+where applications define tensor dtype and shape or decode BLOB bytes. The
+optional `adapter` receives the resulting sample mapping and can rename or
+combine fields for a model-specific batch contract. The core Dataset does not
+know model field names, image formats, or normalization rules. Top-level
+functions and callable classes are recommended for transforms and adapters so
+the Dataset remains picklable by multi-worker `torch.utils.data.DataLoader`
+instances.
+
+Columns configured by `video-frame-field` are rejected: a window read would
drop
+the `frame_index` and other metadata carried by their `VideoFrameDescriptor`
+values. Read those columns with `to_torch()` instead.
+
### Distributed BLOB processing with Ray
For larger jobs, read descriptors with `to_ray()`, then fetch and process BLOB
diff --git a/docs/docs/pypaimon/pytorch.md b/docs/docs/pypaimon/pytorch.md
index af9189bbae..33dcb6f3c3 100644
--- a/docs/docs/pypaimon/pytorch.md
+++ b/docs/docs/pypaimon/pytorch.md
@@ -157,7 +157,55 @@ embedded frame ordinals keep frame mapping out of the
normal data file. Use
physical video ranges and cache decoder sessions per worker. See
[Multimodal API: Video Frame Storage](multimodal-api#video-frame-storage)
for the write path and a complete decoder example.
+## Contiguous Windows
+Use a map-style `ContiguousWindowDataset` when training samples are fixed-size
+windows which must not cross a sequence boundary. The dataset builds an index
+from only the group column, order column, and Paimon row IDs. Projected values,
+including BLOB payloads, are read from the pinned snapshot when a sample is
+requested; they are not retained in the index.
+
+```python
+from torch.utils.data import DataLoader
+
+dataset = (
+ frames.scan()
+ .to_contiguous_window_dataset(
+ window_size=16,
+ columns=["state", "image"],
+ anchor_columns=["image"],
+ group_key="episode_index",
+ order_key="frame_index",
+ tail="pad",
+ )
+)
+
+loader = DataLoader(dataset, batch_size=32, num_workers=4, shuffle=True)
+```
+
+Each item contains the group and order keys, one list for each requested
+column, and a boolean `is_pad` tensor where `True` marks padding. Padding
+repeats the final real value by default; `pad_values` can override individual
+columns. Columns named in `anchor_columns` contain only the first row's value,
+which is useful when an observation applies to a full action window. Use
+`column_transforms` to convert column lists to tensors and
+`adapter` to produce a model-specific sample mapping. Keep these callbacks
+picklable when using multiple DataLoader workers.
+
+Scheduled anchors start at row zero and advance by `stride` (default `1`).
+`tail="drop"` omits incomplete windows, `tail="pad"` includes and pads them,
+and `tail="error"` rejects a sequence with any scheduled incomplete window.
+Rows are sorted by `order_key` inside each `group_key` value. Order values must
+be integers which increase by exactly one; duplicates and missing steps are
+rejected, and windows never cross groups. The resolved Paimon
+snapshot is pinned for the lifetime of the dataset, so later commits cannot
+change its index or sample contents. A dataset pinned through `tag_name` fails
+its reads if the tag is moved to another snapshot, rather than mixing rows from
+the two snapshots.
+
+Columns configured by `video-frame-field` are rejected: a window read would
drop
+the `frame_index` and other metadata carried by their `VideoFrameDescriptor`
+values. Read those columns with `to_torch()` instead.
## File Format Metadata Cache
Reusable PyArrow Dataset metadata is cached across reads. Configure its
estimated
diff --git a/paimon-python/pypaimon/multimodal/query.py
b/paimon-python/pypaimon/multimodal/query.py
index d4491651d8..489ff64152 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -164,6 +164,66 @@ class ScanQuery:
max_buffer_input_splits=max_buffer_input_splits,
)
+ def to_contiguous_window_dataset(
+ self,
+ *,
+ window_size,
+ columns=None,
+ anchor_columns=None,
+ group_key="episode_index",
+ order_key="frame_index",
+ stride=1,
+ tail="drop",
+ column_transforms=None,
+ pad_values=None,
+ adapter=None,
+ blob_parallelism=64):
+ """Build a snapshot-pinned, map-style Dataset of contiguous rows.
+
+ The Dataset indexes only ``group_key``, ``order_key``, and Paimon row
+ IDs, then reads projected values on demand. Columns listed in
+ ``anchor_columns`` are provided to ``column_transforms`` as one-element
+ lists read from the first row of each window; ``adapter`` receives the
+ transformed values. ``order_key`` must contain non-null integers that
+ increase by exactly one within each group. The Dataset sorts rows
within
+ each group and never creates a window across groups.
+
+ Args:
+ window_size: Number of rows in a complete window.
+ columns: Value columns to return, excluding the group and order
+ keys. The scan projection is used when omitted.
+ anchor_columns: Subset of ``columns`` read only from the window's
+ first row.
+ group_key: Column identifying an independent row sequence.
+ order_key: Integer position column within each group.
+ stride: Distance between scheduled window starts.
+ tail: Handling for incomplete final windows: ``drop``, ``pad``, or
+ ``error``.
+ column_transforms: Per-column callables applied to value lists.
+ pad_values: Optional replacement values used by ``tail='pad'``.
+ adapter: Callable that converts the complete sample mapping.
+ blob_parallelism: Maximum concurrent BLOB body reads per fetch.
+
+ Returns:
+ A snapshot-pinned ``ContiguousWindowDataset``. See that class for
+ padding, mask, transform, and adapter result semantics.
+ """
+ from pypaimon.multimodal.window_dataset import ContiguousWindowDataset
+ return ContiguousWindowDataset(
+ self,
+ window_size=window_size,
+ columns=columns,
+ anchor_columns=anchor_columns,
+ group_key=group_key,
+ order_key=order_key,
+ stride=stride,
+ tail=tail,
+ column_transforms=column_transforms,
+ pad_values=pad_values,
+ adapter=adapter,
+ blob_parallelism=blob_parallelism,
+ )
+
def to_ray(
self,
*,
@@ -418,6 +478,12 @@ class _PreFilterQuery(ScanQuery):
"not search queries."
)
+ def to_contiguous_window_dataset(self, *args, **kwargs):
+ raise TypeError(
+ "to_contiguous_window_dataset is only supported on scan(), "
+ "not search queries."
+ )
+
class VectorQuery(_PreFilterQuery):
"""Chainable query wrapper for vector global-index search."""
diff --git a/paimon-python/pypaimon/multimodal/window_dataset.py
b/paimon-python/pypaimon/multimodal/window_dataset.py
new file mode 100644
index 0000000000..2a63845962
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/window_dataset.py
@@ -0,0 +1,716 @@
+# 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.
+
+"""Snapshot-pinned PyTorch Dataset for contiguous Paimon row windows."""
+
+import copy
+import operator
+
+import numpy as np
+import pyarrow as pa
+import pyarrow.compute as pc
+import torch
+from torch.utils.data import Dataset
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.multimodal.blob_read import fetch_blob_bodies
+from pypaimon.multimodal.query import ScanQuery, _PreFilterQuery
+from pypaimon.read.datasource.torch_dataset import (
+ SplitRangeIndex,
+ row_ranges_for_split,
+ select_indexed_splits,
+)
+from pypaimon.read.query_auth_split import QueryAuthSplit
+from pypaimon.schema.data_types import is_blob_type, is_map_blob_type
+from pypaimon.snapshot.time_travel_util import SCAN_KEYS
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.utils.range import Range
+
+
+class ContiguousWindowDataset(Dataset):
+ """Map-style Dataset which reads fixed row windows on demand.
+
+ The in-memory index contains only group values, order bounds, and Paimon
+ row IDs, stored in Arrow and NumPy arrays. Each ``__getitem__`` reads the
+ projected rows from the snapshot resolved while the index was built,
reusing
+ that snapshot's authorized scan plan instead of planning again. Within each
+ group, ``order_key`` must contain non-null integers that increase by
exactly
+ one; rows from different groups never share a window. ``tail`` controls
+ scheduled anchors whose remaining rows are shorter than ``window_size``:
+
+ * ``drop`` omits them;
+ * ``pad`` repeats final values and marks repeats in ``is_pad``;
+ * ``error`` rejects the dataset.
+
+ The raw result mapping contains scalar group and order values, a
+ length-``window_size`` Boolean ``is_pad`` tensor, one-element lists for
+ ``anchor_columns``, and length-``window_size`` lists for other projected
+ columns. ``anchor_columns`` therefore avoids loading repeated context such
+ as observation images or initial robot state. ``column_transforms`` then
+ convert individual column lists before ``adapter`` adapts the complete
+ mapping to a model-specific contract.
+ ``blob_parallelism`` controls concurrent BLOB reads for each item or batch.
+ Video frame columns are not supported yet, because a window read would drop
+ the frame metadata carried by their descriptors.
+ """
+
+ _TAIL_POLICIES = ("drop", "pad", "error")
+
+ def __init__(
+ self,
+ query,
+ *,
+ window_size,
+ columns=None,
+ anchor_columns=None,
+ group_key="episode_index",
+ order_key="frame_index",
+ stride=1,
+ tail="drop",
+ column_transforms=None,
+ pad_values=None,
+ adapter=None,
+ blob_parallelism=64):
+ if not isinstance(query, ScanQuery) or isinstance(query,
_PreFilterQuery):
+ raise TypeError(
+ "ContiguousWindowDataset is only supported on scan(), "
+ "not search queries.")
+ self.window_size = _positive_int(window_size, "window_size")
+ self.stride = _positive_int(stride, "stride")
+ if tail not in self._TAIL_POLICIES:
+ raise ValueError(
+ "tail must be one of %s; got %r."
+ % (self._TAIL_POLICIES, tail))
+ self.tail = tail
+ self.group_key = _column(query, group_key, "group_key")
+ self.order_key = _column(query, order_key, "order_key")
+ if self.group_key == self.order_key:
+ raise ValueError("group_key and order_key must name different
columns.")
+ if "is_pad" in (self.group_key, self.order_key):
+ raise ValueError("group_key and order_key must not be is_pad.")
+ self.columns = _columns(
+ query, columns, self.group_key, self.order_key)
+ _reject_video_columns(query._table, self.columns)
+ self.anchor_columns = _anchor_columns(anchor_columns, self.columns)
+ anchor_column_set = set(self.anchor_columns)
+ self._window_columns = [
+ name for name in self.columns if name not in anchor_column_set
+ ]
+ self.column_transforms = _column_transforms(
+ column_transforms, self.columns)
+ self.pad_values = _pad_values(pad_values, self.columns)
+ if adapter is not None and not callable(adapter):
+ raise TypeError("adapter must be callable or None.")
+ self.adapter = adapter
+ self.blob_parallelism = _positive_int(
+ blob_parallelism, "blob_parallelism")
+
+ if not query._table.options.row_tracking_enabled():
+ raise ValueError(
+ "ContiguousWindowDataset requires row-tracking.enabled=true.")
+
+ index, snapshot_id = _read_window_index(
+ query, self.group_key, self.order_key)
+ self.snapshot_id = snapshot_id
+ self._table = _pin_table(query._table, snapshot_id)
+ self._plans = {}
+ self._build_index(index)
+
+ @classmethod
+ def from_query(cls, query, **kwargs):
+ """Build a contiguous-window Dataset from a ``ScanQuery``."""
+ return cls(query, **kwargs)
+
+ def __len__(self):
+ return int(self._anchor_groups.size)
+
+ def __getitem__(self, index):
+ """Read one window by map-style Dataset index.
+
+ Negative indices follow Python sequence semantics. The return value is
+ the pre-adapter mapping described by the class, or the adapter result
+ when an adapter is configured.
+ """
+ anchor, row_ids = self._resolve_window(index)
+ rows = self._read_window_rows(row_ids)
+ anchor_row = (
+ self._read_rows(row_ids[:1], self.anchor_columns)[0]
+ if self.anchor_columns else None
+ )
+ return self._sample(anchor, rows, anchor_row)
+
+ def __getitems__(self, indices):
+ """Read several Dataset indices while coalescing overlapping row IDs.
+
+ The returned list preserves the requested index order and duplicates.
+ Coalescing affects only physical reads, not logical sample cardinality.
+ """
+ windows = [self._resolve_window(index) for index in indices]
+ if not windows:
+ return []
+ row_ids = list(dict.fromkeys(
+ row_id for _, window_row_ids in windows
+ for row_id in window_row_ids
+ ))
+ rows_by_id = dict(zip(row_ids, self._read_window_rows(row_ids)))
+ anchor_row_ids = list(dict.fromkeys(
+ window_row_ids[0] for _, window_row_ids in windows
+ ))
+ anchor_rows_by_id = (
+ dict(zip(
+ anchor_row_ids,
+ self._read_rows(anchor_row_ids, self.anchor_columns),
+ ))
+ if self.anchor_columns else {}
+ )
+ return [
+ self._sample(
+ anchor,
+ [rows_by_id[row_id] for row_id in window_row_ids],
+ anchor_rows_by_id.get(window_row_ids[0]),
+ )
+ for anchor, window_row_ids in windows
+ ]
+
+ def __getstate__(self):
+ # Cached plans hold planning state of one process; each DataLoader
+ # worker plans the pinned snapshot once for itself.
+ state = self.__dict__.copy()
+ state["_plans"] = {}
+ return state
+
+ def _resolve_window(self, index):
+ index = operator.index(index)
+ if index < 0:
+ index += len(self)
+ if index < 0 or index >= len(self):
+ raise IndexError("window index out of range")
+
+ group_index = int(self._anchor_groups[index])
+ start = int(self._anchor_starts[index])
+ valid_count = min(
+ self.window_size, int(self._group_lengths[group_index]) - start)
+ offset = int(self._group_starts[group_index]) + start
+ row_ids = self._row_ids[offset:offset + valid_count].tolist()
+ return (group_index, start, valid_count), row_ids
+
+ def _sample(self, anchor, rows, anchor_row=None):
+ group_index, start, valid_count = anchor
+ padding_count = self.window_size - valid_count
+ padding_mask = torch.zeros(self.window_size, dtype=torch.bool)
+ if padding_count:
+ padding_mask[valid_count:] = True
+ sample = {
+ self.group_key: self._group_keys[group_index],
+ self.order_key: int(self._group_first_orders[group_index]) + start,
+ "is_pad": padding_mask,
+ }
+ for name in self.columns:
+ if name in self.anchor_columns:
+ values = [copy.deepcopy(anchor_row[name])]
+ else:
+ values = [copy.deepcopy(row[name]) for row in rows]
+ if padding_count and name not in self.anchor_columns:
+ pad_value = self.pad_values.get(name, values[-1])
+ values.extend(
+ copy.deepcopy(pad_value) for _ in range(padding_count))
+ transform = self.column_transforms.get(name)
+ sample[name] = transform(values) if transform is not None else
values
+ if self.adapter is not None:
+ return self.adapter(sample)
+ return sample
+
+ def _build_index(self, index):
+ """Validate index rows, then store row IDs, groups, and window anchors.
+
+ Args:
+ index: Arrow table containing ``group_key``, ``order_key``, and
+ Paimon's ``_ROW_ID`` for the resolved snapshot.
+
+ The index is kept as NumPy arrays of row IDs, per-group offsets, and
+ window anchors, plus one Python group value per group. Order values are
+ not stored per row: contiguity makes them the group's first value plus
+ the offset inside the group.
+ """
+ group_column = index.column(self.group_key)
+ order_column = index.column(self.order_key)
+ row_id_column = index.column(SpecialFields.ROW_ID.name)
+ if row_id_column.null_count:
+ raise ValueError(
+ "ContiguousWindowDataset requires readable Paimon row IDs, "
+ "but %s contains null values." % SpecialFields.ROW_ID.name)
+ if group_column.null_count:
+ raise ValueError(
+ "%s must not contain null values." % self.group_key)
+ if order_column.null_count:
+ raise ValueError(
+ "%s must not contain null values." % self.order_key)
+ if not pa.types.is_integer(order_column.type):
+ raise ValueError(
+ "%s must contain integer values." % self.order_key)
+ if pa.types.is_floating(group_column.type) and pc.any(
+ pc.is_nan(group_column)).as_py():
+ raise ValueError(
+ "%s must not contain NaN values, which never compare equal to "
+ "themselves and would split one group." % self.group_key)
+
+ try:
+ ordered = index.sort_by([
+ (self.group_key, "ascending"),
+ (self.order_key, "ascending"),
+ ])
+ except pa.ArrowNotImplementedError:
+ raise ValueError(
+ "%s values must be mutually orderable." % self.group_key)
+
+ group_values = _contiguous_array(ordered.column(self.group_key))
+ order_values = _integer_numpy(
+ _contiguous_array(ordered.column(self.order_key)))
+ self._row_ids = _integer_numpy(
+ _contiguous_array(ordered.column(SpecialFields.ROW_ID.name)))
+ self._group_starts = _group_starts(group_values)
+ self._group_lengths = np.diff(
+ np.append(self._group_starts, len(self._row_ids)))
+ self._group_keys = group_values.take(
+ pa.array(self._group_starts, type=pa.int64())).to_pylist()
+ self._group_first_orders = order_values[self._group_starts]
+ self._validate_contiguity(order_values)
+ self._anchor_groups, self._anchor_starts = self._build_anchors()
+
+ def _validate_contiguity(self, order_values):
+ if len(order_values) < 2:
+ return
+ same_group = np.ones(len(order_values) - 1, dtype=bool)
+ same_group[self._group_starts[1:] - 1] = False
+ steps = np.diff(order_values)
+ duplicate = same_group & (steps == 0)
+ if duplicate.any():
+ position = int(np.flatnonzero(duplicate)[0])
+ raise ValueError(
+ "Group %s has duplicate order value %r in %s."
+ % (self._group_of(position),
+ int(order_values[position]), self.order_key))
+ broken = same_group & (steps != 1)
+ if broken.any():
+ position = int(np.flatnonzero(broken)[0])
+ raise ValueError(
+ "Group %s is not contiguous in %s: %s followed by %s."
+ % (self._group_of(position), self.order_key,
+ int(order_values[position]), int(order_values[position +
1])))
+
+ def _group_of(self, position):
+ group_index = int(np.searchsorted(
+ self._group_starts, position, side="right")) - 1
+ return self._group_keys[group_index]
+
+ def _build_anchors(self):
+ groups = []
+ starts = []
+ for group_index, length in enumerate(self._group_lengths):
+ length = int(length)
+ positions = np.arange(0, length, self.stride, dtype=np.int64)
+ valid_counts = np.minimum(self.window_size, length - positions)
+ incomplete = np.flatnonzero(valid_counts < self.window_size)
+ if incomplete.size:
+ if self.tail == "error":
+ first = int(incomplete[0])
+ raise ValueError(
+ "Group %s has an incomplete window at %s: "
+ "window_size=%d, available=%d."
+ % (self._group_keys[group_index],
+ int(self._group_first_orders[group_index])
+ + int(positions[first]),
+ self.window_size,
+ int(valid_counts[first])))
+ if self.tail == "drop":
+ positions = positions[valid_counts == self.window_size]
+ if positions.size:
+ groups.append(np.full(positions.size, group_index,
dtype=np.int64))
+ starts.append(positions)
+ if not groups:
+ empty = np.zeros(0, dtype=np.int64)
+ return empty, empty.copy()
+ return np.concatenate(groups), np.concatenate(starts)
+
+ def _read_window_rows(self, row_ids):
+ if not self._window_columns:
+ return [{} for _ in row_ids]
+ return self._read_rows(row_ids, self._window_columns)
+
+ def _read_rows(self, row_ids, columns=None):
+ """Read projected rows by ID from the pinned snapshot.
+
+ Args:
+ row_ids: Paimon row IDs to read. Their order and duplicates define
+ the returned row order.
+ columns: Projected value columns, or all Dataset columns when
+ omitted.
+
+ Returns:
+ A list of row dictionaries aligned one-for-one with ``row_ids``.
+ The internal ``_ROW_ID`` field is removed, and BLOB descriptors are
+ resolved to their bodies.
+ """
+ columns = self.columns if columns is None else columns
+ rows = self._plan_for(columns).read(row_ids)
+ row_id_column = SpecialFields.ROW_ID.name
+ by_row_id = {}
+ for row in rows:
+ by_row_id[int(row.pop(row_id_column))] = row
+ missing = [row_id for row_id in row_ids if row_id not in by_row_id]
+ if missing:
+ raise RuntimeError(
+ "Pinned snapshot %s did not return indexed row IDs %s."
+ % (self.snapshot_id, missing))
+ return [by_row_id[row_id] for row_id in row_ids]
+
+ def _plan_for(self, columns):
+ key = tuple(columns)
+ plan = self._plans.get(key)
+ if plan is None:
+ plan = _PinnedRowIdPlan(
+ self._table, columns, self.blob_parallelism, self.snapshot_id)
+ self._plans[key] = plan
+ return plan
+
+
+class _PinnedRowIdPlan:
+ """One authorized scan plan of the pinned snapshot, read by row ID.
+
+ Planning happens once per projection. Every read then narrows the cached
+ splits to the row ranges covering the requested row IDs, so repeated item
+ and batch reads never revisit the snapshot's manifests.
+ """
+
+ def __init__(self, table, columns, blob_parallelism, snapshot_id):
+ self._blob_parallelism = blob_parallelism
+ self._snapshot_id = snapshot_id
+ self._blob_columns = [
+ field.name for field in table.fields
+ if field.name in columns
+ and (is_blob_type(field.type) or is_map_blob_type(field.type))
+ ]
+ self._map_blob_columns = {
+ field.name for field in table.fields
+ if field.name in self._blob_columns and
is_map_blob_type(field.type)
+ }
+ blob_column_set = set(self._blob_columns)
+ self._projection = (
+ [name for name in columns if name not in blob_column_set]
+ + [SpecialFields.ROW_ID.name]
+ + self._blob_columns
+ )
+ self._table = (
+ table.copy({CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"})
+ if self._blob_columns else table
+ )
+ plan = self._new_read_builder().new_scan().plan()
+ # A tag can be replaced between indexing and this deferred plan, so the
+ # plan is only usable while it still resolves the indexed snapshot.
+ if snapshot_id is not None and plan.snapshot_id != snapshot_id:
+ raise RuntimeError(
+ "The window index was built from snapshot %s, but reading its "
+ "rows now resolves snapshot %s; the pinned snapshot moved."
+ % (snapshot_id, plan.snapshot_id))
+ self._splits = plan.splits()
+ _reject_masked_row_ids(self._splits)
+ self._split_ranges = [
+ row_ranges_for_split(split) for split in self._splits
+ ]
+ self._split_range_index = SplitRangeIndex(self._split_ranges)
+
+ def read(self, row_ids):
+ """Return raw row dictionaries, including ``_ROW_ID``, for
``row_ids``."""
+ requested = list(dict.fromkeys(row_ids))
+ ranges = Range.to_ranges(requested)
+ splits = select_indexed_splits(
+ self._splits, self._split_ranges, self._split_range_index, ranges)
+ if not splits:
+ return []
+ read_builder = self._new_read_builder()
+ predicate = read_builder.new_predicate_builder().is_in(
+ SpecialFields.ROW_ID.name, requested)
+ arrow = read_builder.with_filter(predicate).new_read().to_arrow(splits)
+ # Row ranges are merged per split, so a read can still return rows
+ # between requested IDs; drop them before BLOB bodies are fetched.
+ row_id_column = arrow.column(SpecialFields.ROW_ID.name)
+ arrow = arrow.filter(pc.is_in(
+ row_id_column,
+ value_set=pa.array(requested, type=row_id_column.type)))
+ if not self._blob_columns:
+ return arrow.to_pylist()
+
+ bodies = fetch_blob_bodies(
+ self._table.file_io,
+ arrow.select(self._blob_columns).to_pydict(),
+ self._blob_columns,
+ self._blob_parallelism,
+ self._map_blob_columns)
+ blob_column_set = set(self._blob_columns)
+ rows = arrow.select([
+ name for name in arrow.column_names if name not in blob_column_set
+ ]).to_pylist()
+ for name in self._blob_columns:
+ values = bodies[name]
+ if len(values) != len(rows):
+ raise RuntimeError(
+ "BLOB column %s is not row-aligned with a window read."
+ % name)
+ for row, value in zip(rows, values):
+ row[name] = value
+ return rows
+
+ def _new_read_builder(self):
+ return self._table.new_read_builder().with_projection(self._projection)
+
+
+def _masked_columns(split):
+ if not isinstance(split, QueryAuthSplit):
+ return ()
+ masking = getattr(split.auth_result, "column_masking", None)
+ return tuple(masking) if masking else ()
+
+
+def _reject_masked_row_ids(splits):
+ for split in splits:
+ if SpecialFields.ROW_ID.name in _masked_columns(split):
+ raise ValueError(
+ "ContiguousWindowDataset requires readable Paimon row IDs, but
"
+ "query authorization masks %s for this table; read the rows "
+ "with scan().to_arrow() instead."
+ % SpecialFields.ROW_ID.name)
+
+
+def _reject_masked_index_keys(splits, group_key, order_key):
+ for split in splits:
+ masked = _masked_columns(split)
+ keys = [name for name in (group_key, order_key) if name in masked]
+ if keys:
+ raise ValueError(
+ "ContiguousWindowDataset groups and orders rows by %s, but "
+ "query authorization masks %s for this table; a mask can merge
"
+ "distinct groups or break contiguity, so its windows would no "
+ "longer follow the stored rows."
+ % ([group_key, order_key], keys))
+
+
+def _contiguous_array(column):
+ combined = column.combine_chunks()
+ if not isinstance(combined, pa.ChunkedArray):
+ return combined
+ if combined.num_chunks == 1:
+ return combined.chunk(0)
+ if not combined.num_chunks:
+ return pa.nulls(0, type=combined.type)
+ return pa.concat_arrays(list(combined.iterchunks()))
+
+
+def _integer_numpy(array):
+ # Cast narrow widths so differences of adjacent values cannot overflow.
+ if not pa.types.is_uint64(array.type):
+ array = array.cast(pa.int64())
+ return array.to_numpy(zero_copy_only=False)
+
+
+def _group_starts(group_values):
+ count = len(group_values)
+ if not count:
+ return np.zeros(0, dtype=np.int64)
+ changed = pc.not_equal(
+ group_values.slice(1), group_values.slice(0, count - 1))
+ return np.concatenate((
+ [0],
+ np.flatnonzero(changed.to_numpy(zero_copy_only=False)) + 1,
+ )).astype(np.int64, copy=False)
+
+
+def _read_window_index(query, group_key, order_key):
+ index_query = copy.copy(query)
+ index_query._projection = [group_key, order_key]
+ index_query._include_row_id = True
+ read_builder = index_query._configured_read_builder()
+ plan = read_builder.new_scan().plan()
+ splits = plan.splits()
+ _reject_masked_row_ids(splits)
+ _reject_masked_index_keys(splits, group_key, order_key)
+ index = read_builder.new_read().to_arrow(splits)
+ if index.num_rows and plan.snapshot_id is None:
+ raise RuntimeError("Cannot pin the snapshot used to build the window
index.")
+ return index, plan.snapshot_id
+
+
+def _pin_table(table, snapshot_id):
+ """Pin a table copy to ``snapshot_id``, or reuse it when unresolved.
+
+ A scan already pinned by ``scan.tag-name`` keeps that tag: the tag retains
+ its snapshot's metadata after the main snapshot file expires, which reading
+ by raw snapshot ID cannot.
+ """
+ if snapshot_id is None:
+ return table
+ tag_name = table.options.scan_tag_name()
+ if tag_name is not None:
+ _require_tag_snapshot(table, tag_name, snapshot_id)
+ scan_keys = set(SCAN_KEYS)
+ scan_keys.update(option.key() for option in (
+ CoreOptions.SCAN_MODE,
+ CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
+ CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
+ CoreOptions.SCAN_CREATION_TIME_MILLIS,
+ ))
+ if tag_name is not None:
+ scan_keys.discard(CoreOptions.SCAN_TAG_NAME.key())
+ options = {
+ key: None for key in scan_keys
+ if table.options.options.contains_key(key)
+ }
+ if tag_name is None:
+ options[CoreOptions.SCAN_SNAPSHOT_ID.key()] = str(snapshot_id)
+ if not options:
+ return table
+ return table.copy(options)
+
+
+def _require_tag_snapshot(table, tag_name, snapshot_id):
+ tag = table.tag_manager().get(tag_name)
+ if tag is None:
+ raise RuntimeError(
+ "Tag %r used to build the window index no longer exists." %
tag_name)
+ resolved = tag.trim_to_snapshot().id
+ if resolved != snapshot_id:
+ raise RuntimeError(
+ "Tag %r now resolves to snapshot %s, but the window index was "
+ "built from snapshot %s." % (tag_name, resolved, snapshot_id))
+
+
+def _reject_video_columns(table, columns):
+ video_columns = [
+ name for name in columns if name in table.options.video_frame_fields()
+ ]
+ if video_columns:
+ raise ValueError(
+ "ContiguousWindowDataset does not support video frame columns %s "
+ "yet: a window read would drop their frame metadata, such as "
+ "frame_index." % video_columns)
+
+
+def _columns(query, columns, group_key, order_key):
+ available = {field.name for field in query._table.fields}
+ if columns is None:
+ if query._projection is None:
+ columns = [field.name for field in query._table.fields]
+ else:
+ columns = list(query._projection)
+ columns = [name for name in columns
+ if name not in (group_key, order_key)]
+ elif isinstance(columns, str):
+ columns = [columns]
+ else:
+ try:
+ columns = list(columns)
+ except TypeError:
+ raise TypeError(
+ "columns must be a non-empty sequence of column names.")
+ if not columns:
+ raise ValueError("columns must contain at least one value column.")
+ if any(not isinstance(name, str) or not name for name in columns):
+ raise TypeError("columns must contain only non-empty column names.")
+ if len(set(columns)) != len(columns):
+ raise ValueError("columns must not contain duplicates.")
+ invalid = [name for name in columns if name not in available]
+ if invalid:
+ raise ValueError("columns do not exist: %s." % invalid)
+ reserved = [name for name in columns
+ if name in (group_key, order_key, "is_pad")]
+ if reserved:
+ raise ValueError(
+ "columns must not include group_key, order_key, or is_pad: %s."
+ % reserved)
+ return columns
+
+
+def _anchor_columns(value, columns):
+ if value is None:
+ return []
+ if isinstance(value, str):
+ value = [value]
+ else:
+ try:
+ value = list(value)
+ except TypeError:
+ raise TypeError(
+ "anchor_columns must be a sequence of projected column names.")
+ if any(not isinstance(name, str) or not name for name in value):
+ raise TypeError(
+ "anchor_columns must contain only non-empty column names.")
+ if len(set(value)) != len(value):
+ raise ValueError("anchor_columns must not contain duplicates.")
+ invalid = [name for name in value if name not in columns]
+ if invalid:
+ raise ValueError(
+ "anchor_columns must be included in columns: %s." % invalid)
+ return value
+
+
+def _column_transforms(value, columns):
+ transforms = _mapping(value, "column_transforms")
+ _validate_mapping_columns(transforms, columns, "column_transforms")
+ invalid = [name for name, transform in transforms.items()
+ if not callable(transform)]
+ if invalid:
+ raise TypeError(
+ "column_transforms values must be callable: %s." % invalid)
+ return transforms
+
+
+def _pad_values(value, columns):
+ values = _mapping(value, "pad_values")
+ _validate_mapping_columns(values, columns, "pad_values")
+ return values
+
+
+def _mapping(value, name):
+ if value is None:
+ return {}
+ try:
+ return dict(value)
+ except (TypeError, ValueError):
+ raise TypeError("%s must be a mapping or None." % name)
+
+
+def _validate_mapping_columns(value, columns, name):
+ invalid = [column for column in value if column not in columns]
+ if invalid:
+ raise ValueError("%s contains unknown columns: %s." % (name, invalid))
+
+
+def _column(query, value, name):
+ if not isinstance(value, str) or not value:
+ raise TypeError("%s must be a non-empty column name." % name)
+ available = {field.name for field in query._table.fields}
+ if value not in available:
+ raise ValueError("%s column %r does not exist." % (name, value))
+ return value
+
+
+def _positive_int(value, name):
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ raise ValueError("%s must be a positive int." % name)
+ return value
+
+
+__all__ = ["ContiguousWindowDataset"]
diff --git a/paimon-python/pypaimon/read/datasource/torch_dataset.py
b/paimon-python/pypaimon/read/datasource/torch_dataset.py
index de4bb2cace..45c1286fe4 100644
--- a/paimon-python/pypaimon/read/datasource/torch_dataset.py
+++ b/paimon-python/pypaimon/read/datasource/torch_dataset.py
@@ -152,7 +152,7 @@ class _RowIdRangeIndex:
return row_ids
-class _SplitRangeIndex:
+class SplitRangeIndex:
def __init__(self, ranges_by_split):
self.intervals = sorted(
@@ -180,6 +180,56 @@ class _SplitRangeIndex:
return sorted(split_indices)
+def row_ranges_for_split(split) -> List[Range]:
+ """Return the merged global row-ID ranges covered by ``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 select_indexed_splits(
+ splits, split_ranges, split_range_index, ranges) -> List[Split]:
+ """Return ``IndexedSplit``s restricting ``splits`` to ``ranges``.
+
+ Row ranges reach the format readers through ``IndexedSplit``, which lets a
+ data-evolution read skip files and row blocks that no requested row ID
+ touches. Authorization wrappers are preserved.
+ """
+ selected = []
+ for split_index in split_range_index.find(ranges):
+ original = 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, 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 TorchDataset(Dataset):
"""
Map-style PyTorch Dataset for Paimon table data.
@@ -205,9 +255,9 @@ class TorchDataset(Dataset):
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
+ row_ranges_for_split(split) for split in splits
]
- self._split_range_index = _SplitRangeIndex(self._split_ranges)
+ 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(
@@ -274,20 +324,6 @@ class TorchDataset(Dataset):
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)
@@ -377,33 +413,8 @@ class TorchDataset(Dataset):
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
+ return select_indexed_splits(
+ self.splits, self._split_ranges, self._split_range_index, ranges)
class _BaseTorchIterDataset(IterableDataset):
diff --git a/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
b/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
new file mode 100644
index 0000000000..c3582ea7b5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
@@ -0,0 +1,664 @@
+# 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.
+
+import json
+import os
+import pickle
+import shutil
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import pyarrow as pa
+import torch
+
+import pypaimon.multimodal as pmm
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+from pypaimon.multimodal import window_dataset
+from pypaimon.multimodal.query import ScanQuery
+from pypaimon.multimodal.window_dataset import ContiguousWindowDataset
+from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
+from pypaimon.read.table_scan import TableScan
+
+
+_TABLE_OPTIONS = {
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ "deletion-vectors.enabled": "true",
+ "file.format": "parquet",
+ "vector.file.format": "parquet",
+}
+
+
+class _TensorColumnTransform:
+
+ def __call__(self, values):
+ return torch.tensor(values, dtype=torch.int64)
+
+
+class _WindowAdapter:
+
+ def __call__(self, sample):
+ return {
+ "episode": sample["episode"],
+ "start": sample["step"],
+ "values": sample["value"],
+ "padding_mask": sample["is_pad"],
+ }
+
+
+class ContiguousWindowDatasetTest(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp(prefix="pypaimon_windows_")
+ self.conn = pmm.connect(options={
+ "warehouse": os.path.join(self.temp_dir, "warehouse"),
+ })
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ @staticmethod
+ def _schema():
+ return pa.schema([
+ pa.field("episode", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("value", pa.int32(), nullable=False),
+ pa.field("payload", pa.large_binary(), nullable=False),
+ ])
+
+ @staticmethod
+ def _row(episode, step):
+ return {
+ "episode": episode,
+ "step": step,
+ "value": step + (100 if episode == "episode-b" else 0),
+ "payload": ("%s-%d" % (episode, step)).encode(),
+ }
+
+ def _table(self, name="frames"):
+ table = self.conn.create_table(
+ name, schema=self._schema(), options=_TABLE_OPTIONS)
+ table.add([
+ self._row("episode-b", 2),
+ self._row("episode-a", 1),
+ self._row("episode-b", 0),
+ self._row("episode-a", 0),
+ self._row("episode-b", 3),
+ self._row("episode-b", 1),
+ ])
+ return table
+
+ @staticmethod
+ def _dataset(table, **kwargs):
+ return (
+ table.scan()
+ .to_contiguous_window_dataset(
+ window_size=3,
+ columns=["value", "payload"],
+ group_key="episode",
+ order_key="step",
+ **kwargs,
+ )
+ )
+
+ def test_sorts_rows_and_never_crosses_episode_boundaries(self):
+ dataset = self._dataset(self._table())
+
+ self.assertIsInstance(dataset, torch.utils.data.Dataset)
+ self.assertEqual(2, len(dataset))
+ self.assertIsInstance(dataset.snapshot_id, int)
+ self.assertNotIn("_episodes", vars(dataset))
+
+ first = dataset[0]
+ second = dataset[1]
+ self.assertEqual("episode-b", first["episode"])
+ self.assertEqual(0, first["step"])
+ self.assertEqual([100, 101, 102], first["value"])
+ self.assertEqual([101, 102, 103], second["value"])
+ self.assertFalse(first["is_pad"].any())
+ self.assertEqual({"episode-b"}, {
+ window["episode"] for window in (first, second)
+ })
+
+ def test_reads_blob_payloads_only_when_a_window_is_requested(self):
+ table = self._table()
+ with patch(
+ "pypaimon.multimodal.window_dataset.fetch_blob_bodies",
+ side_effect=window_dataset.fetch_blob_bodies) as fetch:
+ dataset = self._dataset(table)
+ self.assertEqual(0, fetch.call_count)
+
+ sample = dataset[0]
+
+ self.assertEqual(1, fetch.call_count)
+ self.assertEqual(3, len(fetch.call_args.args[1]["payload"]))
+ self.assertEqual(
+ [b"episode-b-0", b"episode-b-1", b"episode-b-2"],
+ sample["payload"],
+ )
+
+ def test_reads_map_blob_payloads_for_map_only_and_mixed_windows(self):
+ schema = pa.schema([
+ pa.field("episode", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("payload", pa.large_binary()),
+ pa.field(
+ "attachments",
+ pa.map_(pa.string(), pa.large_binary()),
+ ),
+ ])
+ table = self.conn.create_table(
+ "map_blobs",
+ schema=schema,
+ options=_TABLE_OPTIONS,
+ )
+ table.add(pa.Table.from_pylist([
+ {
+ "episode": "episode-a",
+ "step": 0,
+ "payload": b"scalar-0",
+ "attachments": {
+ "body": b"map-0", "empty": b"", "null": None},
+ },
+ {
+ "episode": "episode-a",
+ "step": 1,
+ "payload": b"scalar-1",
+ "attachments": None,
+ },
+ ], schema=schema))
+
+ def window(columns):
+ return table.scan().to_contiguous_window_dataset(
+ window_size=2,
+ columns=columns,
+ group_key="episode",
+ order_key="step",
+ )[0]
+
+ map_only = window(["attachments"])
+ mixed = window(["payload", "attachments"])
+
+ self.assertEqual(
+ {"body": b"map-0", "empty": b"", "null": None},
+ dict(map_only["attachments"][0]),
+ )
+ self.assertIsNone(map_only["attachments"][1])
+ self.assertEqual([b"scalar-0", b"scalar-1"], mixed["payload"])
+ self.assertEqual(map_only["attachments"], mixed["attachments"])
+
+ def test_anchor_columns_read_only_the_window_anchor(self):
+ table = self._table()
+ with patch(
+ "pypaimon.multimodal.window_dataset.fetch_blob_bodies",
+ side_effect=window_dataset.fetch_blob_bodies) as fetch:
+ dataset = self._dataset(table, anchor_columns=["payload"])
+
+ sample = dataset[0]
+
+ self.assertEqual([100, 101, 102], sample["value"])
+ self.assertEqual([b"episode-b-0"], sample["payload"])
+ self.assertEqual(1, fetch.call_count)
+ self.assertEqual(1, len(fetch.call_args.args[1]["payload"]))
+
+ def test_plural_access_coalesces_overlapping_window_reads(self):
+ dataset = self._dataset(
+ self._table(), anchor_columns=["payload"])
+
+ with patch.object(
+ dataset, "_read_rows", wraps=dataset._read_rows) as read:
+ actual = dataset.__getitems__([1, 0, 1])
+
+ self.assertEqual(2, read.call_count)
+ self.assertEqual(4, len(read.call_args_list[0].args[0]))
+ self.assertEqual(["value"], read.call_args_list[0].args[1])
+ self.assertEqual(2, len(read.call_args_list[1].args[0]))
+ self.assertEqual(["payload"], read.call_args_list[1].args[1])
+ self.assertEqual(
+ [("episode-b", 1), ("episode-b", 0), ("episode-b", 1)],
+ [(sample["episode"], sample["step"]) for sample in actual],
+ )
+ self.assertEqual(
+ [[101, 102, 103], [100, 101, 102], [101, 102, 103]],
+ [sample["value"] for sample in actual],
+ )
+ self.assertEqual(
+ [[b"episode-b-1"], [b"episode-b-0"], [b"episode-b-1"]],
+ [sample["payload"] for sample in actual],
+ )
+
+ def test_plural_access_isolates_mutable_cells_between_samples(self):
+ table = self.conn.create_table(
+ "mutable_cells",
+ schema=pa.schema([
+ pa.field("episode", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("values", pa.list_(pa.int32()), nullable=False),
+ ]),
+ options=_TABLE_OPTIONS,
+ )
+ table.add([
+ {"episode": "episode-a", "step": step, "values": [step]}
+ for step in range(3)
+ ])
+
+ def mutate(values):
+ for value in values:
+ value.append(99)
+ return values
+
+ dataset = table.scan().to_contiguous_window_dataset(
+ window_size=2,
+ columns=["values"],
+ group_key="episode",
+ order_key="step",
+ column_transforms={"values": mutate},
+ )
+
+ batched = dataset.__getitems__([0, 1, 0])
+ singles = [dataset[index] for index in (0, 1, 0)]
+
+ self.assertEqual(
+ [sample["values"] for sample in singles],
+ [sample["values"] for sample in batched],
+ )
+
+ def test_pad_tail_repeats_last_row_and_marks_real_padding(self):
+ dataset = self._dataset(
+ self._table(), tail="pad", pad_values={"value": -1})
+
+ self.assertEqual(6, len(dataset))
+ short_tail = dataset[1]
+ long_tail = dataset[-1]
+ self.assertEqual("episode-a", short_tail["episode"])
+ self.assertEqual([1, -1, -1], short_tail["value"])
+ self.assertEqual(
+ [b"episode-a-1"] * 3, short_tail["payload"])
+ self.assertEqual([False, True, True], short_tail["is_pad"].tolist())
+ self.assertEqual("episode-b", long_tail["episode"])
+ self.assertEqual([103, -1, -1], long_tail["value"])
+ self.assertEqual([False, True, True], long_tail["is_pad"].tolist())
+
+ def test_error_tail_rejects_an_incomplete_scheduled_window(self):
+ with self.assertRaisesRegex(
+ ValueError, "episode-a.*incomplete.*window_size=3"):
+ self._dataset(self._table(), tail="error")
+
+ def test_stride_controls_scheduled_window_anchors(self):
+ dataset = self._dataset(self._table(), stride=2, tail="pad")
+
+ self.assertEqual(
+ [("episode-a", 0), ("episode-b", 0), ("episode-b", 2)],
+ [(dataset[index]["episode"], dataset[index]["step"])
+ for index in range(len(dataset))],
+ )
+ self.assertEqual(
+ [False, False, True], dataset[-1]["is_pad"].tolist())
+
+ def test_rejects_missing_and_duplicate_order_keys_within_a_group(self):
+ gapped = self.conn.create_table(
+ "gapped", schema=self._schema(), options=_TABLE_OPTIONS)
+ gapped.add([
+ self._row("episode-a", 0),
+ self._row("episode-a", 2),
+ ])
+
+ with self.assertRaisesRegex(
+ ValueError, "episode-a.*not contiguous.*0.*2"):
+ self._dataset(gapped)
+
+ table = self.conn.create_table(
+ "duplicates", schema=self._schema(), options=_TABLE_OPTIONS)
+ table.add([
+ self._row("episode-a", 0),
+ self._row("episode-a", 0),
+ self._row("episode-a", 1),
+ ])
+
+ with self.assertRaisesRegex(
+ ValueError, "episode-a.*duplicate.*order.*0"):
+ self._dataset(table)
+
+ def test_pins_snapshot_for_later_on_demand_reads(self):
+ table = self._table()
+ dataset = self._dataset(table)
+ snapshot_id = dataset.snapshot_id
+
+ table.add([self._row("episode-b", 4)])
+
+ self.assertEqual(snapshot_id, dataset.snapshot_id)
+ self.assertNotEqual(
+ snapshot_id,
table.raw_table.snapshot_manager().get_latest_snapshot().id)
+ self.assertEqual(2, len(dataset))
+ self.assertEqual([101, 102, 103], dataset[-1]["value"])
+
+ def test_snapshot_pin_clears_scan_mode_before_on_demand_reads(self):
+ table = self._table()
+ query = ScanQuery(table.raw_table.copy({"scan.mode": "latest-full"}))
+
+ dataset = query.to_contiguous_window_dataset(
+ window_size=3,
+ columns=["value", "payload"],
+ group_key="episode",
+ order_key="step",
+ )
+
+ self.assertEqual([100, 101, 102], dataset[0]["value"])
+
+ def test_pickle_round_trip_preserves_snapshot_and_window(self):
+ dataset = self._dataset(
+ self._table(), anchor_columns=["payload"])
+ expected = dataset[-1]
+
+ restored = pickle.loads(pickle.dumps(dataset))
+
+ self.assertEqual(dataset.snapshot_id, restored.snapshot_id)
+ self.assertEqual(
+ dataset.snapshot_id,
+ restored._table.options.scan_snapshot_id(),
+ )
+ actual = restored[-1]
+ self.assertEqual(expected["episode"], actual["episode"])
+ self.assertEqual(expected["step"], actual["step"])
+ self.assertEqual(expected["value"], actual["value"])
+ self.assertEqual(expected["payload"], actual["payload"])
+ self.assertTrue(torch.equal(expected["is_pad"], actual["is_pad"]))
+
+ def test_projection_filter_transform_and_dataloader_workers(self):
+ table = self._table()
+ dataset = (
+ table.scan()
+ .where("episode = 'episode-b'")
+ .select(["value"])
+ .to_contiguous_window_dataset(
+ window_size=2,
+ group_key="episode",
+ order_key="step",
+ column_transforms={"value": _TensorColumnTransform()},
+ adapter=_WindowAdapter(),
+ )
+ )
+
+ loader = torch.utils.data.DataLoader(
+ dataset, batch_size=2, shuffle=False, num_workers=2)
+ batches = list(loader)
+
+ self.assertEqual(2, len(batches))
+ self.assertEqual(torch.int64, batches[0]["values"].dtype)
+ self.assertEqual((2, 2), tuple(batches[0]["values"].shape))
+ self.assertEqual(torch.bool, batches[0]["padding_mask"].dtype)
+ self.assertEqual([0, 1, 2], [
+ start for batch in batches for start in batch["start"].tolist()
+ ])
+ self.assertEqual(
+ [[100, 101], [101, 102], [102, 103]],
+ [values for batch in batches for values in
batch["values"].tolist()],
+ )
+ self.assertTrue(all(
+ episode == "episode-b"
+ for batch in batches for episode in batch["episode"]
+ ))
+
+ def test_default_keys_and_public_from_query_entry_point(self):
+ table = self.conn.create_table(
+ "default_keys",
+ schema=pa.schema([
+ pa.field("episode_index", pa.string(), nullable=False),
+ pa.field("frame_index", pa.int32(), nullable=False),
+ pa.field("value", pa.int32(), nullable=False),
+ ]),
+ options=_TABLE_OPTIONS,
+ )
+ table.add([
+ {"episode_index": "episode-a", "frame_index": 0, "value": 10},
+ {"episode_index": "episode-a", "frame_index": 1, "value": 11},
+ ])
+
+ dataset = ContiguousWindowDataset.from_query(
+ table.scan().select(["value"]), window_size=2)
+
+ self.assertEqual(1, len(dataset))
+ self.assertEqual("episode-a", dataset[0]["episode_index"])
+ self.assertEqual(0, dataset[0]["frame_index"])
+ self.assertEqual([10, 11], dataset[0]["value"])
+
+ def test_rejects_scan_and_batch_vector_search_queries(self):
+ table = self.conn.create_table(
+ "vectors",
+ schema=pa.schema([
+ pa.field("episode", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("embedding", pa.list_(pa.float32(), 2)),
+ ]),
+ options=_TABLE_OPTIONS,
+ )
+ table.add([
+ {"episode": "episode-a", "step": 0, "embedding": [1.0, 0.0]},
+ {"episode": "episode-a", "step": 1, "embedding": [0.0, 1.0]},
+ ])
+ kwargs = {
+ "window_size": 2,
+ "columns": ["embedding"],
+ "group_key": "episode",
+ "order_key": "step",
+ }
+
+ for query in (table.search([1.0, 0.0]),
+ table.search_vectors([[1.0, 0.0]])):
+ with self.subTest(query=type(query).__name__):
+ with self.assertRaisesRegex(TypeError, "only supported on
scan"):
+ query.to_contiguous_window_dataset(**kwargs)
+ with self.assertRaisesRegex(TypeError, "only supported on
scan"):
+ ContiguousWindowDataset.from_query(query, **kwargs)
+
+ def test_reads_a_pinned_tag_after_its_snapshot_file_is_removed(self):
+ table = self._table()
+ table.raw_table.create_tag("v1")
+
+ dataset = table.scan(tag_name="v1").to_contiguous_window_dataset(
+ window_size=3, columns=["value"],
+ group_key="episode", order_key="step")
+
+ raw_table = table.raw_table
+ raw_table.file_io.delete_quietly(
+
raw_table.snapshot_manager().get_snapshot_path(dataset.snapshot_id))
+
+ self.assertEqual("v1", dataset._table.options.scan_tag_name())
+ self.assertIsNone(dataset._table.options.scan_snapshot_id())
+ self.assertEqual([100, 101, 102], dataset[0]["value"])
+
+ def test_plans_the_pinned_snapshot_once_per_projection(self):
+ dataset = self._dataset(self._table(), anchor_columns=["payload"])
+
+ with patch.object(
+ TableScan, "plan", autospec=True,
+ side_effect=TableScan.plan) as plan:
+ samples = [dataset[index] for index in range(len(dataset))]
+ samples.extend(dataset.__getitems__(list(range(len(dataset)))))
+
+ self.assertEqual(2, plan.call_count)
+ self.assertEqual(
+ [[100, 101, 102], [101, 102, 103]] * 2,
+ [sample["value"] for sample in samples])
+ self.assertEqual(
+ [[b"episode-b-0"], [b"episode-b-1"]] * 2,
+ [sample["payload"] for sample in samples])
+
+ def test_rejects_query_authorization_that_masks_row_ids(self):
+ table = self._table()
+ auth = TableQueryAuthResult(
+ filter=None,
+ column_masking={"_ROW_ID": json.dumps({"name": "NULL"})},
+ )
+ table.raw_table.catalog_environment.table_query_auth = (
+ lambda options, table_identifier: lambda select: auth)
+
+ with self.assertRaisesRegex(ValueError, "masks _ROW_ID"):
+ self._dataset(table)
+
+ def
test_rejects_reads_after_the_pinned_tag_moves_to_another_snapshot(self):
+ table = self._table()
+ raw_table = table.raw_table
+ raw_table.create_tag("v1")
+ dataset = table.scan(tag_name="v1").to_contiguous_window_dataset(
+ window_size=3, columns=["value"],
+ group_key="episode", order_key="step")
+ restored = pickle.loads(pickle.dumps(dataset))
+
+ table.add([self._row("episode-b", 4)])
+ raw_table.replace_tag("v1")
+
+ for pinned in (dataset, restored):
+ with self.assertRaisesRegex(RuntimeError, "pinned snapshot moved"):
+ pinned[0]
+
+ def test_rejects_query_authorization_that_masks_the_group_key(self):
+ table = self._table()
+ auth = TableQueryAuthResult(
+ filter=None,
+ column_masking={"episode": json.dumps({
+ "name": "SUBSTRING",
+ "inputs": [
+ {"name": "episode", "type": "STRING NOT NULL"}, 1, 1,
+ ],
+ })},
+ )
+ table.raw_table.catalog_environment.table_query_auth = (
+ lambda options, table_identifier: lambda select: auth)
+
+ with self.assertRaisesRegex(ValueError, r"masks \['episode'\]"):
+ self._dataset(table)
+
+ def test_reads_only_the_files_and_row_ranges_a_window_touches(self):
+ table = self.conn.create_table(
+ "many_files", schema=self._schema(), options=_TABLE_OPTIONS)
+ for batch in range(4):
+ table.add([
+ self._row("episode-a", batch * 8 + offset)
+ for offset in range(8)
+ ])
+ dataset = table.scan().to_contiguous_window_dataset(
+ window_size=3, columns=["value"],
+ group_key="episode", order_key="step")
+
+ opened = []
+ decoded = []
+ original_init = FormatPyArrowReader.__init__
+ original_read = FormatPyArrowReader.read_arrow_batch
+
+ def recording_init(reader, *args, **kwargs):
+ original_init(reader, *args, **kwargs)
+ opened.append((args[2], kwargs.get("row_ranges")))
+
+ def recording_read(reader):
+ batch = original_read(reader)
+ decoded.append(0 if batch is None else batch.num_rows)
+ return batch
+
+ with patch.object(FormatPyArrowReader, "__init__", recording_init), \
+ patch.object(
+ FormatPyArrowReader, "read_arrow_batch", recording_read):
+ sample = dataset[len(dataset) - 1]
+
+ self.assertEqual([29, 30, 31], sample["value"])
+ self.assertEqual(1, len({file_path for file_path, _ in opened}))
+ # The reader receives positions inside the file, so global rows 29-31
+ # of the fourth eight-row file arrive as 5-7.
+ self.assertEqual([[(5, 7)]], [ranges for _, ranges in opened])
+ self.assertEqual(3, sum(decoded))
+
+ def test_rejects_nan_group_keys_that_never_compare_equal(self):
+ table = self.conn.create_table(
+ "nan_groups",
+ schema=pa.schema([
+ pa.field("episode", pa.float64()),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("value", pa.int32(), nullable=False),
+ ]),
+ options=_TABLE_OPTIONS,
+ )
+ table.add([
+ {"episode": float("nan"), "step": 0, "value": 0},
+ {"episode": float("nan"), "step": 1, "value": 1},
+ ])
+
+ with self.assertRaisesRegex(ValueError, "episode must not contain
NaN"):
+ table.scan().to_contiguous_window_dataset(
+ window_size=2, columns=["value"],
+ group_key="episode", order_key="step")
+
+ def test_rejects_video_frame_columns_that_would_lose_frame_metadata(self):
+ table = self.conn.create_table(
+ "videos",
+ schema=pa.schema([
+ pa.field("episode", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("video", pa.large_binary()),
+ ]),
+ options=dict(_TABLE_OPTIONS, **{
+ "video-frame-field": "video",
+ "blob-as-descriptor": "true",
+ }),
+ )
+
+ with self.assertRaisesRegex(
+ ValueError, "video frame columns.*frame_index"):
+ table.scan().to_contiguous_window_dataset(
+ window_size=2, columns=["video"],
+ group_key="episode", order_key="step")
+
+ def test_validates_configuration_and_scan_only_contract(self):
+ table = self._table()
+ query = table.scan()
+ for name, value in (
+ ("window_size", 0),
+ ("stride", 0),
+ ("tail", "unknown"),
+ ("group_key", "missing"),
+ ("order_key", "missing")):
+ kwargs = {
+ "window_size": 2,
+ "columns": ["value"],
+ "stride": 1,
+ "tail": "drop",
+ "group_key": "episode",
+ "order_key": "step",
+ }
+ kwargs[name] = value
+ with self.subTest(name=name), self.assertRaises((TypeError,
ValueError)):
+ query.to_contiguous_window_dataset(**kwargs)
+
+ reserved_table = self.conn.create_table(
+ "reserved", schema=pa.schema([
+ pa.field("is_pad", pa.string(), nullable=False),
+ pa.field("step", pa.int32(), nullable=False),
+ pa.field("value", pa.int32(), nullable=False),
+ ]), options=_TABLE_OPTIONS)
+ with self.assertRaisesRegex(ValueError, "must not be is_pad"):
+ reserved_table.scan().to_contiguous_window_dataset(
+ window_size=2, columns=["value"],
+ group_key="is_pad", order_key="step")
+
+ with self.assertRaisesRegex(TypeError, "only supported on scan"):
+ table.search("anything",
column="episode").to_contiguous_window_dataset(
+ window_size=2, columns=["value"],
+ group_key="episode", order_key="step")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/torch_read_test.py
b/paimon-python/pypaimon/tests/torch_read_test.py
index 0dc269110d..bb14bd0320 100644
--- a/paimon-python/pypaimon/tests/torch_read_test.py
+++ b/paimon-python/pypaimon/tests/torch_read_test.py
@@ -37,7 +37,7 @@ from pypaimon.catalog.table_query_auth import
TableQueryAuthResult
from pypaimon.multimodal.table import MultimodalTable
from pypaimon.read.datasource.torch_dataset import (
- _SplitRangeIndex,
+ SplitRangeIndex,
TorchIterDataset,
TorchShuffledIterDataset,
_resolve_distributed_context,
@@ -666,7 +666,7 @@ class TorchReadTest(unittest.TestCase):
self.assertEqual(dataset[0], restored[0])
def test_split_range_index(self):
- index = _SplitRangeIndex([
+ index = SplitRangeIndex([
[Range(split * 10, split * 10 + 9)]
for split in range(10000)
])