Skip to content

feat: support atomic conditional overwrite writes - #62

Draft
FANNG1 wants to merge 2 commits into
daft-engine:mainfrom
FANNG1:feat/overwrite-where
Draft

feat: support atomic conditional overwrite writes#62
FANNG1 wants to merge 2 commits into
daft-engine:mainfrom
FANNG1:feat/overwrite-where

Conversation

@FANNG1

@FANNG1 FANNG1 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add mode="overwrite_where" with a required predicate to daft_lance.write_lance: one Lance commit deletes the rows matching the predicate and adds the DataFrame's rows
  • run the per-fragment delete as a Daft job, pruning the fragments to visit through a scalar index when one answers the predicate
  • check by default that every input row satisfies the predicate, and refuse the check rather than trust it where Daft and Lance read the filter differently
  • normalize the new mode to a physical append so the existing schema, Blob-column and storage-version checks cover it

No Lance-side change: this composes LanceFragment.delete() with LanceOperation.Update.

Closes #61

Semantics

start() pins the dataset version; workers write the input into new fragments exactly as append does; finalize applies the predicate to the pinned snapshot's fragments and commits one LanceOperation.Update(updated_fragments, removed_fragment_ids, new_fragments, fields_modified=[]) against that version. Deletions write deletion files rather than rewriting data, so row addresses and the indexes built on them survive; the new fragments are unindexed and get scanned flat. Nothing matched and nothing written means no commit and no empty version.

Fragment pruning is opportunistic: when explain_plan shows the filter answered by a ScalarIndexQuery, a streamed row-address scan gives the exact fragment set; otherwise the delete visits every fragment, which costs the same single pass the pruning scan would have.

The input check

The delete range comes from predicate alone and the input is appended verbatim, so a row outside the predicate is never replaced by a re-run — it accumulates. validate_predicate=True (the default) rejects such rows per micropartition.

That check evaluates the predicate with Daft while the delete runs with Lance, so it is only worth as much as the two engines' agreement. Two cases are refused up front, before any data is written, both confirmed against lance 8.0.0:

  • float32/float16 columns. Daft widens 0.1f32 to 0.10000000149 and calls score > 0.1 true; Lance narrows the literal and calls it false. A row like that passed the check and was then never deleted, so re-running the write piled up duplicates.
  • predicates Daft types differently, such as a bare TIMESTAMP literal against a naive timestamp column. Daft parses it into a UTC-aware literal; previously only parsing was checked, so the write failed mid-flight with a raw DaftTypeError.

Both name validate_predicate=False in the error. The refusal is scoped to the columns the predicate actually reads, so a table that merely has a float32 column elsewhere still gets the check.

Concurrency caveat (documented, not detected)

Lance does not treat a concurrent Append as conflicting with this Update. A commit against a stale read version succeeds — verified with max_retries=0 — and rows another writer added during the overwrite survive it even when they match the predicate. The write_lance docstring and the README say so, and test_concurrent_append_survives_the_overwrite pins the behavior so a future Lance change surfaces here rather than silently.

Test plan

  • full suite on the native runner: 374 passed, 5 skipped, 2 xfailed, 2 xpassed
  • ruff format --check and ruff check clean
  • mypy clean for daft_lance/ and the new test file
  • Ray runner not exercised — ray is not installed in this environment. The delete job reuses the DatasetOpenContext + daft.cls shape that compaction and column merging already run on Ray, and the fragment frame is repartitioned by fragment id there (on the native runner repartition is a no-op that only warns), but that path is untested here.

26 tests in tests/io/lancedb/test_overwrite_where.py cover: multi-fragment replacement in exactly one version, idempotent re-runs including rows the previous run appended, empty input, a predicate matching nothing, a fully matched fragment being removed, input rows outside the predicate with and without the opt-out, NULL rows counting as not matching, the argument-validation matrix, storage-version and schema parity with append, invalid Lance filters, both dialect refusals, index pruning as a unit and end to end through two overwrites, scalar plus IVF_PQ index correctness after an overwrite, and a namespace-addressed table.

Mutation-checked rather than assumed: stubbing the overwrite out to a no-op fails 13 of the 26, and making pruning report no fragments fails 2.

Review history

The first commit is the feature; the second is the fixes from an independent review of it — the two dialect refusals above, repartitioning the delete job (it had been running as a single task), and two tests that had asserted so little they passed with the overwrite stubbed out.

FANNG1 added 2 commits August 25, 2026 22:08
Adds mode="overwrite_where" to daft_lance.write_lance: one Lance commit
deletes the rows matching a SQL predicate and adds the new data, so readers
see either the whole replacement or none of it.

The delete runs distributed, one task per fragment, reusing the
DatasetOpenContext pattern the merge and compaction paths already use. When a
scalar index covers the predicate, a row-address lookup prunes the fragment
list first; without one, every fragment goes to the workers rather than
paying for a serialized scan on the driver.

Input rows are checked against the predicate by default: overwrite_where
appends the input verbatim, so a row outside the predicate would survive the
next run of the same write instead of being replaced. validate_predicate=False
opts out.

The new mode writes exactly like an append and is normalized to it in the
constructor, so the existing schema, blob-column and storage-version checks
cover it -- adding a fourth mode string to those checks would have skipped
them silently.

Known gap, documented in write_lance and the README: Lance does not treat a
concurrent append as conflicting with the Update this commits, so rows another
writer adds during the overwrite survive it. test_concurrent_append_survives
_the_overwrite pins that behavior.

Co-authored-by: fanng <“fanng@apache.org”>

Claude-Session: https://claude.ai/code/session_017b8TVnEmyN8wbXFTHJwYip
Review of the previous commit found two ways the input-row check could hurt
rather than help, both confirmed against real datasets.

The check evaluates the predicate with Daft while the delete runs with
Lance/DataFusion, and the two engines disagree on a decimal literal compared
against a narrow float column: Daft widens 0.1f32 to 0.10000000149 and calls
"score > 0.1" true, Lance narrows the literal and calls it false. A row like
that passed validation and was then never deleted, so re-running the same
write piled up duplicates -- exactly what the check exists to prevent. A
predicate reading a float32/float16 column is now refused up front.

The driver-side check also only parsed the Daft expression, never evaluated
it, so a Lance-valid predicate Daft types differently ("t >= TIMESTAMP '...'"
against a naive timestamp column, the shape the docstring advertises) failed
mid-write with a raw DaftTypeError and no pointer to validate_predicate=False.
It is now evaluated against a zero-row input typed like the table.

Also: from_pylist produces a single partition, so the per-fragment delete ran
as one task on a distributed runner. It is now repartitioned by fragment id,
except on the native runner where repartition is a no-op that only warns. The
dead concurrency parameter is gone and the docstrings that claimed the work
spread across the cluster now match what it does.

Tests: the concurrency and idempotence tests asserted so little that they
passed with the overwrite stubbed out to a no-op, and no test drove the
index-pruning path end to end. Both now assert whole row sets, and a new test
overwrites twice through an index on the predicate column -- the second run
only replaces the row the first one appended if pruning still finds that
fragment. Stubbing the overwrite to a no-op now fails 13 of 26 tests instead
of 7 of 22, and a pruning miss fails 2.

Co-authored-by: fanng <“fanng@apache.org”>

Claude-Session: https://claude.ai/code/session_017b8TVnEmyN8wbXFTHJwYip
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support atomic conditional overwrite writes (INSERT OVERWRITE ... WHERE)

1 participant