Skip to content

perf(mem_wal)!: serve the shard manifest from the store that wrote it - #8640

Open
hamersaw wants to merge 7 commits into
lance-format:mainfrom
hamersaw:refactor/mem-wal-manifest-cache
Open

perf(mem_wal)!: serve the shard manifest from the store that wrote it#8640
hamersaw wants to merge 7 commits into
lance-format:mainfrom
hamersaw:refactor/mem-wal-manifest-cache

Conversation

@hamersaw

@hamersaw hamersaw commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem

ShardManifestStore::read_latest scanned the version space on every call: a GET for version_hint.json, a HEAD to confirm the hinted version, then batches of parallel HEADs until a whole batch 404s. The loop only exits on a full batch of misses, so the floor is 2 + manifest_scan_batch_size object-store requests even when the hint is exact — and nothing cached the result, so a reader paid it per request.

Profiling a WAL node, this was essentially all of its GET+HEAD traffic. Per fresh-tier read with N L0 generations: 5 + N requests, of which the 5 were manifest probing and only N were data.

Change

Serve the manifest from the store that wrote it, and make the invariant that permits this an enforced one rather than an assumed one.

Serving

A store keeps the manifest it durably wrote as its position and serves that from latest(). Soundness does not rest on holding the claim. Manifest versions are CAS-allocated and gap-free — every writer commits latest.version + 1 under PUT-IF-NOT-EXISTS — so a successful write at version N proves N was the tip: a peer cannot hold N+1 without N existing first.

The two reads now differ by whether they take a position, which is what the names say:

goes to storage adopts a position
latest() only when it has no position no
refresh_latest() always yes

A plain scan deliberately does not adopt, so a reader that polls keeps observing the writer rather than pinning the first manifest it saw. refresh_latest adopts because a claim reads uncached precisely to see another process, and the tip it finds is what its own write then builds on. check_fenced and claim_epoch use it for exactly that reason.

Enforcing

Nothing previously enforced the gap-free invariant the cache rests on: every caller hand-wrote current.version + 1 and nothing checked the result. Given a gap, a cached commit could be acknowledged behind the durable tip.

The invariant is also load-bearing well beyond the cache. find_latest_version stops at the first absent batch, so a gap wider than manifest_scan_batch_size combined with a lost best-effort hint makes even an uncached read misreport the tip.

The check belongs with whoever holds the predecessor, not with the store's position — that position is shared, and a peer's failed CAS clears it, so a commit can find itself judged against an empty position midway through. commit_update therefore validates the closure's output against the manifest the closure received, which is immutable and local. A version the caller did not intend is rejected, not silently corrected; ShardManifest::next_version() is what callers build with.

write keeps only what its own state can judge: a version at or below this store's position is reported as the collision it is, so callers retry.

The tailer

A store can only check contiguity against a position it holds, and WalTailer was the one writer that could never hold one: it claims no epoch, and its store must serve fresh reads for its own position hints — so no single piece of state could be both stable enough to validate against and fresh enough to hint from.

It turns out it never needed to write at all. Its per-entry manifest write maintained wal_entry_position_last_seen purely as a cursor hint for next_position(), whose only callers are tests; replay, the tailer's sole production user, derives the tip from its own read loop. The tailer now tracks the highest position it has read in memory, and publishing that cursor moves to the replay driver, which already holds the epoch, as an ordinary commit_update.

That removes one manifest write per replayed WAL entry and leaves every manifest writer an epoch holder.

Measured

Against a WAL node driving this code:

before after
read, N L0 generations 5 + N requests N
flush (seal into L0) 132 24
compaction pass 35 10
replay, per unflushed WAL entry 8.81 4.18

The replay figure predates the tailer change, which removes a further manifest write per entry.

Writes are unchanged (one conditional PUT per entry).

Breaking changes

  • ShardManifestStore::read_latestlatest
  • ShardManifestStore::read_latest_uncachedrefresh_latest
  • ShardManifestStore::write is now crate-private. Callers reach it through commit_update, claim_epoch, or initialize_shard — the three entrances that derive a version from a manifest they just read. It was public from the commit that introduced MemWAL and never acquired a caller.

Downstream commit_update closures need no change: setting version: current.version + 1 is exactly what the check expects.

Tests

cargo test -p lance --lib dataset::mem_wal:: — 619 passed.

