Repository navigation
perf(catalog): probe the next __manifest version by name instead of listing - #962
Conversation
…isting A warm read learned whether its branch's `__manifest` moved through Lance's `latest_version_id`, which lists `_versions/`. On a store with ordered listing that one request returns up to 1,000 keys, so a branch past 1,000 commits paid a full page on every read. The freshness probe now HEADs the attached versions above the one the handle holds, at most two, and treats the first "not found" as the end of the history. Lance writes an attached version only with a put-if-absent over a read of the version below it, and nothing deletes a version of a live branch's `__manifest`. A longer gap, a detached handle or any other error falls back to the listing.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: request changes. This is a COMMENT review because the authenticated account is the PR author.
This PR reduces the storage work before a query can reuse a warm graph handle. The old check lists manifest names. The new check asks whether the next one or two names exist. It avoids a large LIST response when the reader is current or one commit behind. It retains Lance's latest-version lookup for larger gaps, detached handles, and probe errors.
The normal path works in the focused tests. However, the new negative check also accepts missing manifest authority as a current cached head. That breaks the existing failure contract. See the inline P1. Fix this at the shared catalog probe, so reads, write capture, merges, and unpublished/unattached validation all retain the same rule. Do not suppress the lifecycle test's directory error: that error exposes effects after authority disappeared.
Design and tradeoffs. Attached publication uses Overwrite with zero retries and skips automatic cleanup (commit owner). I checked these assumptions against the lockfile's Lance 11.0.0 source, including strict overwrite, manifest naming, object-store HEAD, branch paths, and latest-version resolution. I also checked callers and branch retirement. The local dependency archives match Cargo.lock, and the inspected sources match those archives.
Lance's own upward probe first confirms that its starting version exists. This helper omits that positive witness. A missing successor proves no advance only while the underlying authority remains valid.
The change adds no persistent state, public API, or second commit authority. One private helper is preferable to separate optimizations in each caller. It removes repeated listing payloads, but adds a continuity requirement and a fixed probe budget. Caught-up readers pay one HEAD. One-commit lag pays two HEADs. Two or more commits of lag pay two HEADs plus the old lookup. This favors frequent reads and sparse writes. It can cost more under sustained writes or on a local filesystem, where Lance can read the directory directly. The PR's cloud timings are author measurements of individual requests. I did not reproduce them or measure served latency.
The lasting liability is retention: #644 cannot remove an intermediate version above a held reader without changing this probe. The documented fence helps, but future GC must solve that interaction by design. Five caller-specific versions of this optimization would multiply that obligation. Keep one owner and explicit tests for absence, advance, gaps, and storage errors.
I surveyed the other 32 open PRs. This change complements immutable table-handle reuse in #818 and the authority-cache work in #825. Group commit (#785) can change how often readers lag, so its cost results should include this fallback. Unattached-root benchmarks (#961) remain relevant because unpublished validation calls this probe. Planner, language, deployment, and documentation proposals do not remove its freshness obligation. Lance 13.0.0 still separates general latest-version lookup from the restricted successor probe. An upgrade alone does not discharge the continuity or existence checks.
Local validation. Production code came from these exact commits:
- Before:
47dfe7b4a00c1d6405bc076dc6f4f549ae6fc06c. - Reviewed head:
e3f1e1268447ddb4405f5804c72cf7ac9e4e2fa6.
Commands used Rust 1.97.1, RUST_MIN_STACK=16777216, RUSTC_WRAPPER=, the repository's Tokio configuration, and a reused build directory. The explicit build flags were RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable'.
| Check | Before | Reviewed head |
|---|---|---|
cargo test -p omnigraph-catalog --locked freshness_probe -- --nocapture |
PR request-count test fails: (0,0,0) instead of (0,1,1). Temporary missing-authority test passes. |
Request-count test passes. Temporary missing-authority test fails: returns Ok(ManifestIncarnation { version: 1, ... }). |
cargo test -p omnigraph-engine --locked --test integration lifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence -- --exact --nocapture |
1 passed | 1 failed: restoring __manifest reports DirectoryNotEmpty, matching CI. |
cargo test -p omnigraph-catalog --locked |
Not run | 207 passed |
cargo test -p omnigraph-engine --locked --test integration -- warm_read_cost:: branching:: consistency:: point_in_time:: write_cost:: |
Not run | 139 passed |
For the comparison, I copied the PR's test additions to the base and declared the budget 2 inside the test on both commits. Production code was unchanged. I also ran this same temporary catalog test on both commits:
#[tokio::test]
async fn freshness_probe_refuses_missing_manifest_authority_review_962() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let coordinator = ManifestCoordinator::init(uri, &build_test_catalog()).await.unwrap();
let manifest = dir.path().join("__manifest");
let held = dir.path().join("manifest-unavailable");
std::fs::rename(&manifest, &held).unwrap();
let result = coordinator.probe_latest_incarnation().await;
std::fs::rename(&held, &manifest).unwrap();
assert!(result.is_err(), "missing manifest authority must refuse, got {result:?}");
}Please include this minimal regression, or its equivalent in the existing owner, with the fix. GQT cannot express removal of the internal authority directory or exact HEAD/LIST counts. The catalog and existing lifecycle Rust owners directly test those contracts. All temporary edits were restored. The checkout is clean at the reviewed head.
CI and limitations. The reviewed head passed ordinary, DST, and served GQT jobs, format, clippy, and storage-upgrade compatibility. The overall gate is red:
- Workspace test failure: the lifecycle regression above. CI stopped after 2,880 of 4,338 tests.
- DST failure: the cost golden needs a reviewed update after the fix. For example,
AddFriendLISTs change 16→9 and_verifyGETs change 1425→1443. The meter counts successful GETs after the call, so missing HEAD attempts are not a complete request-cost total. - Fix Regression Gate: the gate interpreted the body as closing #752 and found no issue-named test. Name the existing Rust regression for the issue, or correct the gate's closure interpretation. This PR does not complete #752's per-phase attribution work.
A first local engine build ran out of disk space. After removing confirmed unused incremental Rust artifacts, the focused builds and comparisons completed. I did not run the full workspace, local DST suite, live S3/Azure tests, or a latency benchmark. No additional optional code changes are requested.
| for _ in 0..FRESHNESS_PROBE_STEPS { | ||
| match store.exists(&scheme.manifest_path(&base, latest + 1)).await { | ||
| Ok(true) => latest += 1, | ||
| Ok(false) => return Ok(latest), |
There was a problem hiding this comment.
[P1] Preserve missing-authority refusal before reusing the cached head
Ok(false) also means that the whole __manifest tree is absent. In that case this returns the held version without proving that any authority still exists. The main-branch arm then returns the cached incarnation, so callers accept it as current and can proceed into durable work. This is observable now: lifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence passes at 47dfe7b4 and fails at e3f1e126 because operations recreate a nonempty __manifest directory while the original is unavailable. The smaller catalog reproduction in the review also changes from an error to Ok(version=1).
Preserve a positive authority/existence check at this shared probe before treating a missing successor as freshness. Cover missing authority across its read and write callers, and keep the existing before-effect contract. Fixing only the lifecycle caller or ignoring its restore error would leave the same cause in cached reads and merge/proposal validation.
…essor A removed or unreadable `__manifest` answers "not found" for every name, so the probe read it as "unchanged" and a write on a warm handle went on to recreate `__manifest/` instead of refusing before effects. The probe now HEADs the held version and the next one together and accepts "no change" only while the held version answers; otherwise the listing reports the failure as before. The warm-read cost tests pin the one recorded `__manifest` request, the HEAD of the held version, and the DST cost golden records the listings the probe no longer makes.
…e's repeat reads A write probes its branch's freshness when it captures the branch and again when it revalidates the capture, and each probe HEADs the held `__manifest` version, so that HEAD repeats within a step beside the indexed table's manifest reopen. A read probes once.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve 82ec066bc16428b956590bbae25bca944c52ce9d. The missing-authority P1 from my previous review is resolved. This is a COMMENT review because the authenticated account is the author. I found no remaining blocking defect and request no optional implementation change.
This PR reduces the storage work before a query can reuse a warm graph handle. Instead of listing manifest history, it checks the held version and the next version by name. A current reader avoids a potentially large LIST response. The revised probe also checks that its starting authority exists. Removing __manifest now causes refusal instead of allowing cached authority to reach write effects.
The fix belongs in the shared catalog probe. Both quick returns require a successful HEAD of the held version. An absent held version, a probe error, or a larger gap uses Lance's existing latest-version lookup. This restores the invariant for reads, write capture, merge capture, and captured proposal/unattached validation together. The named-branch identifier check still distinguishes branch lifetimes. No caller-specific exception hides the failure.
I checked the current guides, relevant upstream pages, adjacent callers, storage retirement, and the pinned Lance 11.0.0 implementation. Catalog publication uses strict Overwrite with zero retries and disables automatic cleanup. Attached versions remain contiguous under the supported storage contract. Lance's own upward probe also checks its starting version before accepting an absent successor.
The request tradeoff is concrete:
| Reader position | Probe work before any refresh |
|---|---|
| Current | Two concurrent HEADs, no LIST |
| One version behind | Three HEADs across two rounds, no LIST |
| Two or more versions behind | Three HEADs, then the existing latest-version lookup |
This favors frequent reads with little intervening publication and large remote version directories. It can increase cost for lagging readers or small local datasets. The successful-read meter omits absent HEAD attempts, so its GET total alone is not the request total. I did not reproduce the author's cloud timings or establish a served latency improvement.
The change adds one private decision rule, with no persistent state, public API, or second authority. The fix removes the need for caller-specific missing-authority guards. Its remaining liability is the continuity assumption: future physical manifest GC in #644 must preserve the required range or replace this probe. The documented fence and V1/V2 tests make that obligation visible. Five future callers should reuse this owner rather than copy the algorithm.
I also surveyed the other open PRs. Immutable table-handle reuse (#818) complements this check. Group commit (#785) changes the distribution of reader lag, so its measurements must include the fallback path. Live-branch schemas (#963) and graph diff/merge preview (#964/#966) still need fresh authority before fixing their snapshots. Native admission work (#893/#899) does not change this rule. Lance 13.0.0 still exposes general latest-version lookup separately from the restricted upward probe. A future upgrade needs a new compatibility check.
Regression proof. Before the fix: e3f1e1268447ddb4405f5804c72cf7ac9e4e2fa6. After: 82ec066bc16428b956590bbae25bca944c52ce9d.
- I reran the unchanged lifecycle regression on both commits with
cargo test -j3 -p omnigraph-engine --locked --test integration lifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence -- --exact --nocapture. Before: one failure, because effects recreated the removed authority directory. After: one pass, includingBeforeEffect/NotFoundchecks for nine write doors and unchanged branches/commits. - I reused the same minimal temporary catalog probe from the previous review. It initializes one catalog, moves
__manifestaside, probes, restores the directory, then requires an error. The recorded before-run (cargo test -p omnigraph-catalog --locked freshness_probe -- --nocapture) failed that assertion by returningOk(version: 1). The new run passed. This direct refusal assertion establishes the behavioral failure independently of lifecycle teardown. - The PR now includes the equivalent missing-authority assertion in its existing V1/V2 catalog test. No additional regression is needed. GQT cannot express removal of the internal authority directory or exact HEAD/LIST operations, so these Rust owners are appropriate.
Other local checks. cargo test -j3 -p omnigraph-catalog --locked --lib -- --nocapture: 208 passed (207 checked-in tests plus the temporary probe). cargo test -j3 -p omnigraph-engine --locked --test integration warm_read_cost:: -- --nocapture: 27 passed. cargo test -j3 -p omnigraph-gqt --locked --test runner_dispatch measure_counts_schema_contract_requests_issue_817 -- --exact --nocapture: one passed. Documentation, AGENTS links, formatting, and diff checks passed. All commands used Rust 1.97.1, RUST_MIN_STACK=16777216, RUSTC_WRAPPER=, CARGO_TARGET_DIR=/tmp/review-928/target, and RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable'. Temporary test edits were restored.
CI evidence and limits. CI belongs to this PR head but checked out merge commit 998317cf80c00f0ada532360e3369213f7b67dc8, which includes other main changes. It is separate evidence from the exact-head local runs. Workspace CI passed 4,342 tests, with 32 skipped. DST CI passed the cost golden. GQT CI passed 349 corpus cases and all 26 dispatch tests, including the changed schema-request count test. I did not run the full workspace, full DST suite, or live S3/Azure checks locally.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve f2c65354979e7905b5971f6f340bf34ce2b69d19. This follow-up covers the merge of current main since my previous review. The freshness algorithm is unchanged. I found no blocking defect or optional implementation change to request. This is a COMMENT review because the authenticated account is the author.
This PR reduces storage work before a warm graph handle can be reused. The shared catalog probe checks the held manifest version and its successor by name. It accepts an absent successor only when the held version exists. A missing authority, storage error, detached handle, or larger gap uses the existing latest-version lookup. Named branches retain their lifetime check. The earlier missing-authority defect remains fixed for reads, write capture, merge capture, and captured validation through this common owner.
The merge preserves the relevant contracts. On a coordinator-cache hit, the code still probes freshness and removes a failed entry. Opening a cache miss outside the lock does not grant that entry publication authority; capture stays under the returned guard, and writes retain revalidation and version CAS. Historical alias binding receives the accepted catalog and live snapshot together. It can therefore compare physical table identity against the same accepted view.
I checked the repository guides, relevant upstream documentation, callers, retirement behavior, deployment boundaries, and pinned Lance 11.0.0 source. Catalog publication uses strict Overwrite with zero retries and disables automatic cleanup. Live manifest versions remain contiguous. Lance's own upward probe also establishes a starting version before accepting an absent successor.
The tradeoff remains: a current reader uses two concurrent HEADs and no LIST; a reader one version behind uses three HEADs across two rounds; a reader further behind uses three HEADs before the existing lookup. This favors frequent reads with little intervening publication and large remote histories. Lagging readers and small local histories can pay more requests. The successful-read counter excludes absent HEAD attempts, so it does not report the whole request cost.
The liability assessment is unchanged. This adds one private decision rule, with no persistent state or public API. The held-version check removes the need for separate missing-authority guards in each caller. Five future callers can reuse this owner. The remaining obligation is continuity: physical manifest GC in #644 must preserve the required range or replace this probe.
I surveyed the other open PRs. Immutable table-handle reuse (#818) complements this check. Group commit (#785) needs measurements across reader lag and fallback. Live-branch schemas (#963), diff/merge preview (#964/#966), and actor views (#951) must retain one accepted authority when fixing their read view. Lance 13.0.0 still separates general latest-version lookup from the restricted probe; its availability does not remove the need to recheck naming, commit, and cleanup contracts at a future dependency bump.
Fresh regression proof. Before: e3f1e1268447ddb4405f5804c72cf7ac9e4e2fa6. After: f2c65354979e7905b5971f6f340bf34ce2b69d19.
- The unchanged lifecycle regression failed before: write effects recreated the removed manifest directory, so restoration failed with
DirectoryNotEmpty. It passed at the reviewed head, including refusal evidence and unchanged graph state. - The same temporary catalog test ran on both commits. It creates one catalog, moves
__manifestaside, probes, restores the directory, and requires an error. Before: one assertion failure, because the probe returnedOk(ManifestIncarnation { version: 1, ... }). After: one pass. This confirms the behavioral cause independently of lifecycle teardown. - The PR already includes the equivalent missing-authority assertion for V1 and V2. GQT cannot remove an internal authority directory or assert exact HEAD/LIST operations. The existing Rust owners cover those contracts. No extra regression is requested. All temporary edits were restored.
On the before commit, I ran the lifecycle command below. I ran the direct probe command on both commits:
cargo test --locked -p omnigraph-engine --features failpoints --test integration -j 3 lifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence -- --exact --nocapture
cargo test --locked -p omnigraph-catalog --lib -j 3 tests::freshness_probe_refuses_missing_manifest_authority_review_962 -- --exact --nocaptureAt the exact head, the same lifecycle case ran in this focused set, followed here by the catalog-suite command:
cargo test --locked -p omnigraph-engine --features failpoints --test integration -j 3 -- --nocapture warm_read_cost:: point_in_time:: lifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence
cargo test --locked -p omnigraph-catalog --lib -j 3 -- --nocaptureLocal results: 46 engine tests passed: 27 warm-read, 18 historical-snapshot, and one lifecycle test. All 207 checked-in catalog tests passed. The temporary direct probe passed separately. Formatting, AGENTS links, and documentation checks passed. The documentation check first needed the missing release tag fetched into this checkout. Commands used Rust 1.97.1, RUST_MIN_STACK=16777216, RUSTC_WRAPPER=, CARGO_TARGET_DIR=/tmp/review-928/target, and RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable'.
CI and limits: Workspace CI passed 4,342 tests, with 32 skipped. It checked out 77ae96ddf29759baef7e0a0cb7ced9d509f1a2d3; I verified that its tree exactly equals this reviewed head. DST, ordinary GQT, and served GQT also report success. Those CI results are separate from the local runs. I did not rerun the full workspace or live S3/Azure tests locally, reproduce the author's cloud timings, or establish a served latency improvement.
…s-probe # Conflicts: # crates/omnigraph-dst/cost_table.txt
What & why
This removes a listing of up to 1,000 keys (about 300 KB) from every warm read on a graph with a long history. A read on a warm graph handle checks whether its branch's
__manifestmoved before it reuses the coordinator it holds. That check called Lance'sDataset::latest_version_id, which lists_versions/. On a store with ordered listing (S3, GCS, Azure) Lance reads one page of up to 1,000 keys, so every branch past 1,000 commits paid a full page on every read. Below 1,000 attached versions, the listing also pages through the detachedd*.manifestentries that unattached and unpublished commits leave in the same directory. The cost grew with history until it hit the page cap, and long-lived graphs sit at the cap.The probe now asks for attached versions by name (
ManifestNamingScheme::manifest_path, V1 or V2 paths). It HEADs the held versionvandv + 1concurrently, thenv + 2whenv + 1exists. Whilevexists, the first "not found" ends the history. So an unchanged branch costs two small HEADs in one round trip, and a branch one commit ahead costs three. An absentvfalls back to the listing, and so do a gap of more than two versions, a detached handle and any error. The listing stays the authority. A removed__manifestanswers "not found" for every name, so the listing still reports it asNotFound, and a write on a warm handle refuses before effects instead of recreating the directory. Lance's ownprobe_versions_upward(its S3 Express path) also checks the starting version first, and SlateDB ships the same next-version probe (its RFC 0032).On a graph with a few dozen versions the listing is already one small page, so this change is roughly neutral there. A warm read still waits on one round trip, now a HEAD instead of a LIST. In the server's region that is about 20 ms saved per read; see Measurement.
Backing issue / RFC
usageobject.Measurement
These are read-only timings of the probe's requests against a production graph on S3-compatible storage. Its
main__manifestis at Lance version 3,334. Each cell is the median and range of 10 requests on one reused connection._versions/(1,000 keys, 308 KB)On a graph with 260 versions, the in-region LIST took 30 ms against the same 18–19 ms HEADs.
In the server's region, the probe saves about 20 ms per warm read: the two HEADs run concurrently, replacing one 40 ms LIST. A write probes twice, so it saves about 40 ms. The saving grows with the store's per-request latency and with the listing's size, up to the 1,000-key page. End-to-end hosted latency with this change is not measured.
Request cost, from the DST cost golden (
crates/omnigraph-dst/cost_table.txt, whole suite,main→ this PR):DeletePerson,UpdateV,EnsureIndicesand the harness's_historyand_closereads.--measure's price model (LIST 5.0 µ$, GET or HEAD 0.4 µ$, S3 request pricing) makes a probe go from 5.0 µ$ to 0.8 µ$. Read-request spend across the suite falls from about 5,640 µ$ to 4,260–4,380 µ$.Why the first absent name is the latest
read_version + 1. The catalog's publish is anOverwritewith zero retries, and Lance's S3 commit handler uses the same conditional put.__manifest.cleanupcollects graph-table versions and retired branch trees, and storage: safely reclaim obsolete physical __manifest versions and files #644 (reclaiming old__manifestversions) is open. The code comment and the newdocs/dev/lance.mdfence row state the constraint storage: safely reclaim obsolete physical __manifest versions and files #644 must keep: every version above one a live handle holds stays.Checklist
docs/dev/lance.mdfence row andchangelog.d/catalog-freshness-probe-heads-next-version.performance.md__manifeststill fails before effects. No new state or queue.Local verification
cargo test -p omnigraph-catalog --locked: 207 passed, includingfreshness_probe_asks_for_the_next_version_by_name. Withlatest_attached_versionreduced to the oldlatest_version_idcall, that test fails on its first assertion.RUST_MIN_STACK=16777216 cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-dst --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints --no-fail-fast: all passed. The first local run withoutRUST_MIN_STACKoverflowed the stack inomnigraph-bench --liband in engineintegration. Both were re-run with CI's stack size: bench lib 406 passed, engineintegration616 passed with 8 ignored. That run includeslifecycle::write_preparation_manifest_read_failures_carry_before_effect_evidence, which failed on the first push of this PR.cd crates/omnigraph-dst && DST_REGEN_COSTS=1 cargo test --locked --test scenarios dst_bench_cost_count_golden, thencargo test --lockedthere: 53, 2 and 51 passed.cargo test -p omnigraph-gqt --locked --test runner_dispatch measure_counts: passes. The full--lib --test runner_dispatchrun passes exceptconcurrent_block_refusals, whosestarved_wallfixture hits its wall-time budget under this machine's load. It passes alone, and it passed in CI on this PR.cargo fmt --all --check: ok.cargo clippy -p omnigraph-catalog --all-targets --locked -- -D warnings -W clippy::dbg_macro: ok.python3 scripts/check-docs.py: ok.typoson the changed files: ok.Notes for reviewers
The test.
freshness_probe_asks_for_the_next_version_by_nameruns on Lance'sshared-memory://store, whose listing is ordered and shared across handles, so it exercises the LIST that local-path stores never issue (theyread_dir). It covers V1 and V2 paths:__manifest: the listing'sNotFound.It extends the existing
HistoryGetscounter with LIST counts.Warm-read cost tests.
warm_read_cost.rsnow pins one recorded__manifestrequest on a warm read, the probe's HEAD of the held manifest version. On local stores the old listing was aread_dirthe tracker never saw, which is why the count was zero. The HEAD of the next version answers "not found", and the tracker records no failed request. Lance's tracker reports a HEAD's range as the object's whole size, so the assertions name the request by path rather than by bytes.GQT measure test.
measure_counts_schema_contract_requests_issue_817allows a write step one more repeat read: the freshness probe's HEAD of the held__manifestversion. A write probes when it captures its branch and again when it revalidates that capture. A read probes once.DST cost golden.
cost_table.txtmoves calls froml.listtol.get: about two small HEADs replace each_versions/listing.