mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-30 09:58:20 +00:00
105fd73bc6
## Summary - add `StreamingDataLoader`, which transports worker snapshots with prefetched batches and commits them to the parent dataset only when the trainer receives each batch - preserve exact non-uniform per-split progress and resume lagging splits without replaying already-consumed rows - reject stale parent checkpoints after a standard multi-process `DataLoader` has started, with guidance to use the consumer-aware loader - document the new public loader and merge non-uniform state across ranks ## Root cause PyTorch runs `StreamingDataset.__iter__` in private worker-process copies, while callers invoke `state_dict()` on the parent dataset. Sharing producer counters would still be incorrect because DataLoader prefetch can advance workers beyond batches returned to the trainer. ## Validation - `uv run --extra tests pytest python/tests/test_elastic_dataloader.py -q` (154 passed) - focused non-uniform merge regression (1 passed) - `uv run --project python --extra tests --extra dev ruff format .` - `uv run --project python --extra tests --extra dev ruff check .` - `cd docs && PYTHONPATH=. ../python/.venv/bin/mkdocs build` Fixes #3967 <!-- lance-gatekeeper-fix:v1 agent=572be272619660b97e87fd5c85188341 generation=1 --> --------- Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> Co-authored-by: Xuanwo <github@xuanwo.io>
3198 lines
112 KiB
Python
3198 lines
112 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||
|
||
"""Tests for elastic dataloader properties.
|
||
|
||
Two properties are verified:
|
||
|
||
1. **Elastic determinism** — for a fixed (num_splits, shuffle_seed, epoch), the
|
||
set of samples that forms each global training step is identical regardless of
|
||
the training topology (world_size). This mirrors the MDS guarantee described
|
||
in dataloader.md: "the global batch at a given step will always contain the
|
||
same samples."
|
||
|
||
2. **Resumability** — after calling state_dict() and load_state_dict(), a fresh
|
||
dataset continues from exactly where the previous run stopped: no sample is
|
||
skipped, no sample is repeated. The checkpoint is topology-independent (it
|
||
captures per-split consumption counts), so resumption works even when
|
||
world_size changes between runs.
|
||
|
||
Tests use explicit rank / world_size constructor parameters rather than
|
||
torch.distributed, so they run in a single process without a distributed process
|
||
group. The import guard below causes the whole module to be skipped when
|
||
lancedb.streaming does not yet exist, which keeps CI green while the
|
||
implementation is in progress.
|
||
|
||
Parameters used throughout:
|
||
NUM_ROWS = 120 (divisible by all tested world_sizes and num_splits)
|
||
NUM_SPLITS = 12 (divides cleanly by world_sizes 1,2,3,4,6,12)
|
||
GLOBAL_BATCH_SIZE = 12 (= num_splits → each split contributes 1 sample/step)
|
||
STEPS_PER_EPOCH = 10 (= NUM_ROWS / GLOBAL_BATCH_SIZE)
|
||
"""
|
||
|
||
import dataclasses
|
||
import logging
|
||
import threading
|
||
from unittest.mock import patch
|
||
|
||
import lancedb
|
||
import pyarrow as pa
|
||
import pytest
|
||
from utils import (
|
||
MockPermutationServer,
|
||
assert_server_safe_row_id_requests,
|
||
mock_remote_table,
|
||
)
|
||
|
||
torch = pytest.importorskip("torch")
|
||
streaming = pytest.importorskip("lancedb.streaming")
|
||
StreamingDataset = streaming.StreamingDataset
|
||
StreamingDataLoader = streaming.StreamingDataLoader
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dataset parameters
|
||
# ---------------------------------------------------------------------------
|
||
|
||
NUM_ROWS = 120
|
||
NUM_SPLITS = 12
|
||
GLOBAL_BATCH_SIZE = NUM_SPLITS # one sample per split per step
|
||
STEPS_PER_EPOCH = NUM_ROWS // GLOBAL_BATCH_SIZE # = 10
|
||
SHUFFLE_SEED = 42
|
||
|
||
# world_sizes compatible with NUM_SPLITS (i.e. NUM_SPLITS % world_size == 0)
|
||
COMPATIBLE_WORLD_SIZES = [1, 2, 3, 4, 6, 12]
|
||
|
||
# Parameters for the "large global batch" tests where global_batch_size is a
|
||
# multiple of num_splits rather than equal to it. Using 3× gives 3 samples per
|
||
# split per step, which exercises the path where each rank drains more than one
|
||
# sample per split per global step.
|
||
LARGE_GLOBAL_BATCH_SIZE = 36 # = 3 * NUM_SPLITS
|
||
LARGE_NUM_ROWS = 360 # must be divisible by LARGE_GLOBAL_BATCH_SIZE (360/36=10)
|
||
|
||
# (world_size, num_workers) pairs used in multi-worker tests.
|
||
# Constraint: num_splits % (world_size * num_workers) == 0, i.e. world_size *
|
||
# num_workers must divide NUM_SPLITS (12).
|
||
MULTI_WORKER_TOPOLOGIES = [
|
||
(1, 2),
|
||
(1, 3),
|
||
(1, 4),
|
||
(2, 2),
|
||
(2, 3),
|
||
]
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class FakeWorkerInfo:
|
||
"""Stand-in for torch.utils.data.WorkerInfo, accepted by worker_info_override.
|
||
|
||
Mirrors the attributes that StreamingDataset reads from the real WorkerInfo:
|
||
id — this worker's index (0-based)
|
||
num_workers — total workers for this rank's DataLoader
|
||
"""
|
||
|
||
id: int
|
||
num_workers: int
|
||
|
||
|
||
def _collate_with_first_batch_error(samples):
|
||
ids = [sample["id"] for sample in samples]
|
||
if ids == [0, 1]:
|
||
raise ValueError("first batch fails")
|
||
return ids
|
||
|
||
|
||
def _collate_with_first_batch_stop(samples):
|
||
ids = [sample["id"] for sample in samples]
|
||
if ids == [0, 1]:
|
||
raise StopIteration("first batch stopped")
|
||
return ids
|
||
|
||
|
||
def _collate_with_first_batch_interrupt(samples):
|
||
ids = [sample["id"] for sample in samples]
|
||
if ids == [0, 1]:
|
||
raise KeyboardInterrupt("first batch interrupted")
|
||
return ids
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def lance_table(tmp_path):
|
||
"""A simple LanceDB table with integer IDs 0 .. NUM_ROWS-1."""
|
||
db = lancedb.connect(tmp_path)
|
||
return db.create_table("data", pa.table({"id": list(range(NUM_ROWS))}))
|
||
|
||
|
||
@pytest.fixture
|
||
def lance_table_large(tmp_path):
|
||
"""A larger table for tests where global_batch_size > num_splits.
|
||
|
||
Uses LARGE_NUM_ROWS rows.
|
||
"""
|
||
db = lancedb.connect(tmp_path)
|
||
return db.create_table("data", pa.table({"id": list(range(LARGE_NUM_ROWS))}))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Simulation helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_dataset(
|
||
table,
|
||
rank: int,
|
||
world_size: int,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
worker_info_override: FakeWorkerInfo | None = None,
|
||
) -> StreamingDataset:
|
||
"""Create a StreamingDataset configured for a specific rank in a simulated
|
||
distributed run. rank and world_size are passed explicitly so we do not
|
||
need an actual torch.distributed process group.
|
||
|
||
Pass worker_info_override to simulate a specific DataLoader worker without
|
||
spawning real worker processes.
|
||
"""
|
||
return StreamingDataset(
|
||
table,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
rank=rank,
|
||
world_size=world_size,
|
||
worker_info_override=worker_info_override,
|
||
)
|
||
|
||
|
||
def _collect_global_batches(
|
||
table,
|
||
world_size: int,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
global_batch_size: int = GLOBAL_BATCH_SIZE,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
) -> list[frozenset[int]]:
|
||
"""Drain a full epoch and return one frozenset of sample IDs per global step.
|
||
|
||
Each rank is represented by one StreamingDataset. A "global step" consumes
|
||
(global_batch_size // world_size) samples from every rank, so the global batch
|
||
at step k is the union of all per-rank micro-batches at that step. frozensets
|
||
are used so comparisons are order-independent within a batch.
|
||
|
||
global_batch_size must be a multiple of num_splits (each split contributes
|
||
global_batch_size // num_splits samples per step).
|
||
|
||
Raises AssertionError if the per-rank iterators exhaust at different times,
|
||
which would indicate a padding/truncation bug in the implementation.
|
||
"""
|
||
assert num_splits % world_size == 0, (
|
||
f"world_size={world_size} does not divide num_splits={num_splits}"
|
||
)
|
||
assert global_batch_size % num_splits == 0, (
|
||
f"global_batch_size={global_batch_size} is not a multiple of "
|
||
f"num_splits={num_splits}"
|
||
)
|
||
micro = global_batch_size // world_size # samples per rank per global step
|
||
|
||
datasets = [
|
||
_make_dataset(
|
||
table,
|
||
rank,
|
||
world_size,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
)
|
||
for rank in range(world_size)
|
||
]
|
||
iters = [iter(ds) for ds in datasets]
|
||
|
||
_STOP = object()
|
||
global_batches: list[frozenset[int]] = []
|
||
while True:
|
||
step_samples: set[int] = set()
|
||
exhausted = 0
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
val = next(it, _STOP)
|
||
if val is _STOP:
|
||
exhausted += 1
|
||
break
|
||
step_samples.add(val["id"])
|
||
if exhausted == len(iters):
|
||
# All iterators exhausted at the same step — end of epoch.
|
||
break
|
||
assert exhausted == 0, (
|
||
"Rank iterators exhausted at different steps. "
|
||
"StreamingDataset must produce equal-length sequences across ranks."
|
||
)
|
||
global_batches.append(frozenset(step_samples))
|
||
|
||
return global_batches
|
||
|
||
|
||
def _advance_and_checkpoint(
|
||
table,
|
||
world_size: int,
|
||
steps: int,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
global_batch_size: int = GLOBAL_BATCH_SIZE,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
) -> tuple[list[frozenset[int]], dict]:
|
||
"""Run `steps` global steps, then snapshot the dataset state.
|
||
|
||
Returns (batches_consumed, checkpoint) where checkpoint is the state_dict
|
||
from rank 0's dataset. Because state is per-split (not per-rank), any
|
||
rank's state_dict can be used to resume on any compatible topology.
|
||
"""
|
||
assert num_splits % world_size == 0
|
||
assert global_batch_size % num_splits == 0
|
||
micro = global_batch_size // world_size
|
||
|
||
datasets = [
|
||
_make_dataset(
|
||
table,
|
||
rank,
|
||
world_size,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
)
|
||
for rank in range(world_size)
|
||
]
|
||
iters = [iter(ds) for ds in datasets]
|
||
|
||
seen: list[frozenset[int]] = []
|
||
for _ in range(steps):
|
||
step_samples: set[int] = set()
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
step_samples.add(next(it)["id"])
|
||
seen.append(frozenset(step_samples))
|
||
|
||
checkpoint = datasets[0].state_dict()
|
||
return seen, checkpoint
|
||
|
||
|
||
def _resume_and_collect(
|
||
table,
|
||
world_size: int,
|
||
checkpoint: dict,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
global_batch_size: int = GLOBAL_BATCH_SIZE,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
) -> list[frozenset[int]]:
|
||
"""Create fresh datasets, load checkpoint, and drain to epoch end.
|
||
|
||
world_size may differ from the run that produced the checkpoint,
|
||
exercising the elastic-resume path.
|
||
"""
|
||
assert num_splits % world_size == 0
|
||
assert global_batch_size % num_splits == 0
|
||
micro = global_batch_size // world_size
|
||
|
||
datasets = [
|
||
_make_dataset(
|
||
table,
|
||
rank,
|
||
world_size,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
)
|
||
for rank in range(world_size)
|
||
]
|
||
for ds in datasets:
|
||
ds.load_state_dict(checkpoint)
|
||
|
||
iters = [iter(ds) for ds in datasets]
|
||
_STOP2 = object()
|
||
remaining: list[frozenset[int]] = []
|
||
while True:
|
||
step_samples: set[int] = set()
|
||
exhausted = 0
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
val = next(it, _STOP2)
|
||
if val is _STOP2:
|
||
exhausted += 1
|
||
break
|
||
step_samples.add(val["id"])
|
||
if exhausted == len(iters):
|
||
break
|
||
assert exhausted == 0
|
||
remaining.append(frozenset(step_samples))
|
||
|
||
return remaining
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Elastic determinism tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_elastic_det_full_coverage(lance_table, world_size):
|
||
"""Every sample ID appears exactly once across all steps of an epoch."""
|
||
batches = _collect_global_batches(lance_table, world_size)
|
||
all_seen = sorted(sid for b in batches for sid in b)
|
||
assert all_seen == list(range(NUM_ROWS)), (
|
||
f"world_size={world_size}: expected IDs 0..{NUM_ROWS - 1} but got {all_seen}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_elastic_det_correct_step_count(lance_table, world_size):
|
||
"""Number of global steps equals NUM_ROWS // GLOBAL_BATCH_SIZE."""
|
||
batches = _collect_global_batches(lance_table, world_size)
|
||
assert len(batches) == STEPS_PER_EPOCH, (
|
||
f"world_size={world_size}: expected {STEPS_PER_EPOCH} steps, got {len(batches)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_elastic_det_no_intra_batch_duplicates(lance_table, world_size):
|
||
"""Within a single global batch, every sample ID is distinct."""
|
||
batches = _collect_global_batches(lance_table, world_size)
|
||
for step, batch in enumerate(batches):
|
||
assert len(batch) == GLOBAL_BATCH_SIZE, (
|
||
f"world_size={world_size} step {step}: "
|
||
f"expected {GLOBAL_BATCH_SIZE} samples, got {len(batch)}"
|
||
)
|
||
|
||
|
||
def test_elastic_det_same_batches_across_world_sizes(lance_table):
|
||
"""The global batch at each step is identical for every compatible world_size.
|
||
|
||
This is the core elastic-determinism guarantee from the design doc.
|
||
"""
|
||
reference = _collect_global_batches(lance_table, world_size=1)
|
||
for ws in COMPATIBLE_WORLD_SIZES[1:]:
|
||
batches = _collect_global_batches(lance_table, world_size=ws)
|
||
assert len(batches) == len(reference), (
|
||
f"world_size={ws} produced {len(batches)} steps; "
|
||
f"world_size=1 produced {len(reference)}"
|
||
)
|
||
for step, (ref_batch, batch) in enumerate(zip(reference, batches)):
|
||
assert ref_batch == batch, (
|
||
f"Global batch at step {step} differs between world_size=1 and "
|
||
f"world_size={ws}.\n"
|
||
f" world_size=1 → {sorted(ref_batch)}\n"
|
||
f" world_size={ws} → {sorted(batch)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_elastic_det_reproducible(lance_table, world_size):
|
||
"""Two independent runs with the same parameters produce identical epochs."""
|
||
batches_a = _collect_global_batches(lance_table, world_size)
|
||
batches_b = _collect_global_batches(lance_table, world_size)
|
||
assert batches_a == batches_b, (
|
||
f"world_size={world_size}: second run produced different batches"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("epoch_a,epoch_b", [(0, 1), (1, 2), (0, 2)])
|
||
def test_elastic_det_different_epochs_differ(lance_table, epoch_a, epoch_b):
|
||
"""Different epochs use different shuffles and therefore produce different
|
||
step sequences. (Both cover all samples; they just appear in different order.)
|
||
"""
|
||
batches_a = _collect_global_batches(lance_table, world_size=1, epoch=epoch_a)
|
||
batches_b = _collect_global_batches(lance_table, world_size=1, epoch=epoch_b)
|
||
# Same set of samples overall...
|
||
assert sorted(sid for b in batches_a for sid in b) == list(range(NUM_ROWS))
|
||
assert sorted(sid for b in batches_b for sid in b) == list(range(NUM_ROWS))
|
||
# ...but a different step-by-step ordering.
|
||
assert batches_a != batches_b, (
|
||
f"Epoch {epoch_a} and epoch {epoch_b} produced identical step sequences; "
|
||
"shuffle_seed should incorporate the epoch number."
|
||
)
|
||
|
||
|
||
def test_elastic_det_different_seeds_differ(lance_table):
|
||
"""Different shuffle seeds produce different sample orderings."""
|
||
batches_seed0 = _collect_global_batches(lance_table, world_size=1, shuffle_seed=0)
|
||
batches_seed1 = _collect_global_batches(lance_table, world_size=1, shuffle_seed=1)
|
||
assert batches_seed0 != batches_seed1, (
|
||
"Different shuffle_seed values must produce different sample orderings."
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Resumability tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"checkpoint_at_step", [0, 1, 4, STEPS_PER_EPOCH - 1, STEPS_PER_EPOCH]
|
||
)
|
||
def test_resumability_same_world_size(lance_table, checkpoint_at_step):
|
||
"""Resuming on the same world_size produces the same global batches as a
|
||
reference full-epoch run, split at the checkpoint step.
|
||
|
||
Combined (before + after resume):
|
||
- No sample is repeated.
|
||
- No sample is skipped.
|
||
- Each global batch matches the reference exactly.
|
||
"""
|
||
world_size = 2
|
||
reference = _collect_global_batches(lance_table, world_size)
|
||
|
||
seen_before, checkpoint = _advance_and_checkpoint(
|
||
lance_table, world_size, checkpoint_at_step
|
||
)
|
||
seen_after = _resume_and_collect(lance_table, world_size, checkpoint)
|
||
|
||
# Correct total number of steps.
|
||
assert len(seen_before) + len(seen_after) == STEPS_PER_EPOCH, (
|
||
f"Expected {STEPS_PER_EPOCH} total steps; "
|
||
f"got {len(seen_before)} + {len(seen_after)}"
|
||
)
|
||
|
||
# Each pre-checkpoint batch matches the reference.
|
||
for step, (ref, got) in enumerate(zip(reference, seen_before)):
|
||
assert ref == got, (
|
||
f"Pre-checkpoint batch {step} doesn't match reference.\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
# Each post-resume batch matches the reference (continuing from
|
||
# checkpoint_at_step).
|
||
for offset, (ref, got) in enumerate(
|
||
zip(reference[checkpoint_at_step:], seen_after)
|
||
):
|
||
step = checkpoint_at_step + offset
|
||
assert ref == got, (
|
||
f"Post-resume batch {step} doesn't match reference.\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
# Full coverage: every sample seen exactly once.
|
||
all_seen = sorted(sid for b in seen_before + seen_after for sid in b)
|
||
assert all_seen == list(range(NUM_ROWS))
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"initial_world_size,resume_world_size",
|
||
[
|
||
(1, 2),
|
||
(1, 4),
|
||
(1, 12),
|
||
(2, 1),
|
||
(2, 4),
|
||
(4, 2),
|
||
(4, 1),
|
||
(3, 6),
|
||
(6, 3),
|
||
(12, 1),
|
||
],
|
||
)
|
||
def test_resumability_elastic_world_size_change(
|
||
lance_table, initial_world_size, resume_world_size
|
||
):
|
||
"""Resuming on a *different* world_size (elastic resume) produces the same
|
||
global batches as the reference epoch for both the pre- and post-checkpoint
|
||
phases. No sample is skipped or repeated.
|
||
|
||
This is the central elastic-resume guarantee: because state is stored
|
||
per-split (not per-rank), the checkpoint is topology-independent.
|
||
"""
|
||
checkpoint_at_step = 4
|
||
reference = _collect_global_batches(lance_table, world_size=initial_world_size)
|
||
|
||
seen_before, checkpoint = _advance_and_checkpoint(
|
||
lance_table, initial_world_size, checkpoint_at_step
|
||
)
|
||
seen_after = _resume_and_collect(lance_table, resume_world_size, checkpoint)
|
||
|
||
# Pre-checkpoint batches match reference.
|
||
for step, (ref, got) in enumerate(zip(reference, seen_before)):
|
||
assert ref == got, (
|
||
f"Pre-checkpoint batch {step} differs "
|
||
f"(initial_world_size={initial_world_size}).\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
# Post-resume batches match reference (elastic determinism also holds
|
||
# for the resumed phase with the new world_size).
|
||
for offset, (ref, got) in enumerate(
|
||
zip(reference[checkpoint_at_step:], seen_after)
|
||
):
|
||
step = checkpoint_at_step + offset
|
||
assert ref == got, (
|
||
f"Post-resume batch {step} differs "
|
||
f"(resume_world_size={resume_world_size}).\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
# No overlap and full coverage.
|
||
before_ids = {sid for b in seen_before for sid in b}
|
||
after_ids = {sid for b in seen_after for sid in b}
|
||
assert before_ids.isdisjoint(after_ids), (
|
||
f"Samples seen before and after resume overlap: "
|
||
f"{sorted(before_ids & after_ids)}"
|
||
)
|
||
assert before_ids | after_ids == set(range(NUM_ROWS)), (
|
||
f"Not all samples covered. Missing: "
|
||
f"{sorted(set(range(NUM_ROWS)) - (before_ids | after_ids))}"
|
||
)
|
||
|
||
|
||
def test_resumability_state_dict_is_topology_independent(lance_table):
|
||
"""state_dict() captures per-split consumption, so it must be identical
|
||
across all ranks at the same training step.
|
||
|
||
If ranks produce different state dicts, resumption with a different
|
||
world_size would give inconsistent results depending on which rank's
|
||
checkpoint was saved.
|
||
"""
|
||
checkpoint_at_step = 4
|
||
world_size = 4
|
||
micro = NUM_SPLITS // world_size
|
||
|
||
datasets = [
|
||
_make_dataset(lance_table, rank, world_size) for rank in range(world_size)
|
||
]
|
||
iters = [iter(ds) for ds in datasets]
|
||
|
||
for _ in range(checkpoint_at_step):
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
next(it)
|
||
|
||
state_dicts = [ds.state_dict() for ds in datasets]
|
||
for rank in range(1, world_size):
|
||
assert state_dicts[rank] == state_dicts[0], (
|
||
f"state_dict from rank {rank} differs from rank 0.\n"
|
||
f" rank 0: {state_dicts[0]}\n"
|
||
f" rank {rank}: {state_dicts[rank]}"
|
||
)
|
||
|
||
|
||
def test_resumability_round_trip_is_deterministic(lance_table):
|
||
"""Loading the same checkpoint twice produces identical post-resume sequences."""
|
||
world_size = 2
|
||
checkpoint_at_step = 3
|
||
|
||
_, checkpoint = _advance_and_checkpoint(lance_table, world_size, checkpoint_at_step)
|
||
remaining_a = _resume_and_collect(lance_table, world_size, checkpoint)
|
||
remaining_b = _resume_and_collect(lance_table, world_size, checkpoint)
|
||
|
||
assert remaining_a == remaining_b, (
|
||
"Same checkpoint produced different sequences on two separate resumes."
|
||
)
|
||
|
||
|
||
def test_resumability_at_epoch_start(lance_table):
|
||
"""A checkpoint taken before any samples are consumed resumes as a full epoch."""
|
||
world_size = 2
|
||
reference = _collect_global_batches(lance_table, world_size)
|
||
|
||
seen_before, checkpoint = _advance_and_checkpoint(lance_table, world_size, steps=0)
|
||
seen_after = _resume_and_collect(lance_table, world_size, checkpoint)
|
||
|
||
assert seen_before == []
|
||
assert seen_after == reference
|
||
|
||
|
||
def test_resumability_at_epoch_end(lance_table):
|
||
"""A checkpoint taken after all steps are consumed yields an empty resume."""
|
||
world_size = 2
|
||
|
||
_, checkpoint = _advance_and_checkpoint(lance_table, world_size, STEPS_PER_EPOCH)
|
||
remaining = _resume_and_collect(lance_table, world_size, checkpoint)
|
||
|
||
assert remaining == [], (
|
||
f"Expected no remaining samples after epoch-end checkpoint, "
|
||
f"but got {len(remaining)} batches: {remaining}"
|
||
)
|
||
|
||
|
||
def test_resumability_state_dict_contains_required_keys(lance_table):
|
||
"""state_dict() must contain the keys the design doc mandates.
|
||
|
||
From the design doc:
|
||
- shuffle_seed (must match on resume)
|
||
- num_splits (must match on resume)
|
||
- epoch (current epoch)
|
||
- samples_consumed_per_split (per-split counter, topology-independent)
|
||
"""
|
||
_, checkpoint = _advance_and_checkpoint(lance_table, world_size=1, steps=3)
|
||
required_keys = {
|
||
"shuffle_seed",
|
||
"num_splits",
|
||
"epoch",
|
||
"samples_consumed_per_split",
|
||
}
|
||
missing = required_keys - checkpoint.keys()
|
||
assert not missing, (
|
||
f"state_dict() is missing required keys: {missing}\n"
|
||
f"Got keys: {set(checkpoint.keys())}"
|
||
)
|
||
|
||
|
||
def test_resumability_mismatched_num_splits_raises(lance_table):
|
||
"""Loading a checkpoint with a different num_splits must raise an error.
|
||
|
||
Changing num_splits invalidates the split partitioning and mid-epoch resume
|
||
is no longer meaningful.
|
||
"""
|
||
_, checkpoint = _advance_and_checkpoint(lance_table, world_size=1, steps=3)
|
||
|
||
different_splits = NUM_SPLITS * 2
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=different_splits,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
rank=0,
|
||
world_size=1,
|
||
)
|
||
with pytest.raises((ValueError, RuntimeError)):
|
||
ds.load_state_dict(checkpoint)
|
||
|
||
|
||
def test_resumability_mismatched_shuffle_seed_raises(lance_table):
|
||
"""Loading a checkpoint with a different shuffle_seed must raise an error.
|
||
|
||
A different seed produces a different sample ordering, so the per-split
|
||
consumption counts in the checkpoint no longer refer to the same samples.
|
||
"""
|
||
_, checkpoint = _advance_and_checkpoint(lance_table, world_size=1, steps=3)
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED + 1,
|
||
rank=0,
|
||
world_size=1,
|
||
)
|
||
with pytest.raises((ValueError, RuntimeError)):
|
||
ds.load_state_dict(checkpoint)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Large global batch size tests (global_batch_size is a multiple of num_splits)
|
||
# ---------------------------------------------------------------------------
|
||
# These tests use LARGE_GLOBAL_BATCH_SIZE = 36 = 3 * NUM_SPLITS, so each split
|
||
# contributes 3 samples per global step instead of 1. The elastic-determinism
|
||
# and resumability properties must hold regardless of this multiplier.
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_large_batch_elastic_det_full_coverage(lance_table_large, world_size):
|
||
"""Every sample appears exactly once per epoch regardless of global_batch_size."""
|
||
batches = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
all_seen = sorted(sid for b in batches for sid in b)
|
||
assert all_seen == list(range(LARGE_NUM_ROWS))
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_large_batch_elastic_det_correct_step_count(lance_table_large, world_size):
|
||
"""Steps per epoch = LARGE_NUM_ROWS // LARGE_GLOBAL_BATCH_SIZE."""
|
||
expected_steps = LARGE_NUM_ROWS // LARGE_GLOBAL_BATCH_SIZE
|
||
batches = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
assert len(batches) == expected_steps, (
|
||
f"world_size={world_size}: expected {expected_steps} steps, got {len(batches)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size", COMPATIBLE_WORLD_SIZES)
|
||
def test_large_batch_elastic_det_correct_batch_size(lance_table_large, world_size):
|
||
"""Each global batch contains exactly LARGE_GLOBAL_BATCH_SIZE distinct samples."""
|
||
batches = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
for step, batch in enumerate(batches):
|
||
assert len(batch) == LARGE_GLOBAL_BATCH_SIZE, (
|
||
f"world_size={world_size} step {step}: "
|
||
f"expected {LARGE_GLOBAL_BATCH_SIZE} samples, got {len(batch)}"
|
||
)
|
||
|
||
|
||
def test_large_batch_elastic_det_same_across_topologies(lance_table_large):
|
||
"""Global batch k is identical for every compatible world_size when
|
||
global_batch_size (36) is a 3× multiple of num_splits (12)."""
|
||
reference = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size=1,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
for ws in COMPATIBLE_WORLD_SIZES[1:]:
|
||
batches = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size=ws,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
assert len(batches) == len(reference)
|
||
for step, (ref, got) in enumerate(zip(reference, batches)):
|
||
assert ref == got, (
|
||
f"Global batch at step {step} differs between world_size=1 and "
|
||
f"world_size={ws} (global_batch_size={LARGE_GLOBAL_BATCH_SIZE}).\n"
|
||
f" world_size=1 → {sorted(ref)}\n"
|
||
f" world_size={ws} → {sorted(got)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"initial_world_size,resume_world_size",
|
||
[(1, 4), (2, 4), (4, 2), (4, 1), (3, 6), (6, 3)],
|
||
)
|
||
def test_large_batch_resumability_elastic_world_size_change(
|
||
lance_table_large, initial_world_size, resume_world_size
|
||
):
|
||
"""Elastic resume with global_batch_size=36 (3× num_splits): combined
|
||
samples before and after the checkpoint match the reference epoch exactly."""
|
||
checkpoint_at_step = 4
|
||
reference = _collect_global_batches(
|
||
lance_table_large,
|
||
world_size=initial_world_size,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
|
||
seen_before, checkpoint = _advance_and_checkpoint(
|
||
lance_table_large,
|
||
initial_world_size,
|
||
checkpoint_at_step,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
seen_after = _resume_and_collect(
|
||
lance_table_large,
|
||
resume_world_size,
|
||
checkpoint,
|
||
global_batch_size=LARGE_GLOBAL_BATCH_SIZE,
|
||
)
|
||
|
||
for step, (ref, got) in enumerate(zip(reference, seen_before)):
|
||
assert ref == got, (
|
||
f"Pre-checkpoint batch {step} differs "
|
||
f"(initial_world_size={initial_world_size}, "
|
||
f"global_batch_size={LARGE_GLOBAL_BATCH_SIZE}).\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
for offset, (ref, got) in enumerate(
|
||
zip(reference[checkpoint_at_step:], seen_after)
|
||
):
|
||
step = checkpoint_at_step + offset
|
||
assert ref == got, (
|
||
f"Post-resume batch {step} differs "
|
||
f"(resume_world_size={resume_world_size}, "
|
||
f"global_batch_size={LARGE_GLOBAL_BATCH_SIZE}).\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
before_ids = {sid for b in seen_before for sid in b}
|
||
after_ids = {sid for b in seen_after for sid in b}
|
||
assert before_ids.isdisjoint(after_ids), (
|
||
f"Samples overlap across checkpoint: {sorted(before_ids & after_ids)}"
|
||
)
|
||
assert before_ids | after_ids == set(range(LARGE_NUM_ROWS))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Multi-worker tests (num_workers > 1)
|
||
# ---------------------------------------------------------------------------
|
||
# PyTorch DataLoader collects a full batch from each worker before moving to the
|
||
# next (batch-level, not item-level, round-robin). For the dataset's batches to
|
||
# be consistent across num_workers, the dataset must assign each worker a
|
||
# contiguous block of splits. Worker k owns splits in the range:
|
||
# [k * (num_splits // num_workers), (k+1) * (num_splits // num_workers))
|
||
# (within a single rank's split allocation).
|
||
#
|
||
# Tests in this section use worker_info_override to simulate multiple workers
|
||
# without spawning real DataLoader worker processes.
|
||
|
||
|
||
def _collect_global_batches_multi_worker(
|
||
table,
|
||
world_size: int,
|
||
num_workers: int,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
global_batch_size: int = GLOBAL_BATCH_SIZE,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
) -> list[frozenset[int]]:
|
||
"""Drain a full epoch using multiple simulated workers per rank.
|
||
|
||
Creates one StreamingDataset per (rank, worker_id) pair, each configured
|
||
via worker_info_override. Samples from all (rank, worker_id) pairs at the
|
||
same global step are combined into one frozenset, mirroring the contiguous
|
||
batch-level collection that PyTorch DataLoader performs.
|
||
|
||
Constraint: num_splits % (world_size * num_workers) == 0
|
||
"""
|
||
assert num_splits % (world_size * num_workers) == 0, (
|
||
f"num_splits={num_splits} must be divisible by "
|
||
f"world_size * num_workers = {world_size * num_workers}"
|
||
)
|
||
assert global_batch_size % (world_size * num_workers) == 0
|
||
samples_per_worker_per_step = global_batch_size // (world_size * num_workers)
|
||
|
||
# Collect all samples from every (rank, worker_id) pair.
|
||
worker_samples: dict[tuple[int, int], list[int]] = {}
|
||
for rank in range(world_size):
|
||
for worker_id in range(num_workers):
|
||
ds = _make_dataset(
|
||
table,
|
||
rank,
|
||
world_size,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
worker_info_override=FakeWorkerInfo(
|
||
id=worker_id, num_workers=num_workers
|
||
),
|
||
)
|
||
worker_samples[(rank, worker_id)] = [item["id"] for item in ds]
|
||
|
||
lengths = {len(s) for s in worker_samples.values()}
|
||
assert len(lengths) == 1, (
|
||
f"Workers produced unequal sample counts: "
|
||
f"{dict(zip(worker_samples, (len(s) for s in worker_samples.values())))}"
|
||
)
|
||
n_per_worker = lengths.pop()
|
||
assert n_per_worker % samples_per_worker_per_step == 0
|
||
n_steps = n_per_worker // samples_per_worker_per_step
|
||
|
||
global_batches: list[frozenset[int]] = []
|
||
for step in range(n_steps):
|
||
batch: set[int] = set()
|
||
for samples in worker_samples.values():
|
||
start = step * samples_per_worker_per_step
|
||
batch.update(samples[start : start + samples_per_worker_per_step])
|
||
global_batches.append(frozenset(batch))
|
||
|
||
return global_batches
|
||
|
||
|
||
def _advance_and_checkpoint_multi_worker(
|
||
table,
|
||
world_size: int,
|
||
num_workers: int,
|
||
steps: int,
|
||
*,
|
||
num_splits: int = NUM_SPLITS,
|
||
global_batch_size: int = GLOBAL_BATCH_SIZE,
|
||
shuffle_seed: int = SHUFFLE_SEED,
|
||
epoch: int = 0,
|
||
) -> tuple[list[frozenset[int]], dict]:
|
||
"""Run `steps` global steps with multiple workers per rank, then checkpoint."""
|
||
assert num_splits % (world_size * num_workers) == 0
|
||
assert global_batch_size % (world_size * num_workers) == 0
|
||
samples_per_worker_per_step = global_batch_size // (world_size * num_workers)
|
||
|
||
datasets: dict[tuple[int, int], StreamingDataset] = {}
|
||
iters: dict[tuple[int, int], object] = {}
|
||
for rank in range(world_size):
|
||
for worker_id in range(num_workers):
|
||
ds = _make_dataset(
|
||
table,
|
||
rank,
|
||
world_size,
|
||
num_splits=num_splits,
|
||
shuffle_seed=shuffle_seed,
|
||
epoch=epoch,
|
||
worker_info_override=FakeWorkerInfo(
|
||
id=worker_id, num_workers=num_workers
|
||
),
|
||
)
|
||
datasets[(rank, worker_id)] = ds
|
||
iters[(rank, worker_id)] = iter(ds)
|
||
|
||
seen: list[frozenset[int]] = []
|
||
for _ in range(steps):
|
||
batch: set[int] = set()
|
||
for it in iters.values():
|
||
for _ in range(samples_per_worker_per_step):
|
||
batch.add(next(it)["id"])
|
||
seen.append(frozenset(batch))
|
||
|
||
# State is per-split → same across all rank/worker combinations.
|
||
checkpoint = datasets[(0, 0)].state_dict()
|
||
return seen, checkpoint
|
||
|
||
|
||
# ── Multi-worker correctness ──────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize("world_size,num_workers", MULTI_WORKER_TOPOLOGIES)
|
||
def test_multi_worker_full_coverage(lance_table, world_size, num_workers):
|
||
"""Every sample is seen exactly once across all workers and all steps."""
|
||
batches = _collect_global_batches_multi_worker(lance_table, world_size, num_workers)
|
||
all_seen = sorted(sid for b in batches for sid in b)
|
||
assert all_seen == list(range(NUM_ROWS)), (
|
||
f"world_size={world_size} num_workers={num_workers}: "
|
||
f"expected IDs 0..{NUM_ROWS - 1}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size,num_workers", MULTI_WORKER_TOPOLOGIES)
|
||
def test_multi_worker_correct_step_count(lance_table, world_size, num_workers):
|
||
"""Step count equals NUM_ROWS // GLOBAL_BATCH_SIZE regardless of topology."""
|
||
batches = _collect_global_batches_multi_worker(lance_table, world_size, num_workers)
|
||
assert len(batches) == STEPS_PER_EPOCH, (
|
||
f"world_size={world_size} num_workers={num_workers}: "
|
||
f"expected {STEPS_PER_EPOCH} steps, got {len(batches)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("world_size,num_workers", MULTI_WORKER_TOPOLOGIES)
|
||
def test_multi_worker_no_cross_worker_overlap(lance_table, world_size, num_workers):
|
||
"""Workers within the same rank have disjoint sample sets."""
|
||
for rank in range(world_size):
|
||
per_worker: list[set[int]] = []
|
||
for worker_id in range(num_workers):
|
||
ds = _make_dataset(
|
||
lance_table,
|
||
rank,
|
||
world_size,
|
||
worker_info_override=FakeWorkerInfo(
|
||
id=worker_id, num_workers=num_workers
|
||
),
|
||
)
|
||
per_worker.append({item["id"] for item in ds})
|
||
|
||
for i in range(num_workers):
|
||
for j in range(i + 1, num_workers):
|
||
overlap = per_worker[i] & per_worker[j]
|
||
assert not overlap, (
|
||
f"rank={rank} workers {i} and {j} share samples: {sorted(overlap)}"
|
||
)
|
||
|
||
|
||
# ── Elastic determinism with num_workers ─────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize("world_size,num_workers", MULTI_WORKER_TOPOLOGIES)
|
||
def test_multi_worker_same_global_batches_as_single_worker(
|
||
lance_table, world_size, num_workers
|
||
):
|
||
"""Global batches produced with num_workers > 1 match those from num_workers=1.
|
||
|
||
PyTorch DataLoader collects a complete batch from each worker before moving
|
||
to the next. With contiguous split assignment, worker k's batch at step s
|
||
contains the same samples as the corresponding segment of the single-worker
|
||
batch at step s. The union across all workers at step s is therefore the
|
||
same global batch.
|
||
"""
|
||
reference = _collect_global_batches(lance_table, world_size)
|
||
batches = _collect_global_batches_multi_worker(lance_table, world_size, num_workers)
|
||
|
||
assert len(batches) == len(reference)
|
||
for step, (ref, got) in enumerate(zip(reference, batches)):
|
||
assert ref == got, (
|
||
f"Global batch at step {step} differs between num_workers=1 and "
|
||
f"num_workers={num_workers} (world_size={world_size}).\n"
|
||
f" num_workers=1 → {sorted(ref)}\n"
|
||
f" num_workers={num_workers} → {sorted(got)}"
|
||
)
|
||
|
||
|
||
def test_multi_worker_elastic_det_across_worker_counts(lance_table):
|
||
"""Global batches match for every compatible world_size / num_workers pair."""
|
||
reference = _collect_global_batches(lance_table, world_size=1)
|
||
for world_size, num_workers in MULTI_WORKER_TOPOLOGIES:
|
||
batches = _collect_global_batches_multi_worker(
|
||
lance_table, world_size, num_workers
|
||
)
|
||
assert batches == reference, (
|
||
f"Mismatch for world_size={world_size}, num_workers={num_workers}"
|
||
)
|
||
|
||
|
||
# ── Resumability with num_workers ─────────────────────────────────────────────
|
||
|
||
|
||
def test_streaming_dataloader_commits_only_consumed_worker_batches(tmp_path):
|
||
"""Prefetched worker state is committed only as the trainer receives it."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"worker_commit", pa.table({"id": [1, 2, 3, 4, 10, 20, 30, 40]})
|
||
)
|
||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
prefetch_factor=4,
|
||
)
|
||
iterator = iter(loader)
|
||
try:
|
||
first = next(iterator)["id"].tolist()
|
||
|
||
assert first == [1, 2]
|
||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2, 0]
|
||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||
dataset.state_dict()
|
||
|
||
second = next(iterator)["id"].tolist()
|
||
assert second == [10, 20]
|
||
checkpoint = dataset.state_dict()
|
||
assert checkpoint["samples_consumed_per_split"] == [2, 2]
|
||
uninterrupted = [batch["id"].tolist() for batch in iterator]
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
resumed = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
resumed.load_state_dict(checkpoint)
|
||
resumed_loader = StreamingDataLoader(
|
||
resumed,
|
||
batch_size=2,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
prefetch_factor=4,
|
||
)
|
||
resumed_iterator = iter(resumed_loader)
|
||
try:
|
||
remaining = [batch["id"].tolist() for batch in resumed_iterator]
|
||
finally:
|
||
resumed_iterator._shutdown_workers()
|
||
assert remaining == uninterrupted == [[3, 4], [30, 40]]
|
||
|
||
|
||
def test_distributed_checkpoint_uses_rank_local_worker_boundary(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("rank_boundary", pa.table({"id": list(range(8))}))
|
||
dataset = StreamingDataset(
|
||
table,
|
||
num_splits=4,
|
||
shuffle=False,
|
||
rank=0,
|
||
world_size=2,
|
||
)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=1,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
)
|
||
iterator = iter(loader)
|
||
try:
|
||
assert next(iterator)["id"].tolist() == [0]
|
||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [
|
||
1,
|
||
0,
|
||
0,
|
||
0,
|
||
]
|
||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||
dataset.state_dict()
|
||
|
||
assert next(iterator)["id"].tolist() == [2]
|
||
checkpoint = dataset.state_dict()
|
||
remaining = [batch["id"].tolist() for batch in iterator]
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
assert checkpoint["samples_consumed_per_split"] == [1, 1, 0, 0]
|
||
assert remaining == [[1], [3]]
|
||
|
||
|
||
def test_standard_dataloader_rejects_stale_parent_checkpoint(tmp_path):
|
||
"""A standard DataLoader must not expose prefetched producer progress."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("untracked_workers", pa.table({"id": [1, 2, 10, 20]}))
|
||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
# Merely constructing the checkpoint-aware loader must not authorize a
|
||
# later plain DataLoader's worker progress.
|
||
StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||
loader = torch.utils.data.DataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
)
|
||
iterator = iter(loader)
|
||
try:
|
||
assert next(iterator)["id"].tolist() == [1, 2]
|
||
with pytest.raises(RuntimeError, match="Use StreamingDataLoader"):
|
||
dataset.state_dict()
|
||
list(iterator)
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
|
||
def test_streaming_dataloader_rejects_persistent_workers(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("persistent_workers", pa.table({"id": [1, 2]}))
|
||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
|
||
with pytest.raises(ValueError, match="persistent_workers=True"):
|
||
StreamingDataLoader(
|
||
dataset,
|
||
batch_size=1,
|
||
num_workers=2,
|
||
persistent_workers=True,
|
||
)
|
||
|
||
|
||
def test_collate_failure_invalidates_consumer_checkpoint(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"collate_failure", pa.table({"id": [0, 1, 2, 3, 100, 101, 102, 103]})
|
||
)
|
||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
collate_fn=_collate_with_first_batch_error,
|
||
prefetch_factor=2,
|
||
)
|
||
iterator = iter(loader)
|
||
try:
|
||
with pytest.raises(ValueError, match="first batch fails"):
|
||
next(iterator)
|
||
assert next(iterator) == [100, 101]
|
||
assert next(iterator) == [2, 3]
|
||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||
dataset.state_dict()
|
||
list(iterator)
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
|
||
def test_collate_stop_iteration_invalidates_consumer_checkpoint(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("collate_stop", pa.table({"id": list(range(6))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=0,
|
||
collate_fn=_collate_with_first_batch_stop,
|
||
)
|
||
iterator = iter(loader)
|
||
|
||
with pytest.raises(RuntimeError, match="collate_fn raised StopIteration"):
|
||
next(iterator)
|
||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||
dataset.state_dict()
|
||
assert list(iterator) == [[2, 3], [4, 5]]
|
||
|
||
|
||
def test_batch_base_exception_invalidates_consumer_checkpoint(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("collate_interrupt", pa.table({"id": list(range(6))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=0,
|
||
collate_fn=_collate_with_first_batch_interrupt,
|
||
)
|
||
iterator = iter(loader)
|
||
|
||
with pytest.raises(KeyboardInterrupt, match="first batch interrupted"):
|
||
next(iterator)
|
||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||
dataset.state_dict()
|
||
assert list(iterator) == [[2, 3], [4, 5]]
|
||
|
||
|
||
def test_parent_commit_base_exception_invalidates_consumer_checkpoint(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("commit_interrupt", pa.table({"id": list(range(4))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||
iterator = iter(loader)
|
||
real_commit = dataset._commit_worker_state
|
||
|
||
def interrupt_after_commit(state, *, require_uniform):
|
||
real_commit(state, require_uniform=require_uniform)
|
||
raise KeyboardInterrupt("after parent commit")
|
||
|
||
with patch.object(
|
||
dataset, "_commit_worker_state", side_effect=interrupt_after_commit
|
||
):
|
||
with pytest.raises(KeyboardInterrupt, match="after parent commit"):
|
||
next(iterator)
|
||
|
||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||
dataset.state_dict()
|
||
|
||
|
||
def test_direct_iteration_surfaces_prefetch_failure_before_committing_row(
|
||
tmp_path, monkeypatch
|
||
):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("prefetch_failure", pa.table({"id": list(range(4))}))
|
||
release = threading.Event()
|
||
failed = threading.Event()
|
||
real_getitems = streaming.Permutation.__getitems__
|
||
|
||
def controlled_getitems(permutation, indices):
|
||
if indices and indices[0] >= 2:
|
||
assert release.wait(timeout=5)
|
||
failed.set()
|
||
raise RuntimeError("later prefetched I/O failed")
|
||
return real_getitems(permutation, indices)
|
||
|
||
class SignalDict(dict):
|
||
def __setitem__(self, key, value):
|
||
super().__setitem__(key, value)
|
||
release.set()
|
||
assert failed.wait(timeout=5)
|
||
|
||
monkeypatch.setattr(streaming.Permutation, "__getitems__", controlled_getitems)
|
||
dataset = StreamingDataset(
|
||
table,
|
||
num_splits=1,
|
||
shuffle=False,
|
||
read_batch_size=2,
|
||
io_queue_depth=2,
|
||
)
|
||
dataset._resume_positions = SignalDict()
|
||
iterator = iter(dataset)
|
||
|
||
assert next(iterator)["id"] == 0
|
||
with pytest.raises(RuntimeError, match="later prefetched I/O failed"):
|
||
next(iterator)
|
||
|
||
checkpoint = dataset.state_dict()
|
||
assert checkpoint["samples_consumed_per_split"] == [1]
|
||
assert checkpoint["positions_consumed_per_split"] == [1]
|
||
|
||
|
||
@pytest.mark.parametrize("workers", [0, 1, 2])
|
||
def test_streaming_dataloader_rejects_drop_last(tmp_path, workers):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("drop_last", pa.table({"id": [0, 1, 2]}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
worker_options = {"multiprocessing_context": "spawn"} if workers else {}
|
||
|
||
with pytest.raises(ValueError, match="drop_last=True"):
|
||
StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=workers,
|
||
drop_last=True,
|
||
**worker_options,
|
||
)
|
||
|
||
|
||
def test_streaming_dataloader_owns_one_iterator_until_teardown(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("iterator_owner", pa.table({"id": list(range(4))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=1,
|
||
multiprocessing_context="spawn",
|
||
)
|
||
|
||
first = iter(loader)
|
||
try:
|
||
assert next(first)["id"].tolist() == [0, 1]
|
||
with pytest.raises(RuntimeError, match="concurrent iteration"):
|
||
iter(loader)
|
||
finally:
|
||
first._shutdown_workers()
|
||
|
||
second = iter(loader)
|
||
try:
|
||
assert [batch["id"].tolist() for batch in second] == [[2, 3]]
|
||
except BaseException:
|
||
second._shutdown_workers()
|
||
raise
|
||
|
||
# Natural exhaustion releases ownership too.
|
||
third = iter(loader)
|
||
try:
|
||
assert list(third) == []
|
||
finally:
|
||
third._shutdown_workers()
|
||
|
||
|
||
def test_zero_worker_shutdown_closes_inner_iterator_before_release(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("zero_worker_shutdown", pa.table({"id": list(range(6))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||
|
||
first = iter(loader)
|
||
assert next(first)["id"].tolist() == [0, 1]
|
||
first._shutdown_workers()
|
||
|
||
assert dataset._consumer_iterator_active is False
|
||
assert dataset._raw_batches_ref is None
|
||
second = iter(loader)
|
||
try:
|
||
with pytest.raises(StopIteration):
|
||
next(first)
|
||
assert next(second)["id"].tolist() == [2, 3]
|
||
finally:
|
||
second._shutdown_workers()
|
||
|
||
|
||
def test_direct_and_loader_admission_share_one_atomic_lease(tmp_path, monkeypatch):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("direct_loader_lease", pa.table({"id": list(range(4))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||
entered = threading.Event()
|
||
release = threading.Event()
|
||
direct_result = []
|
||
direct_error = []
|
||
contender = []
|
||
real_resolve = dataset._resolve_my_splits
|
||
|
||
def controlled_resolve():
|
||
if threading.current_thread().name == "direct-start":
|
||
entered.set()
|
||
assert release.wait(timeout=5)
|
||
return real_resolve()
|
||
|
||
def advance_direct(iterator):
|
||
try:
|
||
direct_result.append(next(iterator)["id"])
|
||
except BaseException as exc:
|
||
direct_error.append(exc)
|
||
|
||
monkeypatch.setattr(dataset, "_resolve_my_splits", controlled_resolve)
|
||
direct = iter(dataset)
|
||
thread = threading.Thread(
|
||
target=advance_direct, args=(direct,), name="direct-start"
|
||
)
|
||
thread.start()
|
||
assert entered.wait(timeout=5)
|
||
try:
|
||
with pytest.raises(RuntimeError, match="concurrent iteration"):
|
||
contender.append(iter(loader))
|
||
finally:
|
||
release.set()
|
||
thread.join(timeout=5)
|
||
if contender:
|
||
contender[0]._shutdown_workers()
|
||
direct.close()
|
||
|
||
assert not thread.is_alive()
|
||
assert direct_error == []
|
||
assert direct_result == [0]
|
||
|
||
|
||
def test_loader_acquires_before_snapshot_and_cleans_interrupted_acquire(
|
||
tmp_path, monkeypatch
|
||
):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("lease_snapshot", pa.table({"id": list(range(4))}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||
first = iter(loader)
|
||
assert next(first)["id"].tolist() == [0, 1]
|
||
|
||
entered = threading.Event()
|
||
release = threading.Event()
|
||
pending = []
|
||
pending_errors = []
|
||
observed_snapshots = []
|
||
real_acquire = dataset._acquire_consumer_iterator
|
||
real_snapshot = dataset._checkpoint_snapshot
|
||
|
||
def controlled_acquire():
|
||
if threading.current_thread().name == "stale-start":
|
||
entered.set()
|
||
assert release.wait(timeout=5)
|
||
return real_acquire()
|
||
|
||
def recording_snapshot():
|
||
state = real_snapshot()
|
||
if threading.current_thread().name == "stale-start":
|
||
observed_snapshots.append(state["samples_consumed_per_split"])
|
||
return state
|
||
|
||
def create_pending_iterator():
|
||
try:
|
||
pending.append(iter(loader))
|
||
except BaseException as exc:
|
||
pending_errors.append(exc)
|
||
|
||
monkeypatch.setattr(dataset, "_acquire_consumer_iterator", controlled_acquire)
|
||
monkeypatch.setattr(dataset, "_checkpoint_snapshot", recording_snapshot)
|
||
thread = threading.Thread(target=create_pending_iterator, name="stale-start")
|
||
thread.start()
|
||
assert entered.wait(timeout=5)
|
||
assert next(first)["id"].tolist() == [2, 3]
|
||
with pytest.raises(StopIteration):
|
||
next(first)
|
||
release.set()
|
||
thread.join(timeout=5)
|
||
|
||
assert not thread.is_alive()
|
||
assert pending_errors == []
|
||
assert observed_snapshots == [[4]]
|
||
assert len(pending) == 1
|
||
assert list(pending[0]) == []
|
||
assert dataset.state_dict()["samples_consumed_per_split"] == [4]
|
||
|
||
def interrupted_acquire():
|
||
real_acquire()
|
||
raise KeyboardInterrupt("after acquire")
|
||
|
||
monkeypatch.setattr(dataset, "_acquire_consumer_iterator", interrupted_acquire)
|
||
with pytest.raises(KeyboardInterrupt, match="after acquire"):
|
||
iter(loader)
|
||
assert dataset._consumer_iterator_active is False
|
||
|
||
|
||
def test_consumer_iterator_lease_publication_is_atomic(tmp_path, monkeypatch):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("atomic_lease", pa.table({"id": [0, 1]}))
|
||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||
loader = StreamingDataLoader(dataset, batch_size=1, num_workers=0)
|
||
real_get_ident = streaming.threading.get_ident
|
||
calls = 0
|
||
|
||
def interrupt_during_publication():
|
||
nonlocal calls
|
||
calls += 1
|
||
if calls == 1:
|
||
raise KeyboardInterrupt("during lease mutation")
|
||
return real_get_ident()
|
||
|
||
monkeypatch.setattr(streaming.threading, "get_ident", interrupt_during_publication)
|
||
with pytest.raises(KeyboardInterrupt, match="during lease mutation"):
|
||
iter(loader)
|
||
monkeypatch.setattr(streaming.threading, "get_ident", real_get_ident)
|
||
|
||
assert dataset._consumer_iterator_active is False
|
||
iterator = iter(loader)
|
||
try:
|
||
assert next(iterator)["id"].tolist() == [0]
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
|
||
def test_streaming_dataloader_rejects_dataset_iter_override(tmp_path):
|
||
class CustomizedDataset(StreamingDataset):
|
||
def __iter__(self):
|
||
return iter([1000, 1001])
|
||
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("custom_iteration", pa.table({"id": [0, 1, 2]}))
|
||
dataset = CustomizedDataset(table, num_splits=1, shuffle=False)
|
||
|
||
assert list(dataset) == [1000, 1001]
|
||
with pytest.raises(TypeError, match="override __iter__"):
|
||
StreamingDataLoader(
|
||
dataset,
|
||
batch_size=2,
|
||
num_workers=0,
|
||
collate_fn=list,
|
||
)
|
||
|
||
|
||
def test_interleaved_adapters_do_not_authorize_plain_iteration(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table_a = db.create_table("adapter_a", pa.table({"id": [0, 1]}))
|
||
table_b = db.create_table("adapter_b", pa.table({"id": [10, 11]}))
|
||
dataset_a = StreamingDataset(table_a, num_splits=1, shuffle=False)
|
||
dataset_b = StreamingDataset(table_b, num_splits=1, shuffle=False)
|
||
initial_state = dataset_a.state_dict()
|
||
|
||
owner_a = dataset_a._acquire_consumer_iterator()
|
||
owner_b = dataset_b._acquire_consumer_iterator()
|
||
try:
|
||
iterator_a = iter(streaming._StreamingDatasetAdapter(dataset_a))
|
||
iterator_b = iter(streaming._StreamingDatasetAdapter(dataset_b))
|
||
assert next(iterator_a).data["id"] == 0
|
||
assert next(iterator_b).data["id"] == 10
|
||
assert [sample.data["id"] for sample in iterator_a] == [1]
|
||
assert [sample.data["id"] for sample in iterator_b] == [11]
|
||
finally:
|
||
dataset_a._release_consumer_iterator(owner_a)
|
||
dataset_b._release_consumer_iterator(owner_b)
|
||
|
||
dataset_a.load_state_dict(initial_state)
|
||
with patch(
|
||
"lancedb.streaming.get_worker_info",
|
||
return_value=FakeWorkerInfo(id=0, num_workers=1),
|
||
):
|
||
plain_iterator = iter(dataset_a)
|
||
assert next(plain_iterator)["id"] == 0
|
||
plain_iterator.close()
|
||
|
||
assert dataset_a._untracked_worker_iteration[0] == 1
|
||
with pytest.raises(RuntimeError, match="Use StreamingDataLoader"):
|
||
dataset_a.state_dict()
|
||
|
||
|
||
def test_resume_from_partial_split_cycle_preserves_remaining_order(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("partial_cycle", pa.table({"id": [1, 2, 10, 20]}))
|
||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
iterator = iter(dataset)
|
||
|
||
assert next(iterator)["id"] == 1
|
||
checkpoint = dataset.state_dict()
|
||
iterator.close()
|
||
assert checkpoint["samples_consumed_per_split"] == [1, 0]
|
||
|
||
resumed = StreamingDataset(table, num_splits=2, shuffle=False)
|
||
resumed.load_state_dict(checkpoint)
|
||
assert [row["id"] for row in resumed] == [10, 2, 20]
|
||
|
||
|
||
def test_partial_cycle_resume_preserves_skip_truncation(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"partial_skip", pa.table({"id": [0, 1, 2, 3, 100, 101, 102, 103]})
|
||
)
|
||
kwargs = dict(
|
||
num_splits=2,
|
||
shuffle=False,
|
||
transform=_failing_transform({1, 2, 3}),
|
||
on_transform_error="skip",
|
||
)
|
||
dataset = StreamingDataset(table, **kwargs)
|
||
iterator = iter(dataset)
|
||
|
||
assert next(iterator)["id"] == 0
|
||
checkpoint = dataset.state_dict()
|
||
uninterrupted = [row["id"] for row in iterator]
|
||
|
||
resumed = StreamingDataset(table, **kwargs)
|
||
resumed.load_state_dict(checkpoint)
|
||
assert [row["id"] for row in resumed] == uninterrupted == [100]
|
||
|
||
|
||
def test_multi_worker_resumability_same_topology(lance_table):
|
||
"""Checkpoint with num_workers=2, resume with num_workers=2: exact continuation."""
|
||
world_size = 1
|
||
num_workers = 2
|
||
checkpoint_at_step = 4
|
||
|
||
reference = _collect_global_batches_multi_worker(
|
||
lance_table, world_size, num_workers
|
||
)
|
||
seen_before, checkpoint = _advance_and_checkpoint_multi_worker(
|
||
lance_table, world_size, num_workers, checkpoint_at_step
|
||
)
|
||
|
||
# Resume: create new datasets, load checkpoint, collect remaining.
|
||
remaining: list[frozenset[int]] = []
|
||
samples_per_worker_per_step = GLOBAL_BATCH_SIZE // (world_size * num_workers)
|
||
datasets = {}
|
||
iters = {}
|
||
for rank in range(world_size):
|
||
for worker_id in range(num_workers):
|
||
ds = _make_dataset(
|
||
lance_table,
|
||
rank,
|
||
world_size,
|
||
worker_info_override=FakeWorkerInfo(
|
||
id=worker_id, num_workers=num_workers
|
||
),
|
||
)
|
||
ds.load_state_dict(checkpoint)
|
||
datasets[(rank, worker_id)] = ds
|
||
iters[(rank, worker_id)] = iter(ds)
|
||
|
||
_STOP3 = object()
|
||
while True:
|
||
batch: set[int] = set()
|
||
exhausted = 0
|
||
for it in iters.values():
|
||
for _ in range(samples_per_worker_per_step):
|
||
val = next(it, _STOP3)
|
||
if val is _STOP3:
|
||
exhausted += 1
|
||
break
|
||
batch.add(val["id"])
|
||
if exhausted == len(iters):
|
||
break
|
||
assert exhausted == 0
|
||
remaining.append(frozenset(batch))
|
||
|
||
for step, (ref, got) in enumerate(zip(reference, seen_before)):
|
||
assert ref == got, f"Pre-checkpoint step {step} doesn't match reference"
|
||
for offset, (ref, got) in enumerate(zip(reference[checkpoint_at_step:], remaining)):
|
||
step = checkpoint_at_step + offset
|
||
assert ref == got, f"Post-resume step {step} doesn't match reference"
|
||
|
||
all_seen = sorted(sid for b in seen_before + remaining for sid in b)
|
||
assert all_seen == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_multi_worker_resumability_worker_count_change(lance_table):
|
||
"""Checkpoint with num_workers=1, resume with num_workers=2.
|
||
|
||
Because state is per-split, it is independent of num_workers just as it is
|
||
independent of world_size.
|
||
"""
|
||
world_size = 1
|
||
checkpoint_at_step = 4
|
||
reference = _collect_global_batches(lance_table, world_size=1)
|
||
|
||
seen_before, checkpoint = _advance_and_checkpoint(
|
||
lance_table, world_size=1, steps=checkpoint_at_step
|
||
)
|
||
|
||
# Resume with num_workers=2 using the checkpoint.
|
||
num_workers = 2
|
||
samples_per_worker_per_step = GLOBAL_BATCH_SIZE // (world_size * num_workers)
|
||
iters = {}
|
||
for worker_id in range(num_workers):
|
||
ds = _make_dataset(
|
||
lance_table,
|
||
rank=0,
|
||
world_size=world_size,
|
||
worker_info_override=FakeWorkerInfo(id=worker_id, num_workers=num_workers),
|
||
)
|
||
ds.load_state_dict(checkpoint)
|
||
iters[worker_id] = iter(ds)
|
||
|
||
_STOP4 = object()
|
||
remaining: list[frozenset[int]] = []
|
||
while True:
|
||
batch: set[int] = set()
|
||
exhausted = 0
|
||
for it in iters.values():
|
||
for _ in range(samples_per_worker_per_step):
|
||
val = next(it, _STOP4)
|
||
if val is _STOP4:
|
||
exhausted += 1
|
||
break
|
||
batch.add(val["id"])
|
||
if exhausted == len(iters):
|
||
break
|
||
assert exhausted == 0
|
||
remaining.append(frozenset(batch))
|
||
|
||
for offset, (ref, got) in enumerate(zip(reference[checkpoint_at_step:], remaining)):
|
||
step = checkpoint_at_step + offset
|
||
assert ref == got, (
|
||
f"Post-resume step {step} doesn't match reference when switching "
|
||
f"from num_workers=1 to num_workers=2.\n"
|
||
f" reference: {sorted(ref)}\n"
|
||
f" got: {sorted(got)}"
|
||
)
|
||
|
||
before_ids = {sid for b in seen_before for sid in b}
|
||
after_ids = {sid for b in remaining for sid in b}
|
||
assert before_ids.isdisjoint(after_ids)
|
||
assert before_ids | after_ids == set(range(NUM_ROWS))
|
||
|
||
|
||
# ── worker_info_override warning behaviour ────────────────────────────────────
|
||
|
||
|
||
def test_worker_info_override_logs_warning_when_torch_worker_active(
|
||
lance_table, caplog
|
||
):
|
||
"""When worker_info_override is set but get_worker_info() also returns a
|
||
value (i.e. the dataset is being iterated inside a real DataLoader worker),
|
||
the dataset must log a warning that the override takes precedence and that
|
||
this may produce incorrect or duplicated data.
|
||
|
||
The warning is important because silently ignoring the real worker assignment
|
||
would cause all workers to read the same data.
|
||
"""
|
||
override = FakeWorkerInfo(id=0, num_workers=2)
|
||
ds = _make_dataset(lance_table, rank=0, world_size=1, worker_info_override=override)
|
||
|
||
fake_torch_info = FakeWorkerInfo(id=0, num_workers=4)
|
||
with patch("lancedb.streaming.get_worker_info", return_value=fake_torch_info):
|
||
with caplog.at_level(logging.WARNING, logger="lancedb.streaming"):
|
||
list(ds)
|
||
|
||
warning_messages = [
|
||
r.message for r in caplog.records if r.levelno >= logging.WARNING
|
||
]
|
||
assert warning_messages, (
|
||
"Expected at least one WARNING when worker_info_override is active inside "
|
||
"a real DataLoader worker, but no warnings were logged."
|
||
)
|
||
combined = " ".join(warning_messages).lower()
|
||
assert "override" in combined or "worker_info_override" in combined, (
|
||
f"Warning message did not mention the override. Messages: {warning_messages}"
|
||
)
|
||
|
||
|
||
def test_worker_info_override_no_warning_in_main_process(lance_table, caplog):
|
||
"""When get_worker_info() returns None (main process / num_workers=0),
|
||
using worker_info_override is the normal testing path and must not warn.
|
||
"""
|
||
override = FakeWorkerInfo(id=0, num_workers=2)
|
||
ds = _make_dataset(lance_table, rank=0, world_size=1, worker_info_override=override)
|
||
|
||
with patch("lancedb.streaming.get_worker_info", return_value=None):
|
||
with caplog.at_level(logging.WARNING, logger="lancedb.streaming"):
|
||
list(ds)
|
||
|
||
warning_messages = [
|
||
r.message for r in caplog.records if r.levelno >= logging.WARNING
|
||
]
|
||
assert not warning_messages, (
|
||
f"Unexpected warnings when override used in main process: {warning_messages}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Prefetch queue depth tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_concurrent_iteration_raises(lance_table):
|
||
"""Starting a second iterator while one is already active must raise."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
it1 = iter(ds)
|
||
next(it1) # advance it1 so the pipeline is live
|
||
|
||
it2 = iter(ds)
|
||
with pytest.raises(RuntimeError, match="concurrent"):
|
||
next(it2)
|
||
|
||
|
||
def test_raw_queue_depth_zero_when_not_iterating(lance_table):
|
||
"""raw_queue_depth is 0 before iteration starts and after it ends."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
assert ds.raw_queue_depth == 0
|
||
|
||
list(ds) # drain the whole epoch
|
||
|
||
assert ds.raw_queue_depth == 0
|
||
|
||
|
||
def test_prefetch_queue_depth_zero_when_not_iterating(lance_table):
|
||
"""prefetch_queue_depth is 0 before iteration starts and after it ends."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
assert ds.prefetch_queue_depth == 0
|
||
|
||
list(ds) # drain the whole epoch
|
||
|
||
assert ds.prefetch_queue_depth == 0
|
||
|
||
|
||
def test_prefetch_queue_depth_positive_during_iteration(lance_table):
|
||
"""prefetch_queue_depth is > 0 while rows are being yielded."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
it = iter(ds)
|
||
next(it) # advance past the first yield; pipeline is now primed
|
||
# The other splits still have their initial futures in flight.
|
||
assert ds.prefetch_queue_depth > 0
|
||
|
||
list(it) # exhaust the remaining rows
|
||
|
||
assert ds.prefetch_queue_depth == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Transform tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_fetch_and_transform_time_zero_before_iteration(lance_table):
|
||
"""fetch_time and transform_time start at 0."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
assert ds.fetch_time == 0.0
|
||
assert ds.transform_time == 0.0
|
||
|
||
|
||
def test_fetch_and_transform_time_positive_after_iteration(lance_table):
|
||
"""Both timers are positive after a full epoch."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
list(ds)
|
||
assert ds.fetch_time > 0.0
|
||
assert ds.transform_time > 0.0
|
||
|
||
|
||
def test_fetch_time_excludes_transform(lance_table):
|
||
"""fetch_time does not include transform time: fetch + transform < total wall time,
|
||
and neither counter bleeds into the other."""
|
||
import pyarrow as pa
|
||
import time
|
||
|
||
def slow_transform(batch: pa.RecordBatch) -> list:
|
||
time.sleep(0.01) # 10 ms of artificial transform work
|
||
return batch.to_pydict()["id"]
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=slow_transform,
|
||
)
|
||
list(ds)
|
||
|
||
# The slow transform should dominate transform_time.
|
||
assert ds.transform_time > ds.fetch_time
|
||
|
||
|
||
def test_bytes_loaded_increases_after_iteration(lance_table):
|
||
"""bytes_loaded is 0 before iteration and positive after."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
assert ds.bytes_loaded == 0
|
||
|
||
list(ds)
|
||
|
||
assert ds.bytes_loaded > 0
|
||
|
||
|
||
def test_bytes_loaded_measured_before_transform(lance_table):
|
||
"""bytes_loaded measures raw Arrow size even when transform discards everything."""
|
||
import pyarrow as pa
|
||
|
||
# This transform throws away every value. If bytes_loaded were measured
|
||
# after the transform, it would see no Arrow data and stay at 0.
|
||
def discard_everything(batch: pa.RecordBatch) -> list:
|
||
return [None] * batch.num_rows
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=discard_everything,
|
||
)
|
||
list(ds)
|
||
|
||
assert ds.bytes_loaded > 0
|
||
|
||
|
||
def test_transform_is_applied(lance_table):
|
||
"""A custom transform passed to StreamingDataset is forwarded to the
|
||
underlying Permutation and applied to every yielded item."""
|
||
import pyarrow as pa
|
||
|
||
def id_only(batch: pa.RecordBatch) -> list[int]:
|
||
return batch.column("id").to_pylist()
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=id_only,
|
||
)
|
||
items = list(ds)
|
||
|
||
assert len(items) == NUM_ROWS
|
||
assert all(isinstance(item, int) for item in items), (
|
||
f"Expected ints from transform, got {type(items[0])}"
|
||
)
|
||
assert sorted(items) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_transform_none_yields_dicts(lance_table):
|
||
"""With no transform (the default), items are plain Python dicts."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
)
|
||
items = list(ds)
|
||
|
||
assert len(items) == NUM_ROWS
|
||
assert all(isinstance(item, dict) for item in items)
|
||
assert all("id" in item for item in items)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("configured", "detected", "expected"),
|
||
[(2, 8, 2), (None, 3, 3), (None, None, 1)],
|
||
)
|
||
def test_transform_parallelism_configures_executor(
|
||
lance_table, monkeypatch, configured, detected, expected
|
||
):
|
||
"""Explicit transform parallelism overrides the detected CPU count."""
|
||
real_executor = streaming.ThreadPoolExecutor
|
||
monkeypatch.setattr(streaming.os, "cpu_count", lambda: detected)
|
||
|
||
with patch.object(streaming, "ThreadPoolExecutor", wraps=real_executor) as executor:
|
||
list(
|
||
StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform_parallelism=configured,
|
||
)
|
||
)
|
||
|
||
assert executor.call_args_list[-1].kwargs["max_workers"] == expected
|
||
|
||
|
||
@pytest.mark.parametrize("transform_parallelism", [0, -1])
|
||
def test_transform_parallelism_must_be_positive(lance_table, transform_parallelism):
|
||
with pytest.raises(
|
||
ValueError, match="transform_parallelism must be greater than 0"
|
||
):
|
||
StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
transform_parallelism=transform_parallelism,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Backpressure / transform_queue_depth tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("transform_queue_depth", [0, -1])
|
||
def test_transform_queue_depth_must_be_positive(lance_table, transform_queue_depth):
|
||
"""transform_queue_depth=0 or negative must raise ValueError."""
|
||
with pytest.raises(
|
||
ValueError, match="transform_queue_depth must be greater than 0"
|
||
):
|
||
StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
transform_queue_depth=transform_queue_depth,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("transform_queue_depth", [1, 2, 4])
|
||
def test_transform_queue_depth_correctness(lance_table, transform_queue_depth):
|
||
"""With backpressure enabled, every row is still yielded exactly once."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform_queue_depth=transform_queue_depth,
|
||
read_batch_size=8,
|
||
)
|
||
items = list(ds)
|
||
assert sorted(item["id"] for item in items) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_transform_queue_depth_matches_no_backpressure(lance_table):
|
||
"""With backpressure enabled the same samples are produced as without it."""
|
||
ds_unlimited = StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
|
||
)
|
||
ds_limited = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform_queue_depth=1,
|
||
)
|
||
assert [item["id"] for item in ds_unlimited] == [
|
||
item["id"] for item in ds_limited
|
||
], "transform_queue_depth must not affect the sample ordering or set"
|
||
|
||
|
||
def test_transform_queue_depth_bounds_cooked_rows(lance_table):
|
||
"""prefetch_queue_depth stays within transform_queue_depth * read_batch_size
|
||
per split when observed from the main thread during iteration."""
|
||
n_splits = 4
|
||
batch_size = 8
|
||
cooked_depth = 2
|
||
# max cooked rows across all 4 splits: 4 * 2 * 8 = 64
|
||
max_allowed = n_splits * cooked_depth * batch_size
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=n_splits,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform_queue_depth=cooked_depth,
|
||
read_batch_size=batch_size,
|
||
transform_parallelism=1,
|
||
world_size=1,
|
||
)
|
||
|
||
peak = 0
|
||
for _ in ds:
|
||
depth = ds.prefetch_queue_depth
|
||
if depth > peak:
|
||
peak = depth
|
||
|
||
# The main thread observes depth *after* popping a row, so the peak is at
|
||
# most max_allowed (one row already popped from the split just served).
|
||
assert peak <= max_allowed, (
|
||
f"prefetch_queue_depth peaked at {peak}, expected <= {max_allowed}"
|
||
)
|
||
|
||
|
||
def test_transform_queue_depth_does_not_admit_at_capacity_minus_one(tmp_path):
|
||
"""Admission requires a full read_batch_size of free space, not just one slot.
|
||
|
||
The test intercepts ThreadPoolExecutor.submit to make I/O calls execute
|
||
synchronously on the main thread. This ensures all raw batches land in
|
||
raw_batches (via _drain_io) before _try_submit_tx evaluates the admission
|
||
predicate for the first time. Without this, the I/O future for batch N+1
|
||
might still be in io_pending at the capacity-minus-one transition, leaving
|
||
raw_batches empty and causing _try_submit_tx to skip the admission check
|
||
entirely — so both the correct and the broken predicate produce depth=0
|
||
observations and the test cannot distinguish them.
|
||
|
||
With all raw batches pre-loaded in raw_batches the 4→3 cooked transition
|
||
(consuming one row from a full cooked queue) always triggers _try_submit_tx
|
||
against a non-empty raw_batches.
|
||
|
||
With transform_queue_depth=1 and batch_size=4, max_cooked_rows=4.
|
||
A transform may only be submitted when in_pipeline + batch_size <= 4, i.e.
|
||
when in_pipeline == 0 (cooked is completely empty). Under the old broken
|
||
predicate (in_pipeline >= max_cooked_rows) the second transform would be
|
||
admitted with cooked containing batch_size-1 rows still unconsumed.
|
||
"""
|
||
import concurrent.futures as cf
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from unittest.mock import patch
|
||
|
||
db = lancedb.connect(tmp_path)
|
||
batch_size = 4
|
||
# Four full batches → four transform submissions to observe.
|
||
table = db.create_table("t", pa.table({"id": list(range(batch_size * 4))}))
|
||
|
||
cooked_at_submit: list[int] = []
|
||
|
||
original_submit = ThreadPoolExecutor.submit
|
||
|
||
def tracking_submit(self, fn, *args, **kwargs):
|
||
name = getattr(fn, "__name__", "")
|
||
if name == "_io_call":
|
||
# Run I/O synchronously on the calling (main) thread and return an
|
||
# already-completed Future. _drain_io checks fut.done(), so a
|
||
# completed Future is moved to raw_batches immediately on the next
|
||
# _advance call — making raw-batch readiness deterministic at the
|
||
# capacity-minus-one transition instead of depending on I/O thread
|
||
# scheduling.
|
||
fut = cf.Future()
|
||
try:
|
||
fut.set_result(fn(*args, **kwargs))
|
||
except Exception as exc:
|
||
fut.set_exception(exc)
|
||
return fut
|
||
if name == "_tx_call_guarded":
|
||
# Capture cooked depth synchronously on the main thread before the
|
||
# transform worker can drain the queue.
|
||
ref = ds._cooked_ref
|
||
cooked_at_submit.append(len(ref[0]) if ref is not None else -1)
|
||
return original_submit(self, fn, *args, **kwargs)
|
||
|
||
with patch.object(ThreadPoolExecutor, "submit", tracking_submit):
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=1,
|
||
shuffle_seed=42,
|
||
read_batch_size=batch_size,
|
||
transform_queue_depth=1,
|
||
transform_parallelism=1,
|
||
)
|
||
list(ds)
|
||
|
||
assert len(cooked_at_submit) == 4, (
|
||
f"Expected 4 transform submissions (one per batch), got {len(cooked_at_submit)}"
|
||
)
|
||
# With full-batch backpressure each transform is only admitted when the
|
||
# cooked queue is completely empty (depth == 0). The old broken predicate
|
||
# would admit at depth == batch_size - 1 == 3.
|
||
assert all(depth == 0 for depth in cooked_at_submit), (
|
||
"Transform admitted with non-empty cooked queue; full-batch backpressure "
|
||
"requires in_pipeline + batch_size <= max_cooked_rows before admission. "
|
||
f"Cooked depths at each submission: {cooked_at_submit}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deprecated parameter name tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_prefetch_batches_deprecated_warns(lance_table, caplog):
|
||
"""prefetch_batches logs a deprecation warning and behaves like io_queue_depth."""
|
||
with caplog.at_level(logging.WARNING, logger="lancedb.streaming"):
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
prefetch_batches=2,
|
||
)
|
||
messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
|
||
assert any("deprecated" in m.lower() and "io_queue_depth" in m for m in messages), (
|
||
f"Expected deprecation warning mentioning io_queue_depth; got: {messages}"
|
||
)
|
||
assert sorted(item["id"] for item in ds) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_filter_limits_rows(tmp_path):
|
||
"""A filter expression is applied to the permutation so only matching rows
|
||
are yielded. IDs 0..59 pass ``id < 60``; the other 60 are excluded."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("data", pa.table({"id": list(range(NUM_ROWS))}))
|
||
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
filter="id < 60",
|
||
)
|
||
items = list(ds)
|
||
|
||
ids = [item["id"] for item in items]
|
||
assert sorted(ids) == list(range(60)), f"Expected ids 0-59, got {sorted(ids)}"
|
||
|
||
|
||
def test_filter_too_few_rows_raises(tmp_path):
|
||
"""A filter that leaves fewer rows than num_splits raises ValueError at
|
||
construction time because each split must have at least one row."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("data", pa.table({"id": list(range(NUM_ROWS))}))
|
||
|
||
with pytest.raises(ValueError, match="at least 1 row per split"):
|
||
StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
filter="id < 0",
|
||
)
|
||
|
||
|
||
def test_columns_limits_output_columns(tmp_path):
|
||
"""Only the requested columns are present in each yielded row."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"data",
|
||
pa.table({"id": list(range(NUM_ROWS)), "val": list(range(NUM_ROWS))}),
|
||
)
|
||
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
columns=["id"],
|
||
)
|
||
items = list(ds)
|
||
|
||
assert len(items) == NUM_ROWS
|
||
assert all(list(item.keys()) == ["id"] for item in items), (
|
||
"Expected only 'id' column in each row"
|
||
)
|
||
assert sorted(item["id"] for item in items) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_columns_invalid_column_raises(tmp_path):
|
||
"""Requesting a column that does not exist raises an error at iteration time."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("data", pa.table({"id": list(range(NUM_ROWS))}))
|
||
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
columns=["nonexistent"],
|
||
)
|
||
with pytest.raises(ValueError):
|
||
list(ds)
|
||
|
||
|
||
def test_shuffle_clump_size_yields_all_rows(lance_table):
|
||
"""shuffle_clump_size still produces a complete epoch with no duplicates or
|
||
omissions — clumping affects I/O locality, not correctness."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
shuffle_clump_size=4,
|
||
)
|
||
items = list(ds)
|
||
|
||
assert sorted(item["id"] for item in items) == list(range(NUM_ROWS)), (
|
||
"Expected every row exactly once with shuffle_clump_size set"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# on_transform_error tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class BadRowError(ValueError):
|
||
"""Raised by the failing transforms below when a batch contains a bad id."""
|
||
|
||
|
||
def _failing_transform(bad_ids: set):
|
||
"""A transform that raises BadRowError whenever the batch has a bad id.
|
||
|
||
Raises on the full batch and on any single-row slice containing a bad id,
|
||
so per-row isolation drops exactly the bad rows.
|
||
"""
|
||
|
||
def transform(batch: pa.RecordBatch) -> list:
|
||
ids = batch.column("id").to_pylist()
|
||
bad = sorted(set(ids) & bad_ids)
|
||
if bad:
|
||
raise BadRowError(f"bad ids in batch: {bad}")
|
||
return [{"id": i} for i in ids]
|
||
|
||
return transform
|
||
|
||
|
||
def _sequential_split_members(table) -> list[list[int]]:
|
||
"""Return each split's ids in yield order for shuffle=False.
|
||
|
||
With a single rank and no workers the round-robin yields one row per split
|
||
per cycle, so item k of a clean run belongs to split k % NUM_SPLITS.
|
||
"""
|
||
ds = StreamingDataset(table, num_splits=NUM_SPLITS, shuffle=False)
|
||
members: list[list[int]] = [[] for _ in range(NUM_SPLITS)]
|
||
for k, row in enumerate(ds):
|
||
members[k % NUM_SPLITS].append(row["id"])
|
||
return members
|
||
|
||
|
||
def test_on_transform_error_default_raises(lance_table):
|
||
"""By default a transform exception propagates and aborts iteration."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=_failing_transform({7}),
|
||
)
|
||
with pytest.raises(BadRowError):
|
||
list(ds)
|
||
|
||
|
||
def test_on_transform_error_invalid_value(lance_table):
|
||
with pytest.raises(ValueError, match="on_transform_error"):
|
||
StreamingDataset(lance_table, num_splits=NUM_SPLITS, on_transform_error="bogus")
|
||
|
||
|
||
def test_on_transform_error_skip_drops_bad_rows(lance_table):
|
||
"""With one bad row per split, 'skip' yields every good row exactly once
|
||
and counts the dropped rows in rows_skipped."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = {members[i][4] for i in range(NUM_SPLITS)}
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
assert ds.rows_skipped == 0
|
||
|
||
ids = [row["id"] for row in ds]
|
||
|
||
assert sorted(ids) == sorted(set(range(NUM_ROWS)) - bad_ids)
|
||
assert ds.rows_skipped == NUM_SPLITS
|
||
|
||
|
||
def test_on_transform_error_skip_uneven_ends_at_last_complete_cycle(lance_table):
|
||
"""When one split loses more rows than the others, the epoch ends at the
|
||
last cycle where every split still has a row — no crash, no bad rows, and
|
||
every step remains one sample per split."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = set(members[0][:3]) # all 3 bad rows in split 0
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
items = [row["id"] for row in ds]
|
||
|
||
rows_per_split = NUM_ROWS // NUM_SPLITS
|
||
expected_cycles = rows_per_split - len(bad_ids)
|
||
assert len(items) == expected_cycles * NUM_SPLITS
|
||
assert len(set(items)) == len(items), "duplicate samples yielded"
|
||
assert not set(items) & bad_ids, "a bad row was yielded"
|
||
# Split 0 contributed exactly its surviving rows, in order, one per cycle.
|
||
survivors = [i for i in members[0] if i not in bad_ids]
|
||
assert items[0::NUM_SPLITS] == survivors[:expected_cycles]
|
||
|
||
|
||
def test_on_transform_error_warn_logs(lance_table, caplog):
|
||
"""'warn' skips like 'skip' but logs a warning for the failing batch."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = {members[i][3] for i in range(NUM_SPLITS)}
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="warn",
|
||
)
|
||
with caplog.at_level(logging.WARNING, logger="lancedb.streaming"):
|
||
items = list(ds)
|
||
|
||
assert len(items) == NUM_ROWS - NUM_SPLITS
|
||
assert ds.rows_skipped == NUM_SPLITS
|
||
assert "Skipped" in caplog.text
|
||
assert "BadRowError" in caplog.text
|
||
|
||
|
||
def test_on_transform_error_callable_selective(lance_table):
|
||
"""A callable handler can skip expected errors and re-raise the rest."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = {members[i][0] for i in range(NUM_SPLITS)}
|
||
|
||
handled: list[Exception] = []
|
||
|
||
def handler(exc: Exception) -> bool:
|
||
handled.append(exc)
|
||
return isinstance(exc, BadRowError)
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error=handler,
|
||
)
|
||
items = list(ds)
|
||
assert len(items) == NUM_ROWS - NUM_SPLITS
|
||
assert handled and all(isinstance(exc, BadRowError) for exc in handled)
|
||
|
||
def broken_transform(batch: pa.RecordBatch) -> list:
|
||
raise TypeError("boom")
|
||
|
||
ds2 = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=broken_transform,
|
||
on_transform_error=handler,
|
||
)
|
||
with pytest.raises(TypeError, match="boom"):
|
||
list(ds2)
|
||
|
||
|
||
def test_transform_wrong_row_count_raises(lance_table):
|
||
"""A transform that returns the wrong number of rows is an error even with
|
||
on_transform_error='skip' — silent shrinkage would corrupt accounting."""
|
||
|
||
def drops_rows(batch: pa.RecordBatch) -> list:
|
||
return batch.column("id").to_pylist()[:-1]
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=drops_rows,
|
||
on_transform_error="skip",
|
||
)
|
||
with pytest.raises(ValueError, match="one output row per input row"):
|
||
list(ds)
|
||
|
||
|
||
def test_skip_deterministic_across_runs(lance_table):
|
||
"""With a fixed seed, skipping produces the identical sample sequence on
|
||
every run — skips are data-dependent, not run-dependent."""
|
||
bad_ids = {5, 17, 46}
|
||
|
||
def run() -> tuple[list[int], int]:
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
return [row["id"] for row in ds], ds.rows_skipped
|
||
|
||
ids_a, skipped_a = run()
|
||
ids_b, skipped_b = run()
|
||
assert ids_a == ids_b
|
||
assert skipped_a == skipped_b
|
||
assert not set(ids_a) & bad_ids
|
||
|
||
|
||
def test_skip_elastic_det_across_world_sizes(lance_table):
|
||
"""With equal bad-row counts per split, skipping preserves the full
|
||
elastic-determinism guarantee: identical global batches at every step for
|
||
every compatible world_size."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = {members[i][6] for i in range(NUM_SPLITS)}
|
||
|
||
def collect(world_size: int) -> list[frozenset[int]]:
|
||
micro = GLOBAL_BATCH_SIZE // world_size
|
||
iters = [
|
||
iter(
|
||
StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
rank=rank,
|
||
world_size=world_size,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
)
|
||
for rank in range(world_size)
|
||
]
|
||
_STOP = object()
|
||
batches: list[frozenset[int]] = []
|
||
while True:
|
||
step_samples: set[int] = set()
|
||
exhausted = 0
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
val = next(it, _STOP)
|
||
if val is _STOP:
|
||
exhausted += 1
|
||
break
|
||
step_samples.add(val["id"])
|
||
if exhausted == len(iters):
|
||
break
|
||
assert exhausted == 0, (
|
||
"Rank iterators exhausted at different steps despite equal "
|
||
"bad-row counts per split"
|
||
)
|
||
batches.append(frozenset(step_samples))
|
||
return batches
|
||
|
||
reference = collect(1)
|
||
assert len(reference) == NUM_ROWS // NUM_SPLITS - 1
|
||
for ws in (2, 3, 4):
|
||
assert collect(ws) == reference, f"world_size={ws} diverged"
|
||
|
||
|
||
def test_resumability_with_skips_same_topology(lance_table):
|
||
"""Checkpointing mid-epoch with skipped rows resumes exactly: no sample
|
||
repeated, no sample lost, skipped rows stay skipped."""
|
||
members = _sequential_split_members(lance_table)
|
||
# Uneven skips: positions diverge across splits (2 bad in split 0, 1 in
|
||
# split 5), which only a position-based checkpoint can resume exactly.
|
||
bad_ids = {members[0][2], members[0][3], members[5][7]}
|
||
kwargs = dict(
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
|
||
reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)]
|
||
rows_per_split = NUM_ROWS // NUM_SPLITS
|
||
assert len(reference) == (rows_per_split - 2) * NUM_SPLITS
|
||
|
||
steps = 3
|
||
ds = StreamingDataset(lance_table, **kwargs)
|
||
it = iter(ds)
|
||
consumed = [next(it)["id"] for _ in range(steps * NUM_SPLITS)]
|
||
checkpoint = ds.state_dict()
|
||
it.close()
|
||
|
||
# Split 0 skipped positions 2 and 3 within its first 3 yields; split 5's
|
||
# bad row is beyond the checkpoint. Everything else is at 3 = the sample
|
||
# count.
|
||
positions = checkpoint["positions_consumed_per_split"]
|
||
assert positions[0] == 5
|
||
assert positions[1:] == [3] * (NUM_SPLITS - 1)
|
||
assert checkpoint["samples_consumed_per_split"] == [3] * NUM_SPLITS
|
||
|
||
ds2 = StreamingDataset(lance_table, **kwargs)
|
||
ds2.load_state_dict(checkpoint)
|
||
resumed = [row["id"] for row in ds2]
|
||
|
||
assert consumed == reference[: steps * NUM_SPLITS]
|
||
assert resumed == reference[steps * NUM_SPLITS :]
|
||
|
||
|
||
def test_resumability_with_skips_elastic_merge(lance_table):
|
||
"""Elastic resume with skips: each rank's checkpoint knows exact positions
|
||
only for its own splits; merge_state_dicts recovers the global state, and
|
||
a run on a different world_size continues exactly."""
|
||
members = _sequential_split_members(lance_table)
|
||
# Bad rows early in split 0 (rank 0) and split 6 (rank 1 of a ws=2 run) so
|
||
# both ranks' position vectors diverge before the checkpoint.
|
||
bad_ids = {members[0][0], members[0][2], members[6][1]}
|
||
kwargs = dict(
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
|
||
reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)]
|
||
|
||
steps = 3
|
||
world_size = 2
|
||
micro = GLOBAL_BATCH_SIZE // world_size
|
||
datasets = [
|
||
StreamingDataset(lance_table, rank=rank, world_size=world_size, **kwargs)
|
||
for rank in range(world_size)
|
||
]
|
||
iters = [iter(ds) for ds in datasets]
|
||
seen: list[frozenset[int]] = []
|
||
for _ in range(steps):
|
||
step_samples = set()
|
||
for it in iters:
|
||
for _ in range(micro):
|
||
step_samples.add(next(it)["id"])
|
||
seen.append(frozenset(step_samples))
|
||
states = [ds.state_dict() for ds in datasets]
|
||
for it in iters:
|
||
it.close()
|
||
|
||
merged = StreamingDataset.merge_state_dicts(states)
|
||
expected_positions = [3] * NUM_SPLITS
|
||
expected_positions[0] = 5 # skipped positions 0 and 2
|
||
expected_positions[6] = 4 # skipped position 1
|
||
assert merged["positions_consumed_per_split"] == expected_positions
|
||
|
||
# The first 3 global batches match the world_size=1 reference.
|
||
ref_batches = [
|
||
frozenset(reference[s * NUM_SPLITS : (s + 1) * NUM_SPLITS])
|
||
for s in range(len(reference) // NUM_SPLITS)
|
||
]
|
||
assert seen == ref_batches[:steps]
|
||
|
||
# Resume on world_size=1 from the merged state.
|
||
ds_resume = StreamingDataset(lance_table, **kwargs)
|
||
ds_resume.load_state_dict(merged)
|
||
resumed = [row["id"] for row in ds_resume]
|
||
assert resumed == reference[steps * NUM_SPLITS :]
|
||
|
||
|
||
def test_rows_skipped_flushed_when_split_entirely_bad(lance_table):
|
||
"""A split whose rows all fail never completes a cycle, so the epoch ends
|
||
immediately — but rows_skipped must still report the drops after the
|
||
iterator exits (the shared-memory counter is flushed on exhaustion)."""
|
||
members = _sequential_split_members(lance_table)
|
||
bad_ids = set(members[0]) # every row of split 0 is bad
|
||
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
transform=_failing_transform(bad_ids),
|
||
on_transform_error="skip",
|
||
)
|
||
assert list(ds) == []
|
||
assert ds.rows_skipped == len(bad_ids)
|
||
|
||
|
||
def test_merge_state_dicts_validates_consistency(lance_table):
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
state = ds.state_dict()
|
||
other = dict(state, shuffle_seed=SHUFFLE_SEED + 1)
|
||
with pytest.raises(ValueError, match="shuffle_seed mismatch"):
|
||
StreamingDataset.merge_state_dicts([state, other])
|
||
with pytest.raises(ValueError, match="at least one"):
|
||
StreamingDataset.merge_state_dicts([])
|
||
|
||
|
||
def test_merge_state_dicts_combines_nonuniform_consumer_progress(lance_table):
|
||
dataset = StreamingDataset(
|
||
lance_table, num_splits=2, shuffle=False, shuffle_seed=SHUFFLE_SEED
|
||
)
|
||
rank0 = dataset.state_dict()
|
||
rank0["samples_consumed_per_split"] = [2, 0]
|
||
rank0["positions_consumed_per_split"] = [2, 0]
|
||
rank1 = dataset.state_dict()
|
||
rank1["samples_consumed_per_split"] = [0, 2]
|
||
rank1["positions_consumed_per_split"] = [0, 2]
|
||
|
||
merged = StreamingDataset.merge_state_dicts([rank0, rank1])
|
||
|
||
assert merged["samples_consumed_per_split"] == [2, 2]
|
||
assert merged["positions_consumed_per_split"] == [2, 2]
|
||
|
||
|
||
def test_load_state_dict_without_positions_key(lance_table):
|
||
"""Checkpoints from before positions_consumed_per_split existed still
|
||
resume exactly (positions equal sample counts when nothing is skipped)."""
|
||
reference = [
|
||
row["id"]
|
||
for row in StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
|
||
)
|
||
]
|
||
|
||
steps = 4
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
it = iter(ds)
|
||
for _ in range(steps * NUM_SPLITS):
|
||
next(it)
|
||
checkpoint = ds.state_dict()
|
||
it.close()
|
||
del checkpoint["positions_consumed_per_split"]
|
||
|
||
ds2 = StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
|
||
)
|
||
ds2.load_state_dict(checkpoint)
|
||
resumed = [row["id"] for row in ds2]
|
||
assert resumed == reference[steps * NUM_SPLITS :]
|
||
|
||
|
||
def test_num_splits_defaults_to_world_size(lance_table):
|
||
"""Omitting num_splits gives world_size splits (one per rank)."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
)
|
||
assert ds._num_splits == 1 # world_size defaults to 1
|
||
|
||
ds_ws2 = StreamingDataset(
|
||
lance_table,
|
||
world_size=2,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
)
|
||
assert ds_ws2._num_splits == 2
|
||
|
||
|
||
def test_shuffle_false_sequential_and_deterministic(lance_table):
|
||
"""shuffle=False produces identical ordering across two fresh instances."""
|
||
ds1 = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle=False)
|
||
ds2 = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle=False)
|
||
first = [item["id"] for item in ds1]
|
||
second = [item["id"] for item in ds2]
|
||
assert first == second, "shuffle=False must be deterministic"
|
||
assert sorted(first) == list(range(NUM_ROWS)), "All rows must be present"
|
||
|
||
|
||
def test_shuffle_false_vs_true_differ(lance_table):
|
||
"""shuffle=True and shuffle=False produce different orderings."""
|
||
ds_shuf = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=True,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
)
|
||
ds_seq = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle=False,
|
||
)
|
||
shuffled = [item["id"] for item in ds_shuf]
|
||
sequential = [item["id"] for item in ds_seq]
|
||
assert shuffled != sequential, "Shuffled and sequential orderings should differ"
|
||
|
||
|
||
def test_shuffle_seed_none_generates_stable_seed(lance_table):
|
||
"""shuffle_seed=None resolves to a concrete integer at construction time.
|
||
|
||
A second dataset built with the same resolved seed must produce the same ordering.
|
||
"""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=None,
|
||
)
|
||
assert isinstance(ds._shuffle_seed, int), (
|
||
"shuffle_seed=None must resolve to an integer"
|
||
)
|
||
first = [item["id"] for item in ds]
|
||
|
||
ds2 = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=ds._shuffle_seed,
|
||
)
|
||
second = [item["id"] for item in ds2]
|
||
assert first == second, "Same resolved seed must produce the same ordering"
|
||
|
||
|
||
# Sequence packing tests
|
||
|
||
|
||
def _create_token_table(tmp_path, documents):
|
||
db = lancedb.connect(tmp_path)
|
||
tokens = pa.array(documents, type=pa.list_(pa.int64()))
|
||
return db.create_table("tokens", pa.table({"tokens": tokens}))
|
||
|
||
|
||
def _packed_dataset(table, pack_sequences, *, blocks_per_epoch, pad_id=0, **kwargs):
|
||
return StreamingDataset(
|
||
table,
|
||
shuffle=False,
|
||
columns=["tokens"],
|
||
pack_sequences=pack_sequences,
|
||
eos_id=9,
|
||
pad_id=pad_id,
|
||
blocks_per_epoch=blocks_per_epoch,
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
def test_pack_sequences_emits_blocks_and_pads_final_tail(tmp_path):
|
||
table = _create_token_table(tmp_path, [[1, 2], [3, 4], [5]])
|
||
dataset = _packed_dataset(table, 6, blocks_per_epoch=2)
|
||
|
||
blocks = list(dataset)
|
||
|
||
assert len(blocks) == 2
|
||
assert blocks[0]["input_ids"].tolist() == [1, 2, 9, 3, 4, 9]
|
||
assert blocks[0]["doc_ids"].tolist() == [0, 0, 0, 1, 1, 1]
|
||
assert blocks[1]["input_ids"].tolist() == [5, 9, 0, 0, 0, 0]
|
||
assert blocks[1]["doc_ids"].tolist() == [0, 0, 0, 0, 0, 0]
|
||
assert blocks[0]["input_ids"].dtype == torch.int64
|
||
assert blocks[0]["doc_ids"].dtype == torch.int64
|
||
|
||
|
||
def test_pack_sequences_pads_lagging_splits(tmp_path):
|
||
table = _create_token_table(
|
||
tmp_path,
|
||
[[1], [2], [10, 11, 12, 13, 14, 15, 16, 17], [20]],
|
||
)
|
||
dataset = _packed_dataset(table, 5, blocks_per_epoch=6, num_splits=2)
|
||
input_ids = [block["input_ids"].tolist() for block in dataset]
|
||
# Split 0 has four real tokens including EOS markers, while split 1 has
|
||
# eleven. Packing must emit three complete two-split cycles.
|
||
assert input_ids == [
|
||
[1, 9, 2, 9, 0],
|
||
[10, 11, 12, 13, 14],
|
||
[0, 0, 0, 0, 0],
|
||
[15, 16, 17, 9, 20],
|
||
[0, 0, 0, 0, 0],
|
||
[9, 0, 0, 0, 0],
|
||
]
|
||
|
||
per_rank = []
|
||
for rank in range(2):
|
||
rank_dataset = _packed_dataset(
|
||
table,
|
||
5,
|
||
blocks_per_epoch=6,
|
||
num_splits=2,
|
||
world_size=2,
|
||
rank=rank,
|
||
)
|
||
per_rank.append([block["input_ids"].tolist() for block in rank_dataset])
|
||
|
||
assert [len(blocks) for blocks in per_rank] == [3, 3]
|
||
sharded = [block for cycle in zip(*per_rank) for block in cycle]
|
||
assert sharded == input_ids
|
||
|
||
|
||
def test_pack_sequences_auto_estimates_filtered_token_column(tmp_path):
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"tokens",
|
||
pa.table(
|
||
{
|
||
"tokens": pa.array([[1] * 4, [2] * 9], type=pa.list_(pa.int64())),
|
||
"keep": [True, False],
|
||
}
|
||
),
|
||
)
|
||
table.add(
|
||
pa.table(
|
||
{
|
||
"tokens": pa.array([[3] * 4, [4] * 9], type=pa.list_(pa.int64())),
|
||
"keep": [True, False],
|
||
}
|
||
)
|
||
)
|
||
|
||
with pytest.warns(UserWarning, match="approximate token-count sample"):
|
||
dataset = _packed_dataset(
|
||
table,
|
||
5,
|
||
blocks_per_epoch="auto",
|
||
num_splits=2,
|
||
filter="keep",
|
||
)
|
||
|
||
# Two kept documents contain 8 tokens plus 2 EOS tokens: two blocks.
|
||
assert dataset.state_dict()["blocks_per_epoch"] == 2
|
||
|
||
|
||
def test_pack_sequences_checkpoint_resumes_on_new_topology(tmp_path):
|
||
table = _create_token_table(
|
||
tmp_path,
|
||
[[1], [2], [10, 11, 12, 13, 14, 15, 16, 17], [20]],
|
||
)
|
||
kwargs = dict(pack_sequences=5, blocks_per_epoch=6, num_splits=2)
|
||
reference = list(_packed_dataset(table, **kwargs))
|
||
|
||
datasets = [
|
||
_packed_dataset(table, world_size=2, rank=rank, **kwargs) for rank in range(2)
|
||
]
|
||
iterators = [iter(dataset) for dataset in datasets]
|
||
first_cycle = [next(iterator) for iterator in iterators]
|
||
checkpoint = StreamingDataset.merge_state_dicts(
|
||
[dataset.state_dict() for dataset in datasets]
|
||
)
|
||
for iterator in iterators:
|
||
iterator.close()
|
||
|
||
resumed = _packed_dataset(table, **kwargs)
|
||
resumed.load_state_dict(checkpoint)
|
||
actual_remaining = list(resumed)
|
||
|
||
assert [block["input_ids"].tolist() for block in first_cycle] == [
|
||
[1, 9, 2, 9, 0],
|
||
[10, 11, 12, 13, 14],
|
||
]
|
||
assert checkpoint["blocks_emitted_per_split"] == [1, 1]
|
||
assert [block["input_ids"].tolist() for block in actual_remaining] == [
|
||
block["input_ids"].tolist() for block in reference[2:]
|
||
]
|
||
assert [block["doc_ids"].tolist() for block in actual_remaining] == [
|
||
block["doc_ids"].tolist() for block in reference[2:]
|
||
]
|
||
|
||
|
||
def test_packed_checkpoint_requires_complete_split_cycle(tmp_path):
|
||
table = _create_token_table(tmp_path, [[1], [2], [10], [20]])
|
||
dataset = _packed_dataset(table, pack_sequences=3, blocks_per_epoch=4, num_splits=2)
|
||
iterator = iter(dataset)
|
||
|
||
next(iterator)
|
||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||
dataset.state_dict()
|
||
|
||
next(iterator)
|
||
assert dataset.state_dict()["blocks_emitted_per_split"] == [1, 1]
|
||
iterator.close()
|
||
|
||
|
||
def test_streaming_dataloader_commits_consumed_packed_batches(tmp_path):
|
||
table = _create_token_table(
|
||
tmp_path,
|
||
[[1], [2], [3], [4], [10], [20], [30], [40]],
|
||
)
|
||
kwargs = dict(pack_sequences=4, blocks_per_epoch=4, num_splits=2)
|
||
dataset = _packed_dataset(table, **kwargs)
|
||
loader = StreamingDataLoader(
|
||
dataset,
|
||
batch_size=1,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
prefetch_factor=2,
|
||
)
|
||
iterator = iter(loader)
|
||
try:
|
||
next(iterator)
|
||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||
dataset.state_dict()
|
||
|
||
next(iterator)
|
||
checkpoint = dataset.state_dict()
|
||
uninterrupted = [batch["input_ids"].tolist() for batch in iterator]
|
||
finally:
|
||
iterator._shutdown_workers()
|
||
|
||
resumed = _packed_dataset(table, **kwargs)
|
||
resumed.load_state_dict(checkpoint)
|
||
resumed_loader = StreamingDataLoader(
|
||
resumed,
|
||
batch_size=1,
|
||
num_workers=2,
|
||
multiprocessing_context="spawn",
|
||
prefetch_factor=2,
|
||
)
|
||
resumed_iterator = iter(resumed_loader)
|
||
try:
|
||
remaining = [batch["input_ids"].tolist() for batch in resumed_iterator]
|
||
finally:
|
||
resumed_iterator._shutdown_workers()
|
||
|
||
assert checkpoint["blocks_emitted_per_split"] == [1, 1]
|
||
assert remaining == uninterrupted
|
||
|
||
|
||
def test_pack_sequences_validates_configuration_and_tokens(tmp_path):
|
||
table = _create_token_table(tmp_path, [[1, 2]])
|
||
|
||
with pytest.raises(ValueError, match="pad_id is required"):
|
||
StreamingDataset(
|
||
table,
|
||
shuffle=False,
|
||
columns=["tokens"],
|
||
pack_sequences=4,
|
||
eos_id=9,
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="blocks_per_epoch is required"):
|
||
StreamingDataset(
|
||
table,
|
||
shuffle=False,
|
||
columns=["tokens"],
|
||
pack_sequences=4,
|
||
eos_id=9,
|
||
pad_id=0,
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="must be divisible"):
|
||
_packed_dataset(table, 4, blocks_per_epoch=3, num_splits=2)
|
||
|
||
with pytest.raises(ValueError, match="positive integer or 'auto'"):
|
||
_packed_dataset(table, 4, blocks_per_epoch="estimate")
|
||
|
||
checkpoint = _packed_dataset(table, 4, blocks_per_epoch=1).state_dict()
|
||
resumed = _packed_dataset(table, 4, blocks_per_epoch=1, pad_id=8)
|
||
with pytest.raises(ValueError, match="pad_id mismatch"):
|
||
resumed.load_state_dict(checkpoint)
|
||
|
||
float_db = lancedb.connect(tmp_path / "float")
|
||
float_table = float_db.create_table(
|
||
"tokens",
|
||
pa.table({"tokens": pa.array([[1.5, 2.5]], type=pa.list_(pa.float64()))}),
|
||
)
|
||
with pytest.raises(ValueError, match="token column with integer values"):
|
||
_packed_dataset(float_table, 4, blocks_per_epoch=1)
|
||
|
||
null_db = lancedb.connect(tmp_path / "null")
|
||
null_table = null_db.create_table(
|
||
"tokens",
|
||
pa.table({"tokens": pa.array([None], type=pa.list_(pa.int64()))}),
|
||
)
|
||
with pytest.raises(ValueError, match="does not support null token lists"):
|
||
list(_packed_dataset(null_table, 4, blocks_per_epoch=1))
|
||
|
||
null_value_db = lancedb.connect(tmp_path / "null_value")
|
||
null_value_table = null_value_db.create_table(
|
||
"tokens",
|
||
pa.table(
|
||
{"tokens": pa.array([[1], [2, None], [3]], type=pa.list_(pa.int64()))}
|
||
),
|
||
)
|
||
blocks = list(
|
||
_packed_dataset(
|
||
null_value_table,
|
||
2,
|
||
blocks_per_epoch=2,
|
||
on_transform_error="skip",
|
||
)
|
||
)
|
||
assert [block["input_ids"].tolist() for block in blocks] == [[1, 9], [3, 9]]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Doc examples — each test mirrors the code snippet in index.mdx so that
|
||
# broken doc examples are caught before they ship.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_doc_example_basic(tmp_path):
|
||
"""doc: Basic Data loading — StreamingDataset with default params."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"some_table",
|
||
pa.table({"feature": [float(i) for i in range(24)], "label": ["cat"] * 24}),
|
||
)
|
||
|
||
ds = StreamingDataset(table, shuffle_seed=42)
|
||
samples = list(ds)
|
||
|
||
assert len(samples) == 24
|
||
assert all(isinstance(s, dict) for s in samples)
|
||
assert all("feature" in s and "label" in s for s in samples)
|
||
|
||
|
||
def test_doc_example_prefetch_params(tmp_path):
|
||
"""doc: Prefetching — read_batch_size and io_queue_depth still cover all rows."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("t", pa.table({"id": list(range(NUM_ROWS))}))
|
||
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
read_batch_size=8,
|
||
io_queue_depth=2,
|
||
)
|
||
assert sorted(s["id"] for s in ds) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_doc_example_transform(tmp_path):
|
||
"""doc: Transformation — normalize scales values into [0, 1]."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table("t", pa.table({"value": list(range(NUM_ROWS))}))
|
||
|
||
def normalize(batch: pa.RecordBatch) -> list[dict]:
|
||
rows = batch.to_pylist()
|
||
for row in rows:
|
||
row["value"] = row["value"] / 255.0
|
||
return rows
|
||
|
||
ds = StreamingDataset(
|
||
table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED, transform=normalize
|
||
)
|
||
samples = list(ds)
|
||
|
||
assert len(samples) == NUM_ROWS
|
||
assert all(0.0 <= s["value"] <= 1.0 for s in samples)
|
||
|
||
|
||
def test_doc_example_observability(lance_table):
|
||
"""doc: Observability — counters are zero before and positive after iteration."""
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
|
||
assert ds.unscanned_rows == 0
|
||
assert ds.raw_queue_depth == 0
|
||
assert ds.prefetch_queue_depth == 0
|
||
assert ds.consumed_rows == 0
|
||
assert ds.bytes_loaded == 0
|
||
assert ds.fetch_time == 0.0
|
||
assert ds.transform_time == 0.0
|
||
|
||
list(ds)
|
||
|
||
assert ds.bytes_loaded > 0
|
||
assert ds.fetch_time >= 0.0
|
||
assert ds.transform_time >= 0.0
|
||
|
||
|
||
def test_doc_example_columns_and_filter(tmp_path):
|
||
"""doc: Filtering data — columns + filter reduce both dimensions independently."""
|
||
db = lancedb.connect(tmp_path)
|
||
table = db.create_table(
|
||
"t",
|
||
pa.table(
|
||
{
|
||
"id": list(range(NUM_ROWS)),
|
||
"value": list(range(NUM_ROWS)),
|
||
"category": ["train"] * 60 + ["val"] * 60,
|
||
}
|
||
),
|
||
)
|
||
|
||
ds = StreamingDataset(
|
||
table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
columns=["id"],
|
||
filter="category = 'train'",
|
||
)
|
||
samples = list(ds)
|
||
|
||
assert all(list(s.keys()) == ["id"] for s in samples), "Only 'id' column expected"
|
||
assert len(samples) == 60, "Only train rows expected"
|
||
assert all(s["id"] < 60 for s in samples)
|
||
|
||
|
||
def test_doc_example_epoch_shuffle(lance_table):
|
||
"""doc: Shuffling rows — different epochs produce different orderings."""
|
||
ids_e0 = [
|
||
s["id"]
|
||
for s in StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED, epoch=0
|
||
)
|
||
]
|
||
ids_e1 = [
|
||
s["id"]
|
||
for s in StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED, epoch=1
|
||
)
|
||
]
|
||
|
||
assert sorted(ids_e0) == list(range(NUM_ROWS))
|
||
assert sorted(ids_e1) == list(range(NUM_ROWS))
|
||
assert ids_e0 != ids_e1, "Different epochs must produce different orderings"
|
||
|
||
|
||
def test_doc_example_shuffle_false_eval(lance_table):
|
||
"""doc: Shuffling rows — shuffle=False gives deterministic sequential order."""
|
||
ids_a = [
|
||
s["id"]
|
||
for s in StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle=False)
|
||
]
|
||
ids_b = [
|
||
s["id"]
|
||
for s in StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle=False)
|
||
]
|
||
|
||
assert ids_a == ids_b, "shuffle=False must produce identical orderings"
|
||
assert sorted(ids_a) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_doc_example_shuffle_clump_size(lance_table):
|
||
"""doc: Shuffling rows (Note) — shuffle_clump_size still covers every row."""
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=NUM_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
shuffle_clump_size=16,
|
||
)
|
||
assert sorted(s["id"] for s in ds) == list(range(NUM_ROWS))
|
||
|
||
|
||
def test_doc_example_elastic_ddp(lance_table):
|
||
"""doc: Data splits and elasticity — rank/world_size/num_splits covers all rows."""
|
||
# Use a highly composite num_splits that works across many world sizes
|
||
ELASTIC_SPLITS = 12 # works with world_size 1, 2, 3, 4, 6, 12
|
||
|
||
all_ids: set[int] = set()
|
||
for rank in range(4):
|
||
ds = StreamingDataset(
|
||
lance_table,
|
||
num_splits=ELASTIC_SPLITS,
|
||
shuffle_seed=SHUFFLE_SEED,
|
||
rank=rank,
|
||
world_size=4,
|
||
)
|
||
all_ids.update(s["id"] for s in ds)
|
||
|
||
assert all_ids == set(range(NUM_ROWS)), "All ranks together must cover every row"
|
||
|
||
|
||
def test_doc_example_checkpoint(lance_table):
|
||
"""doc: state_dict/load_state_dict checkpointing resumes without gaps or repeats."""
|
||
STEPS_BEFORE_CHECKPOINT = 4 # consume 4 full cycles then checkpoint
|
||
|
||
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
|
||
it = iter(ds)
|
||
consumed = [next(it)["id"] for _ in range(STEPS_BEFORE_CHECKPOINT * NUM_SPLITS)]
|
||
checkpoint = ds.state_dict()
|
||
remaining_original = [s["id"] for s in it] # drain the rest
|
||
|
||
# Resume from checkpoint on a fresh dataset
|
||
ds_resumed = StreamingDataset(
|
||
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
|
||
)
|
||
ds_resumed.load_state_dict(checkpoint)
|
||
remaining_resumed = [s["id"] for s in ds_resumed]
|
||
|
||
assert remaining_original == remaining_resumed, (
|
||
"Resumed dataset must continue from exactly the same position"
|
||
)
|
||
assert sorted(consumed + remaining_original) == list(range(NUM_ROWS)), (
|
||
"Consumed + remaining must cover every row exactly once"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Remote tables (LanceDB Cloud / Enterprise)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_streaming_dataset_over_remote_table():
|
||
"""StreamingDataset reads a remote table, with server-safe requests.
|
||
|
||
Builds a permutation over a remote table, then fetches batches from it by row id.
|
||
"""
|
||
server = MockPermutationServer()
|
||
|
||
with mock_remote_table(server) as table:
|
||
ds = StreamingDataset(table, num_splits=2, shuffle_seed=SHUFFLE_SEED)
|
||
ids = [row["id"] for row in ds]
|
||
|
||
assert sorted(ids) == list(range(server.num_rows)), (
|
||
"Every row of the remote table must be yielded exactly once"
|
||
)
|
||
assert len(server.scans) == 1, "the permutation is built with one row-id scan"
|
||
assert server.takes, "rows must be fetched with row-id takes"
|
||
assert_server_safe_row_id_requests(server)
|