[data] PR 1 of 8: Introduce TableDatasink + TableAdapter abstraction - #63619
abhishekverma-ray wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a generic table datasink framework for Ray Data, abstracting format-specific logic into a TableAdapter protocol and providing a generalized ParquetFileWriter for partitioned writes. The implementation includes the core TableDatasink orchestration and comprehensive unit tests. Feedback highlights the need to explicitly document or enforce the path attribute requirement on FileAction objects for reliable orphan cleanup. Additionally, it is recommended to log filesystem errors during directory creation and to optimize the partitioning logic, which currently exhibits O(N * K) complexity and may become a bottleneck for high-cardinality columns.
| @abstractmethod | ||
| def preflight( | ||
| self, | ||
| mode: SaveMode, | ||
| partition_cols: List[str], | ||
| declared_schema: Optional[pa.Schema], | ||
| ) -> None: | ||
| """Load the underlying table and validate the requested write. | ||
|
|
||
| Implementations typically: | ||
| * reach into the catalog / log to load the current table state, | ||
| * validate that the mode is legal against the table state | ||
| (e.g. UPSERT requires an existing table), | ||
| * validate partition columns / declared schema against the existing | ||
| table. | ||
|
|
||
| Must raise a descriptive error on conflict. | ||
| """ | ||
|
|
||
| def on_write_start( | ||
| self, schema_from_first_bundle: Optional[pa.Schema] = None | ||
| ) -> None: |
There was a problem hiding this comment.
what's the distinction between these 2 functions? Feels weird that there's iceberg specific logic in this abstraction.
There was a problem hiding this comment.
preflight
- Purpose: validate the requested write against the current table state. Has access to the user's stated intent (mode, partition_cols, declared_schema) but not the actual incoming data.
- When it raises: synchronously fails the write before any worker tasks are scheduled. No files are written, no orphan cleanup needed.
on_write_start
- Purpose: an optional hook fed the first input bundle's actual schema — i.e. the framework sniffs one Arrow table from the upstream operator and hands its schema to the adapter so the adapter can do data-dependent setup before workers start writing.
- When it does nothing: the default base-class implementation is a no-op. Adapters that don't need pre-write schema evolution just don't override it (e.g. Delta)
| def start_task(self, ctx: TaskContext) -> None: | ||
| """Called once per task before the first ``write_block``. | ||
|
|
||
| Adapters that need per-task state (e.g. a file writer, a write UUID | ||
| pulled from ``ctx.kwargs``) should initialize it here. Default: | ||
| no-op. | ||
| """ | ||
| return None |
There was a problem hiding this comment.
Not sure I understand the purpose of this function?
There was a problem hiding this comment.
start_task — per-task setup on the worker
- Where it runs: inside each Ray write task, on a worker process. The framework calls it exactly once per task, immediately before the first write_block of that task
|
9b544c8 to
760e1d3
Compare
760e1d3 to
99cb859
Compare
99cb859 to
24c0d73
Compare
24c0d73 to
8c4eb05
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Reviewed by Cursor Bugbot for commit 8c4eb05e17c6cff426b78d97ac78cacd81e9fb02. Configure here.
Adds python/ray/data/_internal/datasource/table/ -- a generic framework that unifies the distributed write plumbing shared by table-format datasinks (Iceberg, Delta, future Hudi). Supporting a new format reduces to writing a new TableAdapter. Modules: - modes.py: re-exports SaveMode; adds UpsertSemantics enum. - result.py: TableWriteTaskResult[FileAction] -- generic per-task result. - adapter.py: TableAdapter[FileAction] -- the per-format Strategy. The 11-method contract maps 1-to-1 to the approved design sequence diagram. - file_writer.py: DataFileWriter protocol + ParquetFileWriter implementation (partition encoding / file-action shape pluggable via callbacks). - table_datasink.py: TableDatasink -- the Template Method. Owns the Ray Data lifecycle, mode validation, schema unification, upsert-key concatenation, orphan-path collection, and routes every lifecycle step to the adapter. Adds tests/datasource/test_table_datasink.py exercising the framework in isolation via a FakeAdapter that records every lifecycle call. No production callers wired up in this PR; existing IcebergDatasink stays unchanged. Subsequent PRs migrate Iceberg onto the abstraction and add write_delta on top of it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8c4eb05 to
807a377
Compare
|
This pull request has been automatically marked as stale because it has not had You can always ask for help on our discussion forum or Ray's public slack channel. If you'd like to keep this open, just leave any comment, and the stale label will be removed. |
|
Please refer to #64300 instead. |

[data] Introduce
TableDatasink+TableAdapterabstractionSummary
This PR adds a generic framework,
python/ray/data/_internal/datasource/table/, that unifies the distributed write plumbing shared by Ray Data's table-format datasinks (Iceberg today, Delta next, Hudi/Paimon plausible). Supporting a new format reduces from "write a new ~3000-lineDatasinksubclass" to "implement aTableAdapter."This PR is strictly additive. No existing call sites are touched; no public API moves.
IcebergDatasinkcontinues to work unchanged. Subsequent PRs in the stack migrate Iceberg onto this abstraction (PR 2) and addwrite_deltaon top of it (PRs 3–8).Motivation
The pre-existing
IcebergDatasinkand the in-flightDeltaDatasinkwere structurally identical above the format layer:Without an abstraction, each format would re-implement that lifecycle (and reviewers would have to chase the same plumbing bugs in two places). This PR lifts the lifecycle once, behind a narrow
TableAdaptercontract, so format-specific code shrinks to "load the table, write a Parquet file, commit a transaction."What's in this PR
9 new files, ~2,400 LoC (~1,200 production + ~1,200 tests). Zero existing files modified.
Architecture
TableDatasinkis a Template Method;TableAdapteris the Strategy it delegates to. The framework owns what happens; adapters own how for one format. The full call sequence is reproduced as an ASCII diagram in theadapter.pymodule docstring and as Mermaid inSEQUENCE.md.Framework owns (
TableDatasink)on_write_start→ per-taskwrite→on_write_complete/on_write_failedsupported_modes; coerce string modes; rejectUPSERTfor adapters that don't conform toSupportsUpsertsunify_schemas(..., promote_types=True)soint32+int64→int64etc. before commitcommit_append; OVERWRITE →build_overwrite_predicate+commit_overwrite; UPSERT →build_upsert_predicate+commit_upsertupsert_keystables, concat, hand tobuild_upsert_predicateadapter.path_for_action); attach to exceptions soon_write_failedhands them back to the adapteradapter.path_for_action)Adapter contract — split in two
TableAdapter[FileAction, DeletePredicate]— the APPEND + OVERWRITE baseline every adapter implements:supported_modesSaveModes the adapter supportspreflighton_write_startstart_taskwrite_block(file_actions, emitted_schema, upsert_keys?)finalize_tasktask_metadatagather_task_metadatareconcile_schemabuild_overwrite_predicateoverwrite_filterinto the format's predicate typecommit_append/commit_overwritepath_for_action.path); used for dedup + orphan trackingon_failureSupportsUpserts[FileAction, DeletePredicate]— an opt-in,@runtime_checkableProtocol. Adapters that support UPSERT inherit (or structurally conform to) it; the framework dispatches viaisinstance(adapter, SupportsUpserts)and refusesmode="upsert"otherwise. This keeps the base contract free of upsert concerns for formats that don't support it (e.g. Delta in this stack):upsert_semanticsCOPY_ON_WRITEorMERGE_ON_READbuild_upsert_predicateNone⇒ pure insert)commit_upsertTableAdapteris parameterized on bothFileAction(the opaque per-file metadata, e.g. Iceberg'sDataFile/ Delta'sAddAction) andDeletePredicate(the format's predicate type, e.g. PyIceberg'sBooleanExpression/ Delta's SQLstr), so predicate types flow precisely frombuild_*tocommit_*.ParquetFileWriterA reusable
DataFileWriter. Partition-path encoding and file-action shape are pluggable via callbacks, so Delta'sAddActionand Iceberg'sDataFilecan share the same file-writer body. Partitioning uses a single sort + boundary-walk (O(N log N)) with a NaN-safe partition key, so NaN/None group correctly and buffered partitions coalesce acrossadd_tablecalls.Tests
test_table_datasink.py— framework-level unit tests via aFakeAdapterthat records every lifecycle call. Locks down mode validation andSupportsUpsertsgating; lifecycle ordering; per-mode commit dispatch (APPEND / OVERWRITE / UPSERT); schema type-promotion across workers; empty-write still commits; orphan-path forwarding on failure (and that secondary cleanup failures are swallowed); duplicate-path rejection; andpath_for_action(default + override) driving dedup.test_table_datasink_integration.py— real-I/O tests via aToyParquetAdapterthat drivesParquetFileWriteragainst aLocalFileSystemtmpdir: APPEND/OVERWRITE round-trips read back withpyarrow.parquet; buffering/flush semantics; on-disk schema unification; orphan-file cleanup; adapter pickling; and partitioning correctness (single/multi-column, NaN grouping + buffer coalescing, high-cardinality).Test plan
pytest python/ray/data/tests/datasource/test_table_datasink.py python/ray/data/tests/datasource/test_table_datasink_integration.py— 36 pass (23 unit + 13 integration).test_iceberg.pycollects/imports cleanly (nothing references the new module yet).Risk
Low. Purely additive new package. No existing code paths reach this module. If reviewers want to defer the abstraction work entirely, the directory can be deleted in a single revert with zero collateral.
Follow-ups (separate PRs)
IcebergDatasinkontoTableDatasink+IcebergAdapter(which conforms toSupportsUpsertsand overridespath_for_action→DataFile.file_path). Existingtest_iceberg.pyis the regression net.write_deltaAPPEND mode (new public API).write_deltamodes (OVERWRITE / ERROR / IGNORE), partitioning, schema evolution, cloud + idempotency.🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com