Covering the properties this is allowed to break:

  • a peer's claim is visible to check_fenced through a held position
  • a reader that polls latest() keeps observing the writer, and never adopts a position
  • only refresh_latest adopts — reversing that would either pin pollers or reject valid claims
  • commit_update recovers from a stale position instead of spinning on the version it lost
  • a non-successor write is refused, and the refusal leaves storage untouched
  • a taken version reads as a collision, and drops the position so the retry re-reads
  • eight concurrent commits on one handle all land, and none is lost
  • a tailer read writes no manifest

Note on the commit stack

The first two commits are pre-existing work from the flush-observer branch that has not landed upstream yet; read this PR as its final commit. Rebasing once those merge will reduce it to the single change.

🤖 Generated with Claude Code

hamersaw and others added 4 commits August 18, 2026 20:23
`WriteStats` already tracks flush counts and cumulative time, but a running
total cannot be resampled into a distribution — the individual observations
are gone by the time anything polls it. An embedder can compute an average
and nothing else, which is exactly the wrong shape for latency: a flush
pipeline is judged on its tail, not its mean.

Observe each flush individually instead, through the `metrics` facade that
`lance-io` already uses for object store operations. Observations route to
whatever `Recorder` the embedding process installed, so this crate takes no
position on the exporter and the emit sites compile away with the feature
off.

One family with a `kind` label rather than two: a WAL buffer flush and a
memtable flush are stages of the same write pipeline and are read together,
even though they differ by orders of magnitude — hence bucket bounds
spanning a single object-store round trip through a multi-second dataset
write.

Counts and byte totals stay on `WriteStats`. They are cumulative and lose
nothing to sampling, so there is no reason to route them through a recorder.
Replaces the `metrics`-facade approach from the previous commit.

The problem is unchanged: `WriteStats` tracks flush counts and cumulative
time, but a running total cannot be resampled into a distribution. An
embedder can compute an average and nothing else, which is the wrong shape
for latency — a flush pipeline is judged on its tail, not its mean.

Report each flush to an optional `WalObserver` on `ShardWriterConfig`,
alongside `warmer`. The consumer supplies the sink and owns the aggregation,
so Lance still takes no position on the exporter, but now needs no feature
flag and no process-global recorder.

An injected sink rather than the facade because the consumer holds context
Lance does not — the table a shard belongs to, in particular, which a
process-global histogram cannot label. It also matches how consumers already
reach into this config: `SsTableWarmer` and `DatasetCache` cross the same
boundary the same way, while the facade has one producer in the tree and no
consumer that installs a recorder.

Every trait method defaults to a no-op, so adding an event later is not a
breaking change for implementors.

Counts and byte totals stay on `WriteStats`: they are cumulative and lose
nothing to sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The write bench builds `ShardWriterConfig` field-by-field with no
`..default()`, so adding `observer` broke every job that checks
benchmarks: clippy, MSRV, build-no-lock, and the "Check benchmarks" step
on mac and windows. Set it to `None` beside the sibling `warmer`.

Add the test the observer commit was missing. A durable put returns only
once its WAL flush landed, and the seal fence resolves only once the
sealed memtable reached L0, so both callbacks have fired by the time it
asserts — no sleeping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ShardManifestStore::read_latest` scanned the version space on every call:
a GET for `version_hint.json`, a HEAD to confirm the hinted version, then
batches of parallel HEADs until a whole batch 404s. The loop's exit
condition guarantees that terminating batch is all misses, so the floor is
`2 + manifest_scan_batch_size` requests even when the hint is exact — paid
per read, because nothing cached the result.

Cache the manifest a store durably wrote and serve it from `read_latest`.
Soundness does not rest on holding the claim: manifest versions are
CAS-allocated and gap-free, since every writer commits `latest.version + 1`
with PUT-IF-NOT-EXISTS. A successful write at version N therefore proves N
was the tip — a peer cannot hold N+1 without N existing first. So any store
may serve what it wrote, which also lets the replay tailer reuse the cursor
it just stamped.

Only a write populates the cache. A read miss deliberately does not, so a
reader-only handle still observes the writer; caching reads instead made a
`WalTailer` pin the first manifest it saw and never see the cursor advance.
A failed CAS invalidates, which both frees `commit_update`'s retry to
re-read storage and keeps a possibly-fenced writer from trusting itself.

`check_fenced` and `claim_epoch` read through `read_latest_uncached`: both
exist to observe another process, which our own cache can never show us.

Also expose `ShardWriter::manifest_store` so an embedder commits through
the same instance the writer uses — two stores over one shard would keep
two caches, and neither would see the other's commits.

Measured against a WAL node, per read of a fresh tier with N generations:
`5 + N` object-store requests before, `N` after. Replay of 200 unflushed
WAL entries: 8.81 requests per entry before, 4.18 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hamersaw and others added 2 commits August 20, 2026 09:17
The flush task and the WAL tailer's cursor updates share one
`ShardManifestStore`, so two writes can win their CAS in one order and
return to their callers in the other: the loser re-reads storage, commits
the next version and caches it, then the winner's slow response finally
seats the older one. Nothing is overwritten — versions are CAS-allocated
and gap-free, so a stale cache can only ever propose a version that is
already taken — but `ShardWriter::manifest()` would under-report a
just-flushed generation until the next commit. Cache only a higher
version.

Also state the staleness contract on `ShardWriter::manifest()`, and cut
the comments the manifest cache added roughly in half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hamersaw
hamersaw marked this pull request as ready for review August 20, 2026 15:05
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@hamersaw
hamersaw force-pushed the refactor/mem-wal-manifest-cache branch from 1005733 to e18676c Compare August 20, 2026 20:17
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
@hamersaw
hamersaw force-pushed the refactor/mem-wal-manifest-cache branch from e18676c to 2d15779 Compare August 20, 2026 20:51
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
@wjones127
wjones127 self-requested a review August 20, 2026 22:04
@hamersaw
hamersaw force-pushed the refactor/mem-wal-manifest-cache branch from 2d15779 to 93bb4b5 Compare August 21, 2026 11:41
@hamersaw hamersaw changed the title perf(mem_wal): cache the shard manifest a store durably wrote perf(mem_wal)!: serve the shard manifest from the store that wrote it Aug 21, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026
The manifest cache serves a landed write as proof of the tip, which holds
only while versions are gap-free. Nothing enforced that: every caller
hand-wrote `current.version + 1` and nothing checked the result, so a
cached commit could be acknowledged behind the durable tip.

The gap is load-bearing beyond the cache. `find_latest_version` stops at
the first absent batch, so a gap wider than `manifest_scan_batch_size`
with a lost best-effort hint makes even an uncached read misreport the tip.

A store can only check contiguity against a position it holds, and the
tailer was the one writer that could never hold one — it writes no epoch,
and its store must serve fresh reads for its own position hints. So it
goes first:

- `WalTailer` tracks the highest position it has read in memory, which is
  all `next_position()` ever needed. `best_effort_cursor_update` is gone.
- Publishing `wal_entry_position_last_seen` moves to the replay driver,
  which already holds the epoch, as an ordinary `commit_update`. That
  drops one manifest write per replayed WAL entry, and leaves every
  manifest writer an epoch holder.

That makes the store's own position a sound baseline for serving:

- `latest()` serves the position when held, else scans — and a scan here
  is deliberately not adopted, so a reader that polls keeps observing the
  writer instead of pinning the first manifest it saw.
- `refresh_latest()` scans and adopts what it finds. A claim reads
  uncached precisely because it must see another process, and the tip it
  finds is what its own write then builds on.

The successor check belongs with whoever holds the predecessor, not with
the store's position: that position is shared, and a peer's failed CAS
clears it, so a commit could be rejected as a gap over an empty position
midway through. `commit_update` checks the closure's output against the
manifest the closure received, which is immutable and local. `write` keeps
only what its own state can judge — a version at or below its position is
reported as the collision it is, so callers retry.

BREAKING CHANGE: `ShardManifestStore::read_latest` is renamed to `latest`,
`read_latest_uncached` to `refresh_latest`, and `write` is now crate-private
— callers reach it through `commit_update`, `claim_epoch`, or
`initialize_shard`, which derive versions from a manifest they just read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hamersaw
hamersaw force-pushed the refactor/mem-wal-manifest-cache branch from 93bb4b5 to 49fd79c Compare August 21, 2026 12:45
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026

@lance-gatekeeper lance-gatekeeper Bot 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.

Gate recommendation: approve.

The manifest mutation boundary now validates each update against the exact predecessor supplied to its closure, while collision-driven cache invalidation remains only a retry concern. The raw-gap and stale-predecessor races are both closed without restoring storage reads to the hot path.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change K-approved Latest Gatekeeper recommendation permits acceptance. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant