perf(mem_wal)!: serve the shard manifest from the store that wrote it - #8640
Conversation
`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>
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>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
1005733 to
e18676c
Compare
e18676c to
2d15779
Compare
2d15779 to
93bb4b5
Compare
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>
93bb4b5 to
49fd79c
Compare
|
some agent comments: |
`find_latest_version` collapsed a failed HEAD into an absent version, so a transient error ended the scan and the last version below it was reported as the tip. The version hint is written after the manifest and is best-effort, so lagging by one is the ordinary state during any commit — at the default batch size of 2, a single failed HEAD on the tip is enough. Now that a scan can become this store's position, a wrong answer sticks: `refresh_latest` adopts it, `check_fenced` then reads an epoch behind the peer that fenced us and reports clear, and `latest` serves the stale tip from memory long after the failure clears. Propagate the error instead. A caller that could not read the tip is told that, rather than told the tip is where the failure happened to stop it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The manifest scan now fails closed on any non-NotFound HEAD error before adopting a cache position, so a transient storage failure cannot become a stale fencing decision. Missing versions still terminate the scan normally, while the gap-free and collision-retry behavior remains unchanged.
westonpace
left a comment
There was a problem hiding this comment.
Some review suggestions from Claude, I think 2 & 3 are valid but non-blocking. I'm not worried about 1 (WAL still too experimental to consider something like this as breaking)
I read the full diff, ran the mem_wal manifest suite on the PR head (20/20 pass), and instrumented the store to measure the contended path. CI is green across all 39 checks.
Review of PR #8640
The soundness argument holds up. I traced the fenced-writer paths specifically: a stale position always targets a version the peer already took (contiguity guarantees it), so the CAS collides, invalidate() fires, the retry refreshes, and the fence surfaces. Making the gap-free invariant enforced rather than assumed is the right call — and after the change, every production version: field derives from next_version(); the only remaining hand-written + 1 are in tests. The HEAD-error fix (8caa9df) resolves @xuanyu-z's comment, and the ProxyObjectStore test for it is a good one.
Three things worth addressing:
1. ShardWriter::manifest() changed contract but isn't listed as breaking — write.rs:2512
ShardWriter is publicly exported (pub use write::ShardWriter), so this is embedder-facing API that silently went from "the durable tip" to "our own last commit". The concrete hazard is ShardStatus::Sealed: drop-table 2PC seals from another process, so an embedder polling writer.manifest()?.status to notice a drop-in-flight will now never see it once its own cache is warm. Same for a peer's compaction results. claim_epoch is safe (it uses refresh_latest), and there's no in-repo reader of ShardStatus::Sealed outside manifest.rs — so this lands entirely on Sophon, which the PR body notes keys on that marker. The doc comment covers the general staleness but not this case. Worth listing under Breaking changes, and a ShardWriter::refresh_manifest() would be friendlier than making callers go through manifest_store().refresh_latest().
2. The retry classification is a substring match — manifest.rs:647
let is_version_conflict = e.to_string().contains("already exists");This is pre-existing, but the PR adds a second producer of that string (the local pre-check) and encodes the coupling as a comment on version_taken rather than fixing it. An error-message edit turns a retryable conflict into a hard commit failure, silently. lance_core::Error already has RetryableCommitConflict { version } and VersionConflict { version } — either lets commit_update use matches!. Since version_taken() is new in this PR, the change is small and in scope. Related: manifest.rs:248 documents "Returns Error::AlreadyExists", which isn't a variant that exists — the function returns Error::io.
3. Under contention on one handle the scan comes back — measured, not speculative
I added a counter to scan_latest and ran concurrent_commits_on_one_handle_all_land: 24 full manifest scans for 8 concurrent commits. Every CAS loss invalidates the shared position, so siblings that were perfectly up to date also drop to a scan. The invalidate() calls are both correct — I tried removing the one in the local pre-check and it got worse (29 scans, more wasted PUTs), because tasks then reach the CAS before invalidating anyway. The fix would be serialization: a tokio::Mutex around commit_update would make each commit hand its position to the next, giving zero scans and zero wasted PUTs. Commits are already serialized at the object store by CAS, so there's no throughput to lose.
Whether this matters depends on whether the flush task, a compaction pass, and the new replay-cursor commit actually overlap on the one shared handle — and the new manifest_store() accessor encourages embedders to funnel everything through it. If they're effectively serialized in production, this is fine as-is; I'd just note the bound somewhere, since MAX_RETRIES = 10 also caps how many concurrent commits can survive on one handle.
Nit: read_latest_serves_the_written_manifest (manifest.rs:813) still carries the old method name.
The tailer change is clean — I confirmed next_position()'s manifest hint still resolves via replay_after_wal_entry_position (advanced by flush) so probe_forward_from doesn't degrade to a full LIST, and that WalAppender::open's own store is test-only, so the internal handles that matter all share one cache.
Want me to post this as a PR review?
…its text
`commit_update` decided whether to retry with
`e.to_string().contains("already exists")`, and this branch added a second
producer of that string in `version_taken`. Editing either message would have
turned a retryable collision into a hard commit failure with nothing to catch
it.
Return `Error::RetryableCommitConflict` — already the repo's conflict variant —
and match on it. The collision test asserts the variant rather than the wording,
so the contract is the thing the retry actually reads.
Also drop two names left behind by the `read_latest` -> `latest` rename, and fix
the `write` doc, which named an `Error::AlreadyExists` variant that does not
exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A losing CAS clears the shared position, so commits overlapping inside one CAS round-trip each fall back to a scan; the unluckiest loses every round and runs out of retry budget past ten of them. No current caller is near that -- GC and compaction commit seconds to minutes apart, against a window of one conditional PUT -- but the ceiling is invisible from the signature, and manifest_store() now hands embedders the handle to funnel more sources through. Documents the bound. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
ShardManifestStore::read_latestscanned the version space on every call: a GET forversion_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 is2 + manifest_scan_batch_sizeobject-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 + Nrequests, 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 commitslatest.version + 1under 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:
latest()refresh_latest()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_latestadopts because a claim reads uncached precisely to see another process, and the tip it finds is what its own write then builds on.check_fencedandclaim_epochuse it for exactly that reason.Enforcing
Nothing previously enforced the gap-free invariant the cache rests on: every caller hand-wrote
current.version + 1and 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_versionstops at the first absent batch, so a gap wider thanmanifest_scan_batch_sizecombined 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_updatetherefore 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.writekeeps 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
WalTailerwas 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_seenpurely as a cursor hint fornext_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 ordinarycommit_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:
5 + NrequestsNThe 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_latest→latestShardManifestStore::read_latest_uncached→refresh_latestShardManifestStore::writeis now crate-private. Callers reach it throughcommit_update,claim_epoch, orinitialize_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_updateclosures need no change: settingversion: current.version + 1is exactly what the check expects.Tests
cargo test -p lance --lib dataset::mem_wal::— 619 passed.Covering the properties this is allowed to break:
check_fencedthrough a held positionlatest()keeps observing the writer, and never adopts a positionrefresh_latestadopts — reversing that would either pin pollers or reject valid claimscommit_updaterecovers from a stale position instead of spinning on the version it lostNote 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