Skip to content

feat: support atomic insert_overwrite writes - #62

Merged
universalmind303 merged 5 commits into
daft-engine:mainfrom
FANNG1:feat/overwrite-where
Sep 10, 2026
Merged

universalmind303 merged 5 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="insert_overwrite" with a required overwrite_where SQL filter to daft_lance.write_lance: one Lance commit deletes matching rows and adds the DataFrame rows
  • run the per-fragment delete as a Daft job, using scalar-index pruning when the filter plan supports it
  • append the input DataFrame as-is; overwrite_where selects existing rows to remove
  • normalize this logical mode to physical append, retaining existing schema, Blob-column, and storage-version validation

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

Closes #61

Semantics

start() pins the dataset version. Workers write input into new fragments as append does; finalize applies overwrite_where to that pinned snapshot and commits one LanceOperation.Update(updated_fragments, removed_fragment_ids, new_fragments, fields_modified=[]). Deletes write deletion files rather than rewriting data, so existing row addresses and indexes remain valid. New fragments are unindexed and are scanned flat. If no rows match and no data is written, no version is created.

Fragment pruning is opportunistic: when explain_plan shows ScalarIndexQuery, a streamed _rowaddr scan derives the exact matching fragment IDs; otherwise every fragment is visited, avoiding an additional full scan merely to plan pruning.

The input is appended verbatim. Rows outside overwrite_where are not replaced by a later re-run and can accumulate, so callers needing idempotent replacement should filter the input first.

Concurrency behavior

Lance does not currently treat a concurrent Append as conflicting with this Update: rows another writer adds during the overwrite can survive even if they match overwrite_where. README.md documents this behavior, and test_concurrent_append_survives_the_overwrite pins it for future Lance changes.

Test plan

  • native full suite: 368 passed, 5 skipped, 2 xfailed, 2 xpassed
  • ruff format --check and ruff check
  • mypy for daft_lance/ and the focused test file
  • local Ray runner: 20 focused tests, including a 3-partition ShuffleRead -> UDFProject delete stage

Focused coverage in tests/io/lancedb/test_insert_overwrite.py includes multi-fragment atomic replacement, idempotent re-runs, empty/no-match cases, full-fragment removal, argument and schema validation, index pruning, scalar and IVF_PQ index behavior, namespace tables, and concurrent append behavior.

Mutation checks verify that a no-op overwrite or empty pruning result makes behavioral tests fail.

FANNG1 added 5 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
@FANNG1 FANNG1 changed the title feat: support atomic conditional overwrite writes feat: support atomic insert_overwrite writes Sep 10, 2026
@FANNG1
FANNG1 marked this pull request as ready for review September 10, 2026 08:05

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

thanks @FANNG1

@universalmind303
universalmind303 merged commit 1c4375a into daft-engine:main Sep 10, 2026
5 checks passed
rchowell pushed a commit that referenced this pull request Sep 14, 2026
Bump the version in pyproject.toml and uv.lock so a release can be cut
from main. 0.4.0 was released on 2026-06-05 and main has 11 unreleased
commits since, including Lance Namespace support (#35), atomic
insert_overwrite writes (#62), and segmented bitmap indexes (#31).
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)

2 participants