Skip to content

feat: support distributed merge_into - #5282

Open
raygao25 wants to merge 1 commit into
lance-format:mainfrom
raygao25:feat/distributed-merge-into
Open

raygao25 wants to merge 1 commit into
lance-format:mainfrom
raygao25:feat/distributed-merge-into

Conversation

@raygao25

@raygao25 raygao25 commented Aug 19, 2026 •

Copy link
Copy Markdown

Summary

Adds lance_ray.merge_into for large-scale, daily merge ingestions: match a source batch to a Lance table on a join key, update existing rows, and insert new ones, committed as a single atomic version.

It is the distributed counterpart of pylance’s LanceDataset.merge_insert(on).when_matched_update_all().when_not_matched_insert_all(), for Ray jobs where the source and target are too large to process on a single machine. Unlike lance-spark’s MERGE INTO, which shuffle-joins the source against the target, this path probes a scalar index (BTREE is preferred) to map each source key to its target fragment, then applies updates.

How it works

  1. Dedupe — sort the source on the join key across Ray and keep one row per key.
  2. Plan — each worker maps its keys to target fragment ids with batched index lookups (key IN (...), served by a BTREE/scalar index when present), then shuffles rows to the worker that owns that fragment.
  3. Apply — matched rows are masked with per-fragment deletion files (data files are not rewritten); replacements and inserts are appended as new fragments.
  4. Commit — one LanceOperation.Update on the driver. Concurrent appends rebase; a concurrent rewrite of a touched fragment fails rather than silently dropping the other write.
import lance_ray as lr

dataset = lr.merge_into(daily_batch, "s3://bucket/users.lance", on="user_id", num_workers=128)

A scalar index on the join key is strongly recommended for large targets.

Performance

Measured merging 1M source rows into a 2.5B-row table with a BTREE index on the join key, using 64 Ray workers (2 cpu each):

Phase Time
Dedupe (repartition + sort + drop dups) 11.0 s
Plan (index probe) 36.1 s
Apply + commit 2.7 s
Total 49.8 s

Peak memory ~5.5 GB.

Screenshot 2026-08-18 at 6 27 10 PM

Test plan

  • uv run pytest tests/test_merge_into.py
  • uv run ruff check lance_ray tests
  • uv run ruff format --check lance_ray tests

@github-actions github-actions Bot added the enhancement New feature or request label Aug 19, 2026
@raygao25
raygao25 marked this pull request as ready for review August 20, 2026 03:07
@hfutatzhanghb

Copy link
Copy Markdown
Contributor

Hi, @raygao25 . May i have your contact information, i am very interested in this PR and i hope we can communicate with E-mail or Wechat. Could you leave your e-mail here? Thanks!

@hfutatzhanghb hfutatzhanghb 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.

Bugbot review: five correctness findings, each recorded as an inline discussion thread.

Comment thread lance_ray/merge_into.py
**namespace_kwargs,
)
current_ids = {f.fragment_id for f in current.get_fragments()}
if not touched_fragment_ids.issubset(current_ids):

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.

[P1] Detect changes to touched fragment metadata before rebasing

The retry gate only checks that every touched fragment ID still exists. A concurrent delete or update can keep the same fragment ID while replacing its metadata or deletion vector. Advancing read_version and resubmitting metadata built from the old snapshot can then overwrite that concurrent change and potentially make deleted rows visible again. Please compare each touched fragment against its baseline metadata before retrying, or keep the original read version and let Lance reject the conflict.

Comment thread lance_ray/merge_into.py
storage_options=storage_options or None,
**namespace_kwargs,
)
except Exception as exc:

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.

[P1] Retry only known version conflicts

This catches every exception from commit, including ambiguous transport failures. If the transaction was committed but the success response was lost, retrying the same operation can apply it twice; insert-only merges are especially exposed because touched_fragment_ids is empty and the safety check always passes. Please retry only an explicit manifest/version conflict. Other errors should propagate unless the first transaction can be identified and checked idempotently.

Comment thread lance_ray/merge_into.py
append_parts = replacement_parts + insert_parts
if append_parts:
append_table = pa.concat_tables(append_parts)
fragments = lance.fragment.write_fragments(

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.

[P2] Preserve stable row IDs for replacement rows

For a dataset with stable row IDs, the plan phase captures the matched target _rowid, but the apply phase drops the helper column and writes replacement rows through write_fragments without attaching row_id_meta. Those logical rows can therefore receive new stable IDs after an update. Native Lance merge_insert captures the old IDs and writes them into the replacement fragment metadata. Please preserve matched row IDs, assign new IDs only to inserts, and add a test comparing _rowid before and after the merge.

Comment thread lance_ray/merge_into.py
hits.column("_rowid").to_pylist(),
strict=False,
):
rowinfo_of[key] = (rowaddr >> 32, rowid)

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.

[P2] Handle every target row matching a join key

The lookup may return multiple target rows for the same join key, but this assignment stores only one tuple per key and silently overwrites earlier matches. Only the last target row is deleted and replaced, leaving other matching rows unchanged. Please either retain and route all matching row IDs, or explicitly reject non-unique target keys if uniqueness is part of the API contract.

Comment thread lance_ray/merge_into.py

# Ship each row's target rowid with it so every bucket slice below
# carries the ids of the target rows it replaces (-1 on insert rows).
source_chunk = source_chunk.append_column(

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.

[P3] Avoid colliding with a user column

The aligned source retains every target-schema field, so a target can legitimately contain a column named __merge_into_rowid. Appending the helper with the same name creates duplicate fields, and subsequent name-based column access or removal becomes ambiguous in PyArrow. Please generate a helper name that is absent from the target schema, or keep row IDs in a separate sidecar payload.

@hfutatzhanghb

Copy link
Copy Markdown
Contributor

Hi, @yanghua . Could you please help update pylance version to 11.0.0 ? I think we will need RowIdSequence ability which was published in v11.0.0. Thanks very much!

@raygao25

Copy link
Copy Markdown
Author

Hi, @raygao25 . May i have your contact information, i am very interested in this PR and i hope we can communicate with E-mail or Wechat. Could you leave your e-mail here? Thanks!

Hi, my email is r25gao@gmail.com. Happy to collaborate on this!

@raygao25

Copy link
Copy Markdown
Author

Hi, @raygao25 . May i have your contact information, i am very interested in this PR and i hope we can communicate with E-mail or Wechat. Could you leave your e-mail here? Thanks!

Hi, my email is r25gao@gmail.com. Happy to collaborate on this!

Hi @hfutatzhanghb in case you missed the above.

@hfutatzhanghb

hfutatzhanghb commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Hi, @raygao25 . May i have your contact information, i am very interested in this PR and i hope we can communicate with E-mail or Wechat. Could you leave your e-mail here? Thanks!

Hi, my email is r25gao@gmail.com. Happy to collaborate on this!

Hi @hfutatzhanghb in case you missed the above.

Hi, Ray. I have send you an E-mail with this id : hfutzhanghb@163.com

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants