Skip to content

No public API to preserve stable row IDs when assembling an Update transaction externally #8317

Description

@justinrmiller

Environment: pylance 9.0.0-beta.21 (behavior verified empirically); source citations from v9.1.0-beta.2 (423c1943).

LanceDataset.update() preserves stable row IDs when it rewrites a row. The same edit assembled externally — delete the old row, write a replacement fragment, commit LanceOperation.Update — cannot, because there is no public way to tell Lance that the appended rows carry pre-existing row IDs. The rewritten row silently receives a fresh ID.

This blocks distributed engines that compute updated values on workers and assemble the transaction on a driver.

Repro

import tempfile
import lance
import pyarrow as pa
from lance.fragment import write_fragments

def ids(ds):
    t = ds.to_table(columns=["id"], with_row_id=True)
    return dict(zip(t["id"].to_pylist(), t["_rowid"].to_pylist()))

# (a) built-in update preserves stable row ids
uri_a = tempfile.mkdtemp() + "/a.lance"
ds = lance.write_dataset(pa.table({"id": [1, 2, 3, 4], "v": [10, 20, 30, 40]}),
                         uri_a, max_rows_per_file=2, enable_stable_row_ids=True)
before = ids(ds)
ds.update({"v": "99"}, where="id = 2")
ds = lance.dataset(uri_a)
print(ids(ds) == before)                                            # True
print(ds.read_transaction(ds.version).operation.update_mode)        # rewrite_rows

# (b) the same edit assembled externally loses the row id
uri_b = tempfile.mkdtemp() + "/b.lance"
ds = lance.write_dataset(pa.table({"id": [1, 2, 3, 4], "v": [10, 20, 30, 40]}),
                         uri_b, max_rows_per_file=2, enable_stable_row_ids=True)
before = ids(ds)
updated = ds.get_fragments()[0].delete("id = 2")
new = write_fragments(pa.table({"id": [2], "v": [99]}), uri_b)
op = lance.LanceOperation.Update(
    removed_fragment_ids=[], updated_fragments=[updated],
    new_fragments=list(new), fields_modified=[],
)
lance.LanceDataset.commit(uri_b, op, read_version=ds.version)
print(ids(lance.dataset(uri_b)) == before)                          # False

Output:

(a) built-in update, ids preserved: True
    update_mode recorded: rewrite_rows
(b) externally assembled update, ids preserved: False
    row holding id=2 was rowid 1 -> now 4

Note that (a) and (b) are the same shapeupdate_mode is rewrite_rows, i.e. delete-in-place plus append. The built-in path preserves the ID because it carries the original sequence through internally; an external caller has no way to express that.

Why there is no workaround today

  • write_fragments exposes only enable_stable_row_ids: bool — no way to supply IDs. (Per write_fragments(enable_stable_row_ids=True) silently produces fragments without row-id metadata, rejected on commit #7702 that flag is a no-op there anyway, and commit-time assignment is the sound design for newly inserted rows, since sequences come from the manifest's single next_row_id counter. This request is the different case: rows that already have IDs and are being rewritten.)
  • FragmentMetadata accepts row_id_meta, but RowIdMeta has no constructor — RowIdMeta(...) raises TypeError: cannot create 'lance.fragment.RowIdMeta' instances. The only way in is from_json/from_dict, i.e. hand-authoring the internal RowIdSequence protobuf (protos/rowids.proto). Consumers pin a version floor, so a change to that internal encoding would silently mis-assign row IDs — the exact invariant at stake.
  • UpdateMode::RewriteColumns does preserve IDs, since fragment identity is retained. But it rewrites the full column within every touched fragment, so cost is O(rows in touched fragments) rather than O(matched rows). For a low-selectivity update (say 10 matched rows across three 1M-row fragments) that is a ~10⁵× write amplification, which defeats the point of a sparse update.
  • Relatedly, the Python→Rust conversion for Update hardcodes updated_fragment_offsets: None and inserted_rows_filter: None (python/src/transaction.rs), so the partial RewriteColumns form — documented in transaction.rs as "Used with stable row IDs so build_manifest can refresh row-level version metadata only for rows that were rewritten" — is not expressible from Python either.

Proposed fix

In preference order:

  1. Let the caller supply preserved IDs on LanceOperation.Update — e.g. a field parallel to new_fragments carrying, per new fragment, the pre-existing row IDs its rows correspond to. The Update arm would consume those in place of allocating from next_row_id. This keeps the sequence encoding private and is the minimal surface that unblocks the case.
  2. A public constructor for row-id sequencesRowIdMeta.from_ids([...]) or an equivalent builder — so FragmentMetadata(..., row_id_meta=...) is expressible without hand-encoding protobuf. More general, but exposes the encoding as API.
  3. Expose updated_fragment_offsets (and inserted_rows_filter) through the Python Update conversion, so partial RewriteColumns is reachable. Useful independently of 1 and 2.

Validation for either of 1 or 2 should reject supplied IDs that are not currently live in the dataset, and reject duplicates, so a malformed external transaction fails at commit rather than corrupting the ID space.

Relationship to #7702

#7702 covers write_fragments(enable_stable_row_ids=True) being a silent no-op and the Merge arm skipping ID/fragment-ID assignment. This issue is about the Update arm and is not fixed by that one: even with write_fragments behaving correctly, there is still no way to say "these rows keep the IDs they already had." Both trace to the same root — row-ID assignment happens at commit and external callers cannot participate — so they may be worth addressing together.

Impact

Found while making a distributed sparse-update path preserve row identity. Downstream, consumers key on _rowid to join derived tables back to source rows; when an update silently reassigns IDs, those consumers treat the rewritten rows as new (duplicating derived rows) while the rows keyed by the old IDs are orphaned. The only currently-available correct option is the full column rewrite, which erases the performance advantage that motivated the sparse path.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions