Skip to content

feat(mem_wal): row-level delete via tombstone rows#7417

Merged
hamersaw merged 4 commits into
lance-format:mainfrom
hamersaw:feature/wal-delete
Jun 24, 2026
Merged

feat(mem_wal): row-level delete via tombstone rows#7417
hamersaw merged 4 commits into
lance-format:mainfrom
hamersaw:feature/wal-delete

Conversation

@hamersaw

Copy link
Copy Markdown
Contributor

Summary

Adds row-level DELETE to the in-memory WAL (mem_wal), expressed as tombstone (sentinel) rows: a delete appends the primary key plus _tombstone = true (null elsewhere). A tombstone is the newest value for its PK — it wins newest-per-PK resolution (suppressing the older real row) and is then silently dropped from query results.

This is the lance (OSS) half of the WAL delete feature. The sophon half (compaction hard-delete + serving) consumes this API separately.

What's here

  • _tombstone column — lance-owned, non-nullable Boolean appended to the memtable/generation schema only (never the base table). ShardWriter::open extends the caller's base schema internally; callers keep passing the base schema and never name the column.
  • Write pathShardWriter::put injects _tombstone = false; new ShardWriter::delete(keys) builds the tombstone batch from a key-only input. Legacy WAL entries (written before this change) are back-filled on replay.
  • HNSW null-vector fix — mem_wal's HNSW rejected null vectors outright, so it couldn't handle a nullable vector column at all (a pre-existing bug, independent of deletes). append_batch now skips null rows like the base-table index, compacting survivors while preserving their original offsets in row_ids so search still resolves to the right row.
  • Read paths
    • Filtered read: fold NOT _tombstone into the predicate per WAL arm (only when the source carries the column), so it runs before the per-source pushdown limit and post-dedup in the active arm.
    • Point lookup: carry _tombstone through and filter after CoalesceFirstExec (per-source filtering would let the coalesce fall through and resurrect the row); the plan-free BTree fast path short-circuits a tombstone hit to not-found.
    • Vector / FTS: no filter needed — a null-payload tombstone is never an index candidate; the deleted real row is suppressed by the existing block-list (cross-gen) / NewestPkFilter (within active).

Notable design points

  • Fold/carry _tombstone only when the source schema has the column — back-compat for legacy generations and existing tests for free.
  • _tombstone is non-nullable so the point-lookup base arm can synthesize a matching Literal(false) for CoalesceFirstExec's exact-schema check.
  • Delete requires non-PK columns to be nullable (a tombstone nulls them); surfaced as a clear error otherwise.
  • Caveat documented in code: a delete-heavy burst between compactions can under-deliver a LIMIT/top-k query (uncompensated block-list drops) — recall degradation, not wrong content; self-limiting as compaction drains tombstones.

Testing

19 new tests; full mem_wal suite green (417 passed, 0 failed). cargo fmt + cargo clippy -p lance --tests clean. Coverage: filtered-read masking + filter + limit, point-lookup resurrection guards (fast + plan paths), delete-then-reinsert, delete-of-absent, HNSW null-vector handling (mid-batch + all-null), write-path injection/round-trip and validation.

Draft — opening for early review.

🤖 Generated with Claude Code

Add row-level DELETE to the in-memory WAL. A delete is a tombstone
(sentinel) row: an append carrying the primary key plus `_tombstone =
true` and null elsewhere. The tombstone wins newest-per-PK resolution
(suppressing the older real row) and is then dropped from query results.

- `_tombstone`: lance-owned, non-nullable Boolean appended to the
  memtable/generation schema (not the base table). `ShardWriter::open`
  extends the caller's base schema; `put()` injects `false`; new
  `ShardWriter::delete(keys)` builds the tombstone batch. No caller names
  the column. Legacy WAL replay back-fills it.
- Fix mem_wal HNSW to skip null-vector rows instead of erroring (a
  pre-existing bug — mem_wal could not handle a nullable vector column at
  all). Survivors keep their original offsets in `row_ids`.
- Read paths: filtered read folds `NOT _tombstone` into the predicate per
  WAL arm (only when the source carries the column); point lookup carries
  `_tombstone` through and filters after `CoalesceFirstExec`, with the
  plan-free BTree fast path short-circuiting a tombstone hit to not-found.
  Vector/FTS need no filter — a null-payload tombstone is never an index
  candidate; recency rides the existing block-list / NewestPkFilter.

19 new tests (read paths, point-lookup resurrection guards, HNSW
null-vector handling, write path); full mem_wal suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@github-actions github-actions Bot added the enhancement New feature or request label Jun 23, 2026
hamersaw and others added 2 commits June 23, 2026 07:00
# Conflicts:
#	rust/lance/src/dataset/mem_wal/write.rs
put_no_wait routed batches straight to insert_batches_only without the
_tombstone = false injection that put applies, so base-shaped batches
failed the extended memtable schema check. Mirror put's normalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Two correctness fixes for WAL row-level delete, surfaced by the wallop fuzz:

- merge_insert took the scalar-index-scan join whenever ANY join column was
  indexed. For a composite primary key with only one indexed column (the WAL
  base table: BTree on `id`, PK `(id, shard_key)`), that partial-index path
  under-matched, so a key-only `when_matched_delete` silently deleted nothing.
  Compaction then trimmed the WAL tombstone while the base row survived and the
  deleted row resurfaced. The indexed-scan path is now used only when every
  join column is indexed; a partially-indexed composite key falls back to the
  correct full path.

- Flushing a generation with no indexable vectors (all tombstones — delete
  nulls the vector column and the HNSW skips nulls) errored with "HnswMemIndex
  is empty". It now skips the empty index and still flushes the data; an
  index-only (fast_search) vector query over that generation returns empty,
  with no brute-force scan.

Tests: indexed composite-key delete, all-tombstone flush, and delete+flush
(same-generation / cross-generation / cross-generation-indexed) regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@hamersaw
hamersaw marked this pull request as ready for review June 23, 2026 15:24

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

don't have much time to review for now, mostly looks good, will probably do some post-merge review to catch up

@hamersaw
hamersaw merged commit 61f664c into lance-format:main Jun 24, 2026
31 checks passed
@hamersaw
hamersaw deleted the feature/wal-delete branch June 24, 2026 02:26
BubbleCal pushed a commit that referenced this pull request Jun 30, 2026
## Summary

Adds row-level **DELETE** to the in-memory WAL (`mem_wal`), expressed as
**tombstone (sentinel) rows**: a delete appends the primary key plus
`_tombstone = true` (null elsewhere). A tombstone is the newest value
for its PK — it wins newest-per-PK resolution (suppressing the older
real row) and is then silently dropped from query results.

This is the lance (OSS) half of the WAL delete feature. The sophon half
(compaction hard-delete + serving) consumes this API separately.

## What's here

- **`_tombstone` column** — lance-owned, non-nullable `Boolean` appended
to the memtable/generation schema only (never the base table).
`ShardWriter::open` extends the caller's base schema internally; callers
keep passing the base schema and never name the column.
- **Write path** — `ShardWriter::put` injects `_tombstone = false`; new
**`ShardWriter::delete(keys)`** builds the tombstone batch from a
key-only input. Legacy WAL entries (written before this change) are
back-filled on replay.
- **HNSW null-vector fix** — mem_wal's HNSW rejected null vectors
outright, so it couldn't handle a nullable vector column at all (a
pre-existing bug, independent of deletes). `append_batch` now skips null
rows like the base-table index, compacting survivors while preserving
their original offsets in `row_ids` so search still resolves to the
right row.
- **Read paths**
- *Filtered read*: fold `NOT _tombstone` into the predicate per WAL arm
(only when the source carries the column), so it runs before the
per-source pushdown limit and post-dedup in the active arm.
- *Point lookup*: carry `_tombstone` through and filter **after**
`CoalesceFirstExec` (per-source filtering would let the coalesce fall
through and resurrect the row); the plan-free BTree fast path
short-circuits a tombstone hit to not-found.
- *Vector / FTS*: no filter needed — a null-payload tombstone is never
an index candidate; the deleted real row is suppressed by the existing
block-list (cross-gen) / `NewestPkFilter` (within active).

## Notable design points

- Fold/carry `_tombstone` **only when the source schema has the column**
— back-compat for legacy generations and existing tests for free.
- `_tombstone` is non-nullable so the point-lookup base arm can
synthesize a matching `Literal(false)` for `CoalesceFirstExec`'s
exact-schema check.
- Delete requires non-PK columns to be nullable (a tombstone nulls
them); surfaced as a clear error otherwise.
- Caveat documented in code: a delete-heavy burst between compactions
can under-deliver a `LIMIT`/top-k query (uncompensated block-list drops)
— recall degradation, not wrong content; self-limiting as compaction
drains tombstones.

## Testing

19 new tests; full `mem_wal` suite green (417 passed, 0 failed). `cargo
fmt` + `cargo clippy -p lance --tests` clean. Coverage: filtered-read
masking + filter + limit, point-lookup resurrection guards (fast + plan
paths), delete-then-reinsert, delete-of-absent, HNSW null-vector
handling (mid-batch + all-null), write-path injection/round-trip and
validation.

Draft — opening for early review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
BubbleCal pushed a commit that referenced this pull request Jun 30, 2026
Backport of the following PRs:
- #7362
- #7417
- #7482
- #7483
- #7489

This PR backports the changes from the original PRs to the release/v8.0
branch.

---------

Co-authored-by: Dan Rammer <[email protected]>
Co-authored-by: Lance Release Bot <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
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.

2 participants