You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Note that (a) and (b) are the same shape — update_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.
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 partialRewriteColumns 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:
Let the caller supply preserved IDs onLanceOperation.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.
A public constructor for row-id sequences — RowIdMeta.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.
Exposeupdated_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.
#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.
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, commitLanceOperation.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
Output:
Note that (a) and (b) are the same shape —
update_modeisrewrite_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_fragmentsexposes onlyenable_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 singlenext_row_idcounter. This request is the different case: rows that already have IDs and are being rewritten.)FragmentMetadataacceptsrow_id_meta, butRowIdMetahas no constructor —RowIdMeta(...)raisesTypeError: cannot create 'lance.fragment.RowIdMeta' instances. The only way in isfrom_json/from_dict, i.e. hand-authoring the internalRowIdSequenceprotobuf (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::RewriteColumnsdoes 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.Updatehardcodesupdated_fragment_offsets: Noneandinserted_rows_filter: None(python/src/transaction.rs), so the partialRewriteColumnsform — documented intransaction.rsas "Used with stable row IDs sobuild_manifestcan refresh row-level version metadata only for rows that were rewritten" — is not expressible from Python either.Proposed fix
In preference order:
LanceOperation.Update— e.g. a field parallel tonew_fragmentscarrying, per new fragment, the pre-existing row IDs its rows correspond to. The Update arm would consume those in place of allocating fromnext_row_id. This keeps the sequence encoding private and is the minimal surface that unblocks the case.RowIdMeta.from_ids([...])or an equivalent builder — soFragmentMetadata(..., row_id_meta=...)is expressible without hand-encoding protobuf. More general, but exposes the encoding as API.updated_fragment_offsets(andinserted_rows_filter) through the PythonUpdateconversion, so partialRewriteColumnsis 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 theMergearm skipping ID/fragment-ID assignment. This issue is about theUpdatearm and is not fixed by that one: even withwrite_fragmentsbehaving 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
_rowidto 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.