Skip to content

fix(mem_wal): read-your-writes via split index-apply and dual visibility cursors#1

Closed
hamersaw wants to merge 22 commits into
feat/wal-open-validationfrom
feat/wal-read-visibility
Closed

fix(mem_wal): read-your-writes via split index-apply and dual visibility cursors#1
hamersaw wants to merge 22 commits into
feat/wal-open-validationfrom
feat/wal-read-visibility

Conversation

@hamersaw

Copy link
Copy Markdown
Owner

Stack: 2/3 — WAL read-visibility redesign

The core of the former lance-format#7791. Rows become visible to readers only once they are both indexed and WAL-durable, resolved through two independent cursors instead of a single conflated watermark. Index application runs on its own task rather than as an arm of the WAL flush, giving read-your-writes in every mode.

Commits (bottom → top):

  • mark replayed batches durable so the next flush does not re-append — replayed rows are already WAL-durable; stamp the cursor so the first post-reopen flush doesn't re-append or re-index them.
  • delete dead WAL-tracking state from MemTable
  • make the WAL durability cursor writer-global / make the visibility cursor an exclusive count — the two-cursor model: an indexed cursor and a writer-global durability cursor; readers snapshot visible_count derived from both.
  • publish rows only once they are indexed and durable
  • run the index apply on its own task, not as an arm of the WAL flush
  • rotate memtables during replay instead of overflowing
  • validate single-column PKs and restore index-apply stats
  • keep the latched failure across a poisoned terminal_error lock
  • harden shard-open validation and freeze failure handling — move PK/index/interval validation before claim_epoch (a doomed open must not fence the incumbent); reject a config whose field_id names a different column than its column; retain the outgoing memtable and poison on a failed freeze dispatch.
  • reject a batch position past committed_len instead of skipping it

Stack

  1. fix(mem_wal): harden shard open and fail reads on a poisoned writer lance-format/lance#7872 — foundational hardening → main
  2. this PR — read-visibility redesign → feat/wal-open-validation (PR 1's branch)
  3. flush-interval ticker → this branch

Based on PR 1 and must merge after it.

Review note: this PR targets the PR-1 branch (intra-fork) so the diff shows only this PR's 11 commits. Once lance-format#7872 merges, this will be retargeted to main.

🤖 Generated with Claude Code

…#7870)

## What

`is_index_remap_caught_up` (used by `cleanup_frag_reuse_index`) reports
an index as *not* caught up whenever its `dataset_version` predates a
fragment-reuse (FRI) version — even when the index covers none of the
fragments that version rewrote.

On a table with more than one index this pins the fragment-reuse index
forever:

- a compaction with deferred remap rewrites fragments covered by index A
but not index B, and writes an FRI version;
- the remap catch-up remaps A and advances A's `dataset_version`, but B
covers none of those fragments, so its remap is a no-op and B's
`dataset_version` never advances;
- `cleanup_frag_reuse_index` retains the version because B fails the
version gate, even though B needs nothing;
- the FRI (and the old fragments it references) can never be trimmed or
garbage-collected, and any catch-up that fires while the FRI is
non-empty re-runs forever making no progress.

## Fix

Treat an index whose `fragment_bitmap` is disjoint from every fragment
the reuse chain touches (old **and** new) as caught up, regardless of
`dataset_version`: it holds no row address the FRI would remap, so
trimming can never strand it (fragment ids are never reused).

The disjointness set must include the **new** fragments, not only the
old ones: a deferred-remap commit advances a *covering* index's bitmap
onto the new fragments before its data is remapped, so an old-frag-only
check would wrongly clear a still-stale index and trim a version it
needs. Indexes that intersect the chain fall through to the existing
conservative logic unchanged.

## Tests

Adds `test_caught_up_uses_fragment_coverage_not_only_version` covering:
- non-covering index with a stale version → caught up (the bug);
- covering index still holding the old fragment → not caught up;
- covering index advanced onto the new fragment but not yet remapped →
not caught up (why new frags are in the check);
- remapped index (version advanced) → caught up;
- fragment removed outright (deletion) with an emptied index → caught up
(not pinned forever).

Verified the test fails without the fix. Existing `frag_reuse` tests
pass (`--test-threads=1`).
Xuanwo and others added 7 commits July 21, 2026 11:40
## Summary

- remove FTS format v4 and make v3 the maximum supported capability
version
- use v3 for the code analyzer or 256-document posting blocks, while
keeping text analysis with 128-document blocks on v2
- allow v3 with both 128- and 256-document blocks; reject code analysis
on v1/v2
- update Rust, Python, Java, mem-wal handling, tests, and migration
documentation

## Root cause

The v4 bump conflated reader capability with a new physical file format.
Code analyzer configuration is persisted in `InvertedIndexDetails` and
changes token generation, not the posting or position schema.
Configurable posting block size was already represented by v3 and
persisted in metadata. For 128-document blocks, v3 uses the same fields
and codecs as v2.

Since FTS v3 has not been formally released, v3 can be the capability
gate for both code analyzer metadata and 256-document posting blocks
without introducing v4.

## Compatibility contract

- v1/v2: 128-document posting blocks only; no code analyzer
- v3: code analyzer supported; 128- and 256-document posting blocks
supported
- analyzer configuration: index details metadata
- posting block size: physical encoding metadata
- v4: unsupported; no alias or migration for the unreleased format

## Validation

- `cargo fmt --all`
- focused `lance-index` tokenizer, physical-layout, merge, metadata, and
plugin tests
- focused `lance` code-analyzer optimize, mem-wal metadata, and flush
tests
- `./mvnw -Dtest=InvertedIndexParamsTest test`
- 17 focused Python FTS tests
- `uv run make format`
- `uv run make lint`
`InvertedIndexParams` is persisted in FTS index metadata and shared
across Lance versions. PR lance-format#7681 added `deny_unknown_fields`, causing
existing indexes that still contain retired fields such as `skip_merge`
to fail to load. This also breaks `bench_regress` against its existing
Wikipedia FTS index.

Restore Serde's tolerant-reader behavior so historical and future
metadata fields are ignored while validation for recognized parameters
remains intact. Regression coverage exercises both training JSON and the
on-disk `metadata.lance` loading path.
…ns can't reach target_k (lance-format#7869)

## Summary

Creating an IVF index with `num_partitions` large enough to trigger
hierarchical k-means (> 256) could fail badly when the training data
can't be split into that many non-empty clusters (small dataset and/or
many near-duplicate vectors):

- Debug builds: hard panic at `debug_assert_eq!(heap.len(), target_k)`
in `train_hierarchical_kmeans`.
- Release builds: the assert compiles out, silently producing far fewer
than `num_partitions` non-empty partitions, degrading recall with no
error.

This replaces the `debug_assert_eq!` with a returned
`ArrowError::InvalidArgumentError` describing how many clusters could
actually be formed, so both build types fail cleanly instead of
panicking or silently degrading. The error propagates through
`train_kmeans` → `do_train_ivf_model` as a normal `Result`.

## Test plan

- Added `test_hierarchical_kmeans_too_few_distinct_vectors_errors`,
which builds a heavily near-duplicated dataset with `num_partitions`
(300) far exceeding the number of distinct vectors, and asserts a
descriptive error is returned instead of a panic.
- Verified this test panics against the old `debug_assert_eq!` code
(reproducing the reported issue) and passes with the fix.
- `cargo test -p lance-index --lib vector::` (318 tests) passes.
- `cargo fmt --all -- --check` and `cargo clippy -p lance-index --tests
--benches -- -D warnings` pass.

Fixes lance-format#7867
Blob v2 exposes per-value range reads through `BlobFile`, while
`Dataset.read_blobs` can batch only whole values. Python workloads that
need row-specific windows therefore either schedule one call per blob or
materialize complete values, losing centralized planning, bounded
concurrency, and backpressure.

This adds a dataset-level row-specific range contract for row IDs,
indices, and addresses. Each request carries its row selector, offset,
and length, so duplicates, out-of-order requests, and multiple ranges
for one row remain unambiguous. Validation, physical-range planning,
source grouping, coalescing, bounded scheduling, ordering, and error
semantics stay in Rust; Python remains a thin collecting wrapper.

### Python user-path benchmark

Real Amazon S3 validation ran in one CPython 3.12.13 process on a
`c7i.4xlarge` in `us-east-2`. The workload used four 500 MiB Blob v2
values and 64 deterministic 100 KiB windows, with concurrency 16 and a
64 MiB I/O buffer. Both methods used the same requests and objects for
ten repetitions, and method order rotated by repetition. The reported
p50 and p95 use linear interpolation across those ten runs.

The existing-path baseline used pre-opened `BlobFile` handles and a
prewarmed 16-worker `ThreadPoolExecutor`, so handle lookup and executor
startup were excluded in its favor. Its measured interval covered
submitting 64 synchronous `BlobFile.read_range` calls through receiving
every `bytes` result. The PR path measured one synchronous
`Dataset.read_blob_ranges` call through receiving its collected result
list. Payload validation ran outside both measured intervals.

| Python user-path metric | Existing `ThreadPoolExecutor` +
`BlobFile.read_range` | This PR: `Dataset.read_blob_ranges` | Benefit |
| --- | ---: | ---: | ---: |
| 64-range workload p50 wall-clock latency (lower is better) | 201.19 ms
| 81.51 ms | 2.47x speedup |
| 64-range workload p95 wall-clock latency (lower is better) | 377.65 ms
| 103.74 ms | 3.64x speedup |
| Logical throughput p50 (higher is better) | 31.07 MiB/s | 76.80 MiB/s
| 2.47x throughput |

### Storage I/O diagnostic

A separate Rust scheduler diagnostic at the same implementation commit
measured the physical I/O for the same logical workload. It is used only
to validate range efficiency, not as a Python latency baseline.

| Storage path | Logical bytes | Physical bytes p50 | Physical requests
p50 | Read amplification p50 |
| --- | ---: | ---: | ---: | ---: |
| `read_blob_ranges` | 6,553,600 | 6,556,844 | 72 | 1.0005x |
| Whole-value `read_blobs` control | 6,553,600 | 2,097,155,244 | 136 |
320.0005x |

The range API therefore reduced aggregate read amplification by 319.84x
relative to the only existing dataset-level batch API. Direct S3 is
intentionally omitted from the benefit table because it is a storage
diagnostic control, not a Python user path.

No client, OS, or S3 cache flush was performed; first-pass labels are
observational and Amazon S3 server-side cache state is uncontrolled.
Both measurements used the current PR head, `9c95e0324`.
This PR adds Python 3.14 support.

- upgrade `duckdb` and `datasets`
  - duckdb/duckdb-python#143
  - huggingface/datasets#7839
- fix `executor.rs` to properly propagate the runtime error with Python
3.14 (~~GIL-disabled~~)

All tests passed with some warnings.
```
❯ python -V
Python 3.14.5

❯ uv run make test

===================================================================================== warnings summary ======================================================================================
python/tests/test_dataset.py::test_sharded_iterator_fragments
python/tests/test_dataset.py::test_sharded_iterator_batches
python/tests/test_dataset.py::test_sharded_iterator_non_full_batch
  /home/moco/repo/mocobeta/worktrees/lance/calm-warbler/lance/python/python/lance/_dataset/sharded_batch_iterator.py:69: UserWarning: ShardedBatchIterator is deprecated, use :class:`Sampler` instead
    warnings.warn(

python/tests/test_fork.py::test_table_roundtrip
  /home/moco/repo/mocobeta/worktrees/lance/calm-warbler/lance/python/python/lance/__init__.py:353: UserWarning: lance is not fork-safe. If you are using multiprocessing, use spawn or forkserver instead.
    warnings.warn(

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================================================= 1101 passed, 594 skipped, 1 deselected, 4 warnings in 1396.62s (0:23:16) ==========================================================
```

[Benchmark
result](lance-format#7728 (comment))

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **Chores**
- Updated CI workflows and compatibility testing to use Python 3.14,
including the Linux x86_64 wheel matrix and ARM job setup.
- Adjusted wheel upload gating and refreshed test dependency constraints
(datasets/duckdb), along with static analysis settings to target Python
3.14.

- **Bug Fixes**
- Improved background event pumping behavior during buffered-drain
completion: pump errors are now propagated on Python 3.14+ (while older
versions continue to log warnings without failing).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Weston Pace <weston.pace@gmail.com>
BubbleCal and others added 14 commits July 21, 2026 22:58
lance-format#7427)

This PR introduces the concept of "index seeds". Indexes can opt-in to
planting seeds during ingestion. The seeds are placed into the data
files as global buffers. Later, when updating the index to include these
data files, we can harvest the seeds instead of scanning the data
itself.

This is primarily intended to avoid a potentially expensive data scan to
update the index. As an example this PR adds index seeds for wide
(binary, fixed-size-list, string) columns when creating a zone map
index. Now we calculate the min/max/nulls during ingestion, when the
data is already present and flowing through the system. Then, when we go
to update the index, all we are doing is reading back those counts and
adding them to the index (instead of scanning the large column all over
again).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Zone-map indexes can now use stored seed statistics to accelerate
incremental updates.
* Zone-map configuration now supports optional `rows-per-zone` and seed
usage (`use-seeds`) with backward-compatible behavior when omitted.
* When appending to existing datasets, the system can persist zone-map
seed metadata for eligible indexes.
* Added a benchmark to compare incremental update performance with and
without seeds.
* **Bug Fixes**
* Incremental updates automatically fall back to full processing when
seed data is unavailable or can’t be used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ance-format#7872)

## Stack: 1/3 — foundational hardening

This is the first of three stacked PRs that split the former lance-format#7791 (WAL
poison / read-visibility / flush-interval) into reviewable pieces. It
carries the changes that stand on their own, independent of the
visibility redesign:

- **perf(mem_wal): index small batches inline instead of one thread per
index** — stop spawning a thread per index for tiny batches; index
inline.
- **fix(mem_wal): fail reads on a poisoned writer** — once a writer
self-fences, reads must surface the failure instead of returning a
silently truncated view.
- **fix(mem_wal): reject index configs that disagree with the schema at
shard open** — validate FTS/HNSW/BTree/PK column types and existence
once at `open()`, before any row is accepted. A config that fails
deterministically on every insert (including WAL replay) makes
poison-and-replay non-terminating; reject it up front. Also errors
(instead of silently indexing nothing) when an FTS column is missing
from a batch, and relabels HNSW capacity-exhaustion as `internal` rather
than `invalid_input`.

Reviewable independently of the two PRs stacked on top.

**Stack**
1. **this PR** — foundational hardening → `main`
2. #1 — read-visibility redesign → this branch
3. #2 — flush-interval ticker → PR 2's branch

Splits the former lance-format#7791 into three reviewable pieces; lance-format#7791 can be
closed once this stack lands.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t re-append

After `replay_memtable_from_wal` rehydrated a memtable, the durability cursor
stayed at "nothing flushed". The next WAL flush therefore re-covered `[0, end)`:
it appended the already-durable rows to the WAL a second time *and* re-inserted
every replayed row into the in-memory indexes.

None of the three indexes is idempotent. HNSW mints fresh node ids for the same
row, so KNN returns it twice and burns two of the `k` slots. FTS increments
`doc_count`, `total_tokens`, and every term's `df` rather than recomputing them,
corrupting BM25 for the whole memtable. BTree is a multiset whose second insert
sets `pk_has_overrides` permanently, disabling HNSW plan selection and WAND
pruning. A full scan kept looking healthy while every index-accelerated query
silently returned duplicates — and it compounded, because the WAL now held those
rows twice, so the next crash replayed both copies.

Stamp the durability cursor at the end of replay: the batches came from the WAL,
and `insert_batches` has just re-derived the indexes over them. The index cursor
already advanced itself; only durability was missing.

Regression test reproduces the original report: reopen + replay + one 2-row put
grew the WAL from 8 rows to 18 instead of to 10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`mark_wal_flushed` had no production callers. The two collections it maintained
— `wal_batch_mapping` and `flushed_batch_positions` — were allocated per memtable
and read only from tests, as was `MemTable::last_flushed_wal_entry_position`
(production tracks that on `WriterState`, a different struct). `BatchStore::
is_wal_flush_complete` had no callers at all.

What the L0 flush actually gates on is `MemTable::all_flushed_to_wal()`, which
derives from the batch store's durability watermark and stays. The tests were
calling `mark_wal_flushed` only to satisfy that precondition, so they now set the
watermark directly — the same thing production does, instead of a parallel
bookkeeping path that only tests could reach.

The two tests that covered nothing but the deleted mapping are replaced by one
that covers the surviving behaviour: `all_flushed_to_wal()` flips only once the
watermark covers every committed batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The durable watermark was meaningless across a memtable rotation. `WalFlusher`
is built once per writer and its watch channel is never reset, but the targets
put against it were *memtable-local* batch positions — and positions restart at
0 in every new memtable.

So after memtable A flushed N batches (watermark = N), the first put into
memtable B targeted position 1, saw N >= 1, and acked immediately with no WAL
append at all. The first N puts into every post-rotation memtable were falsely
acked as durable: `durable_write: true` silently degraded to non-durable. When
B's append finally landed it sent a *smaller* value, walking the watermark
backwards — so a watcher from A targeting N could miss N entirely and block
until some later memtable climbed back to it. A hang, not just a stale read.

Fix the coordinate system rather than the symptom:

- `BatchStore` carries an immutable `global_offset`, stamped at freeze from the
  outgoing store's `global_end()`. It is a coordinate, not a cursor: it cannot
  restart and cannot move backwards.
- The durability cursor moves onto `WalFlusher` as a writer-global exclusive
  count, and is the only thing the watch channel carries. It advances
  monotonically (`max`), so an out-of-order completion cannot walk it back.
- `BatchStore::local_end(global_cursor)` is the single place the global-to-local
  subtraction is written. It saturates in both directions, and both are reachable:
  a cursor below a store's offset means "nothing here yet" — the ordinary state of
  a freshly rotated memtable — and open-coding the subtraction underflows there,
  which in release wraps to a huge end and makes the entire new memtable visible
  at once.
- The per-memtable `max_flushed_batch_position` (inclusive, `usize::MAX`-sentinel)
  is deleted. `pending_wal_flush_*` and `all_flushed_to_wal` now derive from the
  global cursor.

Two things fell out while wiring it up:

- The put path captured `batch_store` *after* the freeze check that may rotate
  the memtable, pairing the new store with the old store's end position. Capture
  it before, next to the insert that produced those positions.
- `flush_memtable` sampled the cursor before awaiting the WAL-append completion
  it depends on, so the L0 precondition saw a stale value. Sample it after.

Regression test: with a local target, a post-rotation put acks at durable=2 while
its own batch spans [2, 3) — acked, never appended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`max_visible_batch_position` was an inclusive position starting at 0, so a cursor
of 0 meant *both* "nothing is visible" and "batch 0 is visible". There was no
sentinel and no `Option` to tell them apart.

The window is real, not theoretical. `BatchStore::append` publishes
`committed_len` on the put path, under the state lock, before the WAL flush that
indexes the batch is even triggered — and that flush is a ~100ms S3 PUT on
another task. So batch 0 of every memtable sat committed and readable for a full
round-trip before it was indexed or durable, and only through the arms backed by
the batch store: the index-backed arms, reading the same cursor, saw nothing. The
tiers actively disagreed.

Express the cursor as an exclusive count. `0` now means nothing, the `+1`
disappears, `i < count` replaces `i <= max`, and the off-by-one becomes
inexpressible. `checked_sub(1)` conversions at the consumers fall away — every
site got simpler, as did `bounded_in_memory_membership`, whose `batch_count == 0`
case now falls out of the arithmetic instead of being special-cased.

Rename it to `indexed_count` while we are here. It has only ever been an
*indexed* cursor — it is advanced at the end of `insert_batches`, once every
index insert for a batch completes, and never before. Five read sites treated it
as a visibility cursor, which is a separate thing that belongs to the writer.
Deriving visibility from it is the next change; this one just stops the name from
lying.

Two things the conversion turned up:

- `point_lookup` did `indexed_count().min(len - 1)`, reading the cursor as an
  inclusive index. It had no "nothing visible" case at all.
- `test_shard_writer_e2e_correctness` passed *only* because of this bug: it wrote
  with `durable_write: false` and scanned, and the rows appeared because the
  un-advanced cursor of 0 was misread as "batch 0 is visible". A non-durable put
  is not yet read-your-writes — the index apply is welded to the WAL flush — so
  the test now writes durably, which is what it meant to test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readers keyed off `indexed_count`, which the index insert advances itself. But
the WAL append and the index apply run under one `tokio::join!`, and `join!` runs
both arms to completion — it does not cancel the index arm when the append fails.
So on a failed append the index arm still advanced the cursor every reader treats
as visibility, publishing rows that are not in the WAL and that replay will not
reproduce. `MemTableScanner::new` asserts the opposite in its own doc comment.

Split the cursor in two. `indexed_count` stays the *apply* cursor: it tracks what
the index layer has ingested, it is what HNSW's contiguity check needs, and it
advances on a failed flush — which is fine, because indexes are derived state and
replay rebuilds them. `visible_count` is new, and is the only thing readers
snapshot: the writer advances it downstream of *both* arms succeeding, so
`visible => durable` holds unconditionally.

Also make an index-apply failure terminal. `insert_batches` joins every index
thread unconditionally, so a failure leaves the others fully applied, and none of
them can be rolled back: HNSW has no delete, FTS has already incremented its
collection statistics, BTree has linked into an append-only skiplist. Both ways
out are corrupt — retry re-covers the range and re-inserts into the indexes that
did succeed; skip it and the failed index is permanently short rows the others
have. So discard instead: poison, and let reopen rebuild the indexes from the
WAL. The rollback primitive already exists and it is `open()`.

Replay publishes explicitly: its batches are durable by construction and it
bypasses the flush, so without it the recovered rows stay invisible.

Regression tests pin both halves. With the index arm publishing, a row whose WAL
append failed becomes readable (visible_count=1 where it must be 0). An index
insert that fails deterministically now poisons the writer instead of leaving it
to limp on with a corrupt index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the WAL flush

The WAL append and the index apply were welded together under one `tokio::join!`
on one schedule. They have nothing in common: one is an in-memory write measured
in microseconds, the other a ~100ms S3 PUT billed per call. Batching exists to
bound API cost, which an in-memory index apply does not incur — so the index was
being dragged onto the WAL's schedule for no reason, and that is what made
read-your-writes impossible without durability.

Split them into two tasks with two channels, each a single sequential consumer:

- The index-apply task is triggered per put, in both modes. Being a single
  consumer is the safety property: `HnswGraph::insert_batch` hard-rejects any
  range whose start is not its `indexed_len`, and a single consumer guarantees
  contiguous in-order ranges however many putters race behind it. Ordering comes
  from the task, not from a flush interval — so triggering per-put is exactly as
  safe as triggering on a timer, and a put becomes visible in milliseconds rather
  than waiting on an S3 round-trip.
- The WAL-append task keeps the expensive work and is now append-only. The
  `join!` is gone, and with it the dirty read it caused: a failed append can no
  longer publish rows through an index arm that ran anyway.

They need separate channels, not two message types on one: `TaskDispatcher::run`
awaits `handle()` inline, so sharing would queue every index apply behind an S3
PUT.

Visibility becomes derived rather than published. `WriterCursors` holds the
writer-global `durable` count; each memtable's `IndexStore` holds its own
`indexed` count; and `visible` is computed on demand as `min(indexed, durable)`
under `durable_write`, or just `indexed` without it. Nothing caches it, which is
deliberate: a cached `min` recomputed by two independent tasks is the classic
store-buffer race — under Release/Acquire both tasks can read the other's
pre-store value, both compute a minimum below the true one, and a max-clamped
publish leaves it permanently short, hanging any put blocked on it. With nothing
cached there is nothing to leave stale. The notify channel is a bare wake-up and
every waiter recomputes.

What this buys: `durable_write: false` now costs the caller durability *only*,
not visibility. A non-durable put is read-your-writes through every arm, indexed
ones included — previously the row sat unindexed in the batch store until some
later flush happened along, so a full scan could find it while every
index-accelerated query could not.

Also: `close()` drains both tasks, `freeze` triggers the index apply for the
outgoing store, and every memtable's `IndexStore` is bound to the cursors —
including the empty one built when no indexes are configured, which would
otherwise fall back to `visible == indexed` and publish before durability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single memtable holds at most `max_memtable_batches` batches, but a WAL is
unbounded. Replay stuffed every WAL entry into one memtable, so a shard whose WAL
had grown past one memtable's capacity failed `open()` outright with "MemTable
batch store is full" — permanently unopenable.

Replay now rotates exactly as the live write path does, on the same trigger. When
the active memtable reaches the flush threshold it is sealed and — because the
data is already durable in the WAL — flushed straight to a Lance generation with
the same `MemTableFlusher::flush` the live path uses, rather than held in memory
until open finishes. That bounds resident memory to ~two memtables and truncates
the WAL as it goes, so a later reopen replays only the unflushed tail. Rotation is
at WAL-entry boundaries, so each sealed generation covers a clean range of
complete entries and stamps the last as its `replay_after_wal_entry_position`.

The flush trigger is now one predicate, `memtable_reached_flush_threshold`, shared
by the live path (post-insert, "room for one more batch?") and replay (pre-insert,
"room for the next entry?"). It carries both criteria — `max_memtable_size` bytes
and batch-store capacity. The byte trigger matters beyond avoiding overflow: it is
what keeps a memtable under `max_memtable_rows`, and therefore keeps the in-memory
HNSW index (sized to `max_memtable_rows`) from exhausting its capacity when the
final active memtable is indexed. A single predicate is what stops the two paths
from drifting — the exact class of bug this change set is about.

`MemTable::is_batch_store_full` is deleted: its one production caller now goes
through the shared predicate, and the two remaining test callers use the public
`batch_store().is_full()` directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address CodeRabbit review feedback on the WAL visibility branch:

- validate_index_configs now rejects a single-column primary key on a
  column absent from the schema, closing the gap where such a config
  passed validation and then failed deterministically on every index
  build and WAL replay. Existence is checked for all PK columns; the
  order-preserving encodable-type check stays gated on composite keys.

- Restore the index-update statistics. Index application moved onto its
  own task, leaving do_flush's record_index_update branch dead (gated on
  a hardcoded false) so index_update_count/time/rows and
  log_wal_breakdown() reported a permanent zero. apply_index_range now
  returns IndexApplyStats { rows_indexed, duration } and
  IndexApplyHandler records real applies, skipping coalesced no-ops. Drop
  the dead do_flush gate and the vestigial index fields on WalFlushResult.

- Fix stale docs that still called indexed_count a durable visibility
  watermark, and correct the point-lookup BTree test comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or lock

`check_poisoned` and `mark_terminal_failure` both `.unwrap()`ed the
`terminal_error` lock, so a panic anywhere under it turned every later
poison check into a panic of its own.

Ignore the poisoning instead of reporting it. The guarded data cannot be
torn: the sole writer builds the `WalFlushFailure` and assigns it whole
under an `is_none()` check, so a panic mid-section leaves the slot exactly
as it was. There is no invariant here for poisoning to protect — the flag
is pure alarm.

Mapping the poisoning to an error would be worse than the panic. This
mutex exists to carry the reason a writer is fenced, and recovery is
reopen -> replay driven by that `FenceReason`; answering "mutex was
poisoned" buries it precisely when a caller needs it. It also has nowhere
to go in `mark_terminal_failure`, which returns `()`.

Test: latch a `PersistenceFailure`, poison the mutex from a panicking
thread, then assert the typed reason and message still surface and the
latch still keeps the first failure. It panics against the old code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes from PR review, each confirmed by reproducing the failure in a
test that fails on the pre-fix tree:

- open(): derive PK metadata and run the durable-write/flush-interval and
  index-config validation *before* claim_epoch. These checks ran after the
  epoch was claimed (and, for a successor, after the predecessor was fenced),
  so an open doomed by purely local input knocked the healthy incumbent off
  the shard with a PeerClaimedEpoch fence.

- validate_index_configs(): reject a config whose field_id names a different
  column than its `column`. Index selection keys off field_id alone (a
  single-column PK reuses the BTree whose field_id matches), so a mismatch
  bound the wrong index under a valid-looking name -- stale reads plus the
  wrong column flushed into the durable PK sidecar. Coupled with open()'s
  validation move because the signature and its only caller change together.

- freeze_memtable(): retain the outgoing memtable in the read view before the
  fallible index-apply/WAL-flush dispatches, and poison on a failed send. The
  active memtable was already replaced, so a failed dispatch dropped the table
  and its accepted rows silently vanished -- a zero-row scan with no error, on
  a branch whose whole point is to poison instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kipping it

`apply_index_range` and `flush_from_batch_store` walked `[start, end)` and
silently dropped any position `BatchStore::get` returned `None` for. The store
is append-only (`get` returns `None` only past `committed_len`), so a hole means
the caller asked to cover a batch that was never committed. Skipping it while
still advancing the cursor was silent corruption: the WAL path advanced
durability to `end_batch_position` even though a batch was never appended (lost
on replay), and the index path advanced `indexed_count` past a never-indexed
batch (rows counted visible but absent from every index).

Both now error on a missing position, naming the range and committed length. The
flush path returns a terminal (writer_poisoned) error so `flush` poisons the
writer before durability moves; the index path's error poisons via its handler.
Reopen replays the WAL. Holes are impossible today, but this fails loudly the
day eviction lands rather than diverging silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hamersaw
hamersaw force-pushed the feat/wal-read-visibility branch from c99ce03 to 8e6dcea Compare July 21, 2026 17:29
@github-actions

Copy link
Copy Markdown

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@hamersaw

Copy link
Copy Markdown
Owner Author

Superseded by lance-format#7888lance-format#7872 (PR-1) merged, so this branch was rebased onto upstream/main and reopened as a PR against main.

@hamersaw hamersaw closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants