Skip to content

feat(mem_wal): drive the WAL append on a flush-interval ticker#2

Closed
hamersaw wants to merge 4 commits into
feat/wal-read-visibilityfrom
feat/wal-durable-flush-interval
Closed

feat(mem_wal): drive the WAL append on a flush-interval ticker#2
hamersaw wants to merge 4 commits into
feat/wal-read-visibilityfrom
feat/wal-durable-flush-interval

Conversation

@hamersaw

Copy link
Copy Markdown
Owner

Stack: 3/3 — WAL flush-interval ticker

The feature the original lance-format#7791 was titled for, now small because it sits on the read-visibility redesign. flush_interval_ms was inert: it routed to a timer only ever evaluated on the write path, so it could add a redundant trigger but never delay or batch one.

  • append the WAL on a background ticker, resolved by cursor — give the WAL flusher a real ticker (MessageHandler::tickers) and take the append off the put path. The append is the only thing on that schedule — it's an S3 PUT, billed per call, and bounding that cost is the whole reason a flush interval exists. The index apply stays per-put on its own task. WalFlushSource::NextPending is resolved when the message is handled, by walking live stores oldest-first and taking the first that still owes an append.
  • update Python/Java stats bindings for the writer-global cursor
  • remove the inert sync_indexed_write config familysync_indexed_write, async_index_buffer_rows, and async_index_interval had no behavioral consumers after the index-apply-task split made every write read-your-writes. Removed across Rust core, benches, and Python/Java bindings.
  • two doc-comment cleanups.

Stack

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

Based on PR 2; merge after PR 1 and PR 2.

Review note: this PR targets the PR-2 branch (intra-fork) so the diff shows only this PR's 5 commits.

🤖 Generated with Claude Code

@github-actions github-actions Bot added A-java A-python enhancement New feature or request labels Jul 21, 2026
hamersaw added a commit to lance-format/lance that referenced this pull request Jul 21, 2026
…7872)

## Stack: 1/3 — foundational hardening

This is the first of three stacked PRs that split the former #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. hamersaw#1 — read-visibility redesign → this branch
3. hamersaw#2 — flush-interval ticker → PR 2's branch

Splits the former #7791 into three reviewable pieces; #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>
@hamersaw
hamersaw force-pushed the feat/wal-read-visibility branch from c99ce03 to 8e6dcea Compare July 21, 2026 17:29
This PR replays lance-format#7754 unchanged against `main`.

lance-format#7754 was accidentally merged into
`xuanwo/sparse-stack-2-empty-inline-bitpacked` instead of `main`. This
PR only corrects that target mistake and introduces no changes beyond
the original PR.

All design discussion, review history, approvals, and validation are
recorded in lance-format#7754.

---------

Co-authored-by: Weston Pace <weston.pace@gmail.com>
@hamersaw
hamersaw force-pushed the feat/wal-durable-flush-interval branch from 1396b1d to b3d7b54 Compare July 21, 2026 20:07
hamersaw and others added 3 commits July 21, 2026 15:30
…ity cursors (lance-format#7888)

## 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** (the former lance-format#7791, split for reviewability)
1. lance-format#7872 — foundational hardening → **merged**
2. **this PR** — read-visibility redesign → `main`
3. flush-interval ticker → follows this PR

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`flush_interval_ms` was inert. It routed to a timer that was only ever evaluated
*on the write path*, so it could add a redundant trigger but never delay or batch
one — and since every durable put triggered its own append, tuning the knob did
nothing at all.

Give the WAL flusher a real ticker (`MessageHandler::tickers`, which the memtable
flusher already used) and take the append off the put path. The append is the
only thing on that schedule: it is an S3 PUT, billed per call, and bounding that
cost is the entire reason a flush interval exists. The index apply is not on it —
it is in-memory and free to batch, so it stays per-put on its own task.

The tick names no store. `MessageFactory` is synchronous and cannot take the
async state lock, and capturing an `Arc<BatchStore>` at handler construction
would pin the first memtable forever. So `WalFlushSource::NextPending` is
resolved when the message is *handled* — by walking the live stores oldest-first
and taking the first that still owes an append.

Oldest-first, not "the active memtable", and this is load-bearing. WAL entry
positions are assigned in append-call order; replay walks them ascending; row
positions follow; and primary-key recency is "newest visible row position wins".
So append order *is* dedup order. A tick enqueued before a freeze is handled
after it, and resolving to the active memtable would append the incoming
memtable's batches ahead of the outgoing one's tail — silently handing the key to
the stale row on the next replay. It survives the crash that caused it, and a
full scan cannot see it. Selecting by cursor makes the target a function of what
is durable rather than of when the timer fired.

Two starvation fixes in the dispatcher, both of which this makes reachable:

- The interval used the default `MissedTickBehavior::Burst`, which replays every
  tick missed while `handle()` ran. A WAL append easily outlasts its own
  interval, so missed ticks accumulate, the ticker arm is always ready, and —
  being `biased` — it starves the channel. Now `Delay`.
- The `biased` select polled the ticker *before* `rx.recv()`. A tick only ever
  adds an append a real trigger would have made anyway, whereas a message may be
  a freeze's completion cell or `close()`'s final append, which nothing else
  delivers. Messages now win.

`durable_write: true` with no ticker is rejected at open: the put path no longer
triggers its own append, so such a writer would block forever on an append that
never comes. Better a clear error than a deadlock.

Accepted cost: single-client sequential *durable* throughput drops from ~10
writes/sec (one PUT round-trip) to roughly one per tick. That is the policy
choice — the interval should mean what it says, and API cost should be bounded.
Latency-sensitive callers want `durable_write: false`, which now costs them
durability only, not visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`sync_indexed_write`, `async_index_buffer_rows`, and `async_index_interval` had
no behavioral consumers — they were only serialized into the config-defaults
metadata map. They existed to configure async index-update buffering, which the
index-apply-task split replaced with unconditional per-put indexing: a write is
now read-your-writes through the index in every mode, which is exactly what
`sync_indexed_write` promised. The knobs no longer control anything.

Remove all three across every surface: the Rust core field, builders, defaults,
and metadata serialization; the write-throughput bench's now-meaningless
sync/async index axis; and the Python and Java binding parameters.

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

Copy link
Copy Markdown
Owner Author

Superseded: PR-2 (read-visibility redesign) merged upstream as lance-format#7888. Rebased these commits onto upstream/main and reopening as a cross-fork PR targeting main directly. Two of the original commits (stats bindings + doc-link cleanups) were folded into lance-format#7888 during review.

@hamersaw
hamersaw force-pushed the feat/wal-durable-flush-interval branch from b3d7b54 to c8c1644 Compare July 21, 2026 20:42
@hamersaw hamersaw closed this Jul 21, 2026
@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.

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.

2 participants