Skip to content

feat(python): add parallel overwrite support to write_dataset#7850

Open
AdibaAdi wants to merge 1 commit into
lance-format:mainfrom
AdibaAdi:fix-1980-n-jobs-write-dataset
Open

feat(python): add parallel overwrite support to write_dataset#7850
AdibaAdi wants to merge 1 commit into
lance-format:mainfrom
AdibaAdi:fix-1980-n-jobs-write-dataset

Conversation

@AdibaAdi

Copy link
Copy Markdown

Why

lance.write_dataset currently consumes its input as a single stream and writes one file at a time. For large, fully in-memory tables, this can leave available CPU and I/O capacity underused. In #1980, the reporter observed low CPU and network utilization while writing a large in-memory table and improved throughput by manually distributing the work across threads.

A maintainer confirmed the single-stream behavior, welcomed an n_jobs parameter, and provided a reference approach based on parallel write_fragments calls followed by one commit. This PR brings that approach into the public API for the subset where the existing fragment-commit primitives can preserve correct behavior.

What

This PR adds an optional n_jobs parameter to lance.write_dataset.

  • n_jobs=None and n_jobs=1 preserve the existing sequential path.
  • n_jobs >= 2 partitions an in-memory pyarrow.Table into contiguous, non-empty slices.
  • The slices are written concurrently with lance.fragment.write_fragments.
  • Fragment results are collected in deterministic partition order.
  • All fragments are committed through one final LanceOperation.Overwrite.
  • The effective number of workers is capped at the number of non-empty partitions.
  • No new dependency is introduced; the implementation uses concurrent.futures.ThreadPoolExecutor.

If a worker raises an exception, the final commit is not performed, so no partial dataset version is created.

Scope and backward compatibility

The parallel path currently supports:

  • in-memory pyarrow.Table inputs
  • mode="overwrite"

Parallel create and append are explicitly rejected rather than silently weakening their existing transaction guarantees. The public fragment-commit path cannot currently reproduce the native writer's atomic create-only behavior or append conflict/retry semantics.

Unsupported input types and incompatible options also raise descriptive errors.

Existing callers are unaffected because omitting n_jobs, or setting it to 1, follows the original sequential path.

Tests

Added coverage for:

  • sequential compatibility for n_jobs=None and n_jobs=1
  • parallel correctness for n_jobs=2 and n_jobs=4
  • row-count, schema, and full-content preservation
  • zero, negative, Boolean, and non-integer values
  • worker counts larger than the number of rows
  • empty tables
  • null-valued columns
  • unsupported inputs, modes, and options
  • explicit schema= with parallel mode
  • multiple-fragment output without treating the exact count as an API guarantee
  • worker failure before the final commit
# Run from python/
uv run pytest python/tests/test_dataset.py -k "n_jobs" -q
# 29 passed

uv run pytest python/tests/test_dataset.py -q
# 236 passed, 5 skipped

uv run make lint
# Ruff, Pyright, Rust checks, and Clippy passed

The reported warnings were pre-existing and came from files unrelated to this change.

Benchmark

Local macOS SSD benchmark using identical in-memory tables, mode="overwrite", one warm-up, and five measured repetitions. Each run verified row count, schema, and full content.

These results are local-storage measurements only, not Azure or production proof.

3,000,000 rows × 7 columns (~160 MB)

n_jobs Median time Speedup
1 0.358 s 1.00×
2 0.327 s 1.09×
4 0.319 s 1.12×

1,500,000 rows × 61 columns (~698 MB)

n_jobs Median time Speedup
1 1.492 s 1.00×
2 1.370 s 1.09×
4 1.279 s 1.17×

The local results show a modest but consistent improvement that increases with worker count and workload size. The network-bound object-storage workload described in the issue was not reproduced here.

Known limitations

  • Parallel mode currently supports only in-memory pyarrow.Table inputs.
  • Parallel mode currently supports only mode="overwrite".
  • Fragment files written before a worker failure may remain uncommitted in storage.
  • Object-store performance has not been measured.

Open questions for maintainers

  • Should the parallel orchestration remain in Python, or should the worker count eventually move into the Rust write path so it can be shared across bindings?
  • Should the public parameter remain n_jobs, as requested in the issue, or follow Lance conventions such as num_workers or *_concurrency?

Acceptance criteria

  • Adds opt-in parallel writing to lance.write_dataset
  • Preserves existing behavior for n_jobs=None and n_jobs=1
  • Preserves row count, schema, and data content
  • Rejects invalid worker counts with descriptive errors
  • Prevents a final commit when a worker fails
  • Clearly rejects unsupported modes, inputs, and options
  • Adds automated tests for the new behavior
  • Passes formatting, lint, type, and relevant Rust checks
  • Introduces no new dependency

Closes #1980

@github-actions github-actions Bot added A-python Python bindings enhancement New feature or request labels Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

write_dataset now supports validated parallel writes for in-memory pyarrow.Table inputs, partitioning data across workers and committing fragments in one overwrite transaction. Tests cover supported modes, validation, output correctness, edge cases, and worker failures.

Changes

Parallel dataset writes

Layer / File(s) Summary
Write API validation and routing
python/python/lance/dataset.py
Adds the n_jobs parameter and documentation, validates unsupported combinations, and routes eligible non-empty Arrow tables to parallel writing.
Partitioned fragment writing and commit
python/python/lance/dataset.py
Partitions tables, writes fragments concurrently, orders fragment metadata deterministically, and commits one overwrite version after successful workers.
Parallel write behavior validation
python/python/tests/test_dataset.py
Tests sequential compatibility, partitioning, validation errors, edge cases, input restrictions, null preservation, and failure without a committed version.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant write_dataset
  participant write_fragments
  participant LanceOperation
  Caller->>write_dataset: write Arrow table with n_jobs
  write_dataset->>write_fragments: write table partitions concurrently
  write_fragments-->>write_dataset: return fragment metadata
  write_dataset->>LanceOperation: commit fragments as Overwrite
  LanceOperation-->>Caller: produce dataset version
Loading

Suggested reviewers: xuanwo, bubblecal, jackye1995, wjones127, zhangyue19921010

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding parallel overwrite support via n_jobs to write_dataset.
Description check ✅ Passed The description matches the changeset and explains the new n_jobs parallel write behavior, constraints, and tests.
Linked Issues check ✅ Passed The PR implements the requested n_jobs support for faster writes of in-memory tables, with validation and tests matching #1980.
Out of Scope Changes check ✅ Passed The changes stay focused on parallel write support, related validation, docs, and tests without unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@AdibaAdi

Copy link
Copy Markdown
Author

Hi @wjones127, this implements the n_jobs parameter discussed in #1980 and follows the parallel write_fragments plus final-commit approach from your reference example.

I kept the first version deliberately narrow: parallel writes support in-memory pyarrow.Table inputs with mode="overwrite". Parallel create and append are rejected because the current Python fragment-commit path cannot preserve their create-only and conflict-retry semantics safely.

I would especially appreciate your guidance on whether this Python-level orchestration is the right placement, or whether the worker count should eventually move into the Rust write path.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
python/python/tests/test_dataset.py (2)

6359-6367: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Parametrize instead of duplicating the assertion block.

Both pytest.raises blocks test the exact same error for different input types; this is precisely the "differ only by inputs" case the guideline calls out for @pytest.mark.parametrize.

As per coding guidelines, "Use @pytest.mark.parametrize for Python tests that differ only by inputs."

♻️ Proposed refactor
-def test_write_dataset_n_jobs_unsupported_input_type(tmp_path: Path):
-    table = _n_jobs_table(10)
-
-    with pytest.raises(TypeError, match="only supported for in-memory pyarrow.Table"):
-        lance.write_dataset(table.to_reader(), str(tmp_path / "reader"), n_jobs=2)
-
-    with pytest.raises(TypeError, match="only supported for in-memory pyarrow.Table"):
-        lance.write_dataset(table.to_pandas(), str(tmp_path / "pandas"), n_jobs=2)
+@pytest.mark.parametrize("convert", [lambda t: t.to_reader(), lambda t: t.to_pandas()])
+def test_write_dataset_n_jobs_unsupported_input_type(tmp_path: Path, convert):
+    table = _n_jobs_table(10)
+    with pytest.raises(TypeError, match="only supported for in-memory pyarrow.Table"):
+        lance.write_dataset(convert(table), str(tmp_path / "ds"), n_jobs=2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/tests/test_dataset.py` around lines 6359 - 6367, Refactor
test_write_dataset_n_jobs_unsupported_input_type to use pytest.mark.parametrize
for the reader and pandas input variants, passing each input and its
corresponding output path into one shared pytest.raises assertion. Preserve the
existing TypeError message match and n_jobs=2 behavior.

Source: Coding guidelines


6369-6396: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Only 2 of ~14 unsupported options are covered by tests.

_ensure_parallel_write_supported's unsupported list also rejects namespace_client, table_id, initial_bases, target_bases, target_all_bases, base_store_params, commit_lock, progress, auto_cleanup_options, transaction_properties, enable_stable_row_ids, external_blob_mode, allow_external_blob_outside_bases, and blob_pack_file_size_threshold, but only schema and commit_message are exercised here. A parametrized test over the full option list would catch a future regression where a new/renamed option is silently dropped from the rejection list.

As per coding guidelines, "Every bugfix and feature must have corresponding tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/tests/test_dataset.py` around lines 6369 - 6396, Expand the
n_jobs unsupported-option coverage in
test_write_dataset_n_jobs_unsupported_option and
test_write_dataset_n_jobs_schema_not_silently_ignored by parametrizing over
every option rejected by _ensure_parallel_write_supported, including
namespace_client, table_id, initial_bases, target_bases, target_all_bases,
base_store_params, commit_lock, progress, auto_cleanup_options,
transaction_properties, enable_stable_row_ids, external_blob_mode,
allow_external_blob_outside_bases, and blob_pack_file_size_threshold. Assert
each option raises the existing ValueError, while preserving the separate schema
and commit_message cases.
python/python/lance/dataset.py (2)

7415-7431: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Worker failures propagate without partition context.

_write_partition re-raises whatever write_fragments throws unchanged. When a failure happens with n_jobs workers running concurrently, the resulting exception gives no indication of which partition (offset/length) failed, making debugging harder than necessary.

As per coding guidelines, "Include full error context, including variable names, values, sizes, and types."

♻️ Proposed fix to add partition context on failure
-    def _write_partition(partition: pa.Table) -> List[FragmentMetadata]:
-        return write_fragments(
+    def _write_partition(
+        partition_index: int, partition: pa.Table
+    ) -> List[FragmentMetadata]:
+        try:
+            return write_fragments(
+                partition,
+                uri,
+                mode="overwrite",
+                max_rows_per_file=max_rows_per_file,
+                max_rows_per_group=max_rows_per_group,
+                max_bytes_per_file=max_bytes_per_file,
+                data_storage_version=data_storage_version,
+                storage_options=storage_options,
+            )
+        except Exception as e:
+            raise RuntimeError(
+                f"n_jobs parallel write failed on partition {partition_index} "
+                f"({partition.num_rows} rows): {e}"
+            ) from e
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/lance/dataset.py` around lines 7415 - 7431, Update the nested
_write_partition function to catch write_fragments failures and re-raise them
with the partition’s identifying context, including its offset and length (and
preserving the original exception as the cause). Keep the existing
fragment-writing arguments and executor.map ordering unchanged.

Source: Coding guidelines


7404-7413: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

No upper bound on effective worker/thread count.

num_workers = len(partitions) grows with n_jobs up to num_rows. A user passing a very large n_jobs on a large table could spawn a very large number of OS threads via ThreadPoolExecutor, risking resource exhaustion rather than throughput gains. Consider documenting the caveat prominently or capping the pool size relative to os.cpu_count()/IO parallelism defaults elsewhere in the codebase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/lance/dataset.py` around lines 7404 - 7413, Cap the effective
worker count assigned after partition creation in the surrounding dataset
operation, using the repository’s existing CPU/IO parallelism limit or an
os.cpu_count()-based bound. Preserve the non-empty-partition cap while
preventing very large n_jobs values from creating an unbounded
ThreadPoolExecutor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/python/lance/dataset.py`:
- Around line 7305-7446: Move parallel-write validation and orchestration out of
the Python-only helpers _validate_n_jobs, _ensure_parallel_write_supported, and
_write_dataset_parallel into the shared Rust core, exposing a binding-neutral
API that Python and Java can reuse. Preserve the existing n_jobs bounds,
mode/option compatibility checks, partitioning, fragment commit, and failure
semantics while keeping the Python binding as a thin wrapper with consistent
parameter names.

---

Nitpick comments:
In `@python/python/lance/dataset.py`:
- Around line 7415-7431: Update the nested _write_partition function to catch
write_fragments failures and re-raise them with the partition’s identifying
context, including its offset and length (and preserving the original exception
as the cause). Keep the existing fragment-writing arguments and executor.map
ordering unchanged.
- Around line 7404-7413: Cap the effective worker count assigned after partition
creation in the surrounding dataset operation, using the repository’s existing
CPU/IO parallelism limit or an os.cpu_count()-based bound. Preserve the
non-empty-partition cap while preventing very large n_jobs values from creating
an unbounded ThreadPoolExecutor.

In `@python/python/tests/test_dataset.py`:
- Around line 6359-6367: Refactor
test_write_dataset_n_jobs_unsupported_input_type to use pytest.mark.parametrize
for the reader and pandas input variants, passing each input and its
corresponding output path into one shared pytest.raises assertion. Preserve the
existing TypeError message match and n_jobs=2 behavior.
- Around line 6369-6396: Expand the n_jobs unsupported-option coverage in
test_write_dataset_n_jobs_unsupported_option and
test_write_dataset_n_jobs_schema_not_silently_ignored by parametrizing over
every option rejected by _ensure_parallel_write_supported, including
namespace_client, table_id, initial_bases, target_bases, target_all_bases,
base_store_params, commit_lock, progress, auto_cleanup_options,
transaction_properties, enable_stable_row_ids, external_blob_mode,
allow_external_blob_outside_bases, and blob_pack_file_size_threshold. Assert
each option raises the existing ValueError, while preserving the separate schema
and commit_message cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 47184fb3-23d4-4e4f-a67f-aa8d21797c86

📥 Commits

Reviewing files that changed from the base of the PR and between 252d81a and b785b67.

📒 Files selected for processing (2)
  • python/python/lance/dataset.py
  • python/python/tests/test_dataset.py

Comment on lines +7305 to +7446
# Parallel writes (n_jobs >= 2) currently support only mode="overwrite".
#
# "create" and "append" are intentionally excluded: the parallel path writes
# fragments and then commits them with a single LanceOperation.Overwrite. That
# operation ("overwrite or create", no read_version) can reproduce neither the
# atomic create-only guarantee the native writer enforces, nor append's
# conflict-detection/retry semantics, so supporting those modes here would
# silently weaken their concurrency guarantees.
# TODO(#1980): extend parallel writes to create/append once the fragment-commit
# path can preserve their transaction semantics (most likely a Rust-core impl,
# which owns these semantics and could be shared across all bindings).
_PARALLEL_WRITE_SUPPORTED_MODES = ("overwrite",)


def _validate_n_jobs(n_jobs: Optional[int]) -> None:
"""Validate the ``n_jobs`` argument of :func:`write_dataset`.

``None`` and ``1`` are always accepted and select the existing sequential
write path. Any other value must be an ``int`` >= 1. ``bool`` is rejected
explicitly because ``True``/``False`` are almost always a mistake for a
worker count.
"""
if n_jobs is None:
return
if isinstance(n_jobs, bool) or not isinstance(n_jobs, int):
raise TypeError(
f"n_jobs must be an int or None, got {type(n_jobs).__name__}: {n_jobs!r}"
)
if n_jobs < 1:
raise ValueError(f"n_jobs must be >= 1, got {n_jobs}")


def _ensure_parallel_write_supported(
data_obj: ReaderLike,
mode: str,
*,
unsupported: List[tuple[str, bool]],
) -> None:
"""Reject requests that the parallel write path cannot faithfully honor.

Parallel writes (``n_jobs >= 2``) fan a single in-memory table out into
concurrent :func:`lance.fragment.write_fragments` calls followed by one
commit. That path only reproduces a subset of :func:`write_dataset`
behavior, so anything it cannot honor is rejected with a clear error rather
than being silently ignored or run sequentially.
"""
if not isinstance(data_obj, pa.Table):
raise TypeError(
"n_jobs >= 2 is only supported for in-memory pyarrow.Table input, got "
f"{type(data_obj).__name__}. Convert the input to a pyarrow.Table, or "
"use the default n_jobs=1 for other input types."
)
if mode not in _PARALLEL_WRITE_SUPPORTED_MODES:
raise ValueError(
"n_jobs >= 2 currently only supports mode='overwrite' "
f"(create/append are not yet supported), got mode={mode!r}."
)
set_options = sorted(name for name, is_set in unsupported if is_set)
if set_options:
raise ValueError(
"n_jobs >= 2 cannot be combined with the following option(s): "
f"{', '.join(set_options)}. Use the default n_jobs=1 to use them."
)


def _write_dataset_parallel(
table: pa.Table,
uri: Union[str, Path, LanceDataset],
*,
n_jobs: int,
max_rows_per_file: int,
max_rows_per_group: int,
max_bytes_per_file: int,
data_storage_version: Optional[str],
storage_options: Optional[Dict[str, str]],
enable_v2_manifest_paths: bool,
) -> LanceDataset:
"""Write ``table`` with ``n_jobs`` parallel ``write_fragments`` calls and
commit the resulting fragments in a single ``LanceOperation.Overwrite``.

The caller guarantees ``table`` is a non-empty ``pyarrow.Table``,
``n_jobs >= 2`` and ``mode == "overwrite"`` (the only mode parallel writes
currently support). Fragments are collected in partition order so the
committed layout is deterministic.

Failure semantics: if a worker fails, no commit is performed and the dataset
is never updated, but fragment files written by already-completed workers are
left uncommitted in storage. This matches the low-level
:func:`lance.fragment.write_fragments` API used for distributed writes.
"""
from concurrent.futures import ThreadPoolExecutor

from .fragment import write_fragments

if isinstance(uri, Path):
uri = os.fspath(uri)

num_rows = table.num_rows
# Ceil division so every row falls into exactly one contiguous partition.
partition_size = -(-num_rows // n_jobs)
partitions = []
offset = 0
while offset < num_rows:
length = min(partition_size, num_rows - offset)
partitions.append(table.slice(offset, length))
offset += length
# Effective worker count is capped at the number of non-empty partitions, so
# a small table with a large n_jobs never spawns idle or empty workers.
num_workers = len(partitions)

def _write_partition(partition: pa.Table) -> List[FragmentMetadata]:
return write_fragments(
partition,
uri,
mode="overwrite",
max_rows_per_file=max_rows_per_file,
max_rows_per_group=max_rows_per_group,
max_bytes_per_file=max_bytes_per_file,
data_storage_version=data_storage_version,
storage_options=storage_options,
)

with ThreadPoolExecutor(max_workers=num_workers) as executor:
# executor.map preserves input (partition) order and, when materialized,
# raises the first worker error before we reach the commit below — so a
# failed parallel write never produces a partial dataset version.
per_partition_fragments = list(executor.map(_write_partition, partitions))

fragments = [
fragment
for partition_fragments in per_partition_fragments
for fragment in partition_fragments
]

operation = LanceOperation.Overwrite(table.schema, fragments)
return LanceDataset.commit(
uri,
operation,
storage_options=storage_options,
enable_v2_manifest_paths=enable_v2_manifest_paths,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

New parallel-write feature lives entirely in Python, not the Rust core.

_validate_n_jobs, _ensure_parallel_write_supported, and all partitioning/threading/commit orchestration in _write_dataset_parallel are pure Python. The repo's own TODO(#1980) comment (Lines 7313-7315) already flags that a Rust-core implementation is the more durable home for this logic ("most likely a Rust-core impl, which owns these semantics and could be shared across all bindings"). Today, if the Java binding wants the same n_jobs capability, it will have to re-derive the same validation rules (mode whitelist, unsupported-option list, worker-count math) independently, risking drift.

I recognize Lance's own "Distributed Write" guide demonstrates this exact write_fragments + ThreadPoolExecutor + LanceOperation.Overwrite pattern as a client-side technique, which somewhat justifies keeping the mechanics here — but the validation logic (which options are compatible with parallel writes, min/max n_jobs) is business logic that should ideally be centralized so it isn't reimplemented per binding.

As per coding guidelines, "Keep Python bindings as thin wrappers and centralize validation and logic in the Rust core" and "Keep Python and Java bindings thin, centralize validation and logic in Rust, and keep parameter names consistent across bindings."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/lance/dataset.py` around lines 7305 - 7446, Move parallel-write
validation and orchestration out of the Python-only helpers _validate_n_jobs,
_ensure_parallel_write_supported, and _write_dataset_parallel into the shared
Rust core, exposing a binding-neutral API that Python and Java can reuse.
Preserve the existing n_jobs bounds, mode/option compatibility checks,
partitioning, fragment commit, and failure semantics while keeping the Python
binding as a thin wrapper with consistent parameter names.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add n_jobs parameter to lance.write_dataset to speed up writing large in-memory tables

1 participant