Repository navigation
performance: a cold commit list reads every merged branch's history block, one at a time #1068
Description
Problem
The first commit list on a server that has just started reads every __history object of the graph, one after another, including the blocks of every merged review branch. The answer is only the branch's first-parent chain, and most of those objects hold none of its commits. On a graph written through short-lived review branches the cost grows with the number of branches ever merged, not with the length of the list. Each read is a bucket round trip, so a cold call runs for minutes.
The path at d883c49:
Omnigraph::list_commits(omnigraph.rs#L3902) →CommitGraph::load_commits(commit_graph.rs#L727) →lineage()(commit_graph.rs#L651) →.first_parent_chain().- The history cache counts as complete only when it is closed under every parent, merged parents included. On a cold handle
lineage()therefore takes the full refresh (record_projection_full_refresh) throughhistory::read_lineage_in. - The full refresh lists
__historyand reads each extent in turn:for key in list_extents(...) { found.push(selection.listed(&key, Narrow::All).await?) }(history.rs#L3061). Every extent waits for the previous one. - The result lives only in process memory, and it is stored only after the walk completes.
- The server's scoped storage client sets
client_max_retries=0for every request, reads included (lance_access/scoped.rs#L72). One transient read error anywhere in the walk fails the whole request, and the next call starts the walk over. (bug: one storage write with an unknown outcome stops the server until a manual unlock #1067, step 3, covers re-enabling retries for idempotent requests.)
#1013 added paging (--limit/page_token) that walks first parents and reads only the extents a page's commits occupy. Without those parameters the answer and its cost are unchanged, and the unpaged form is still the default.
Evidence
A production cluster graph on 0.14.0-edge+d883c49, against an S3-compatible bucket in the same region as the server. Writers apply every change as load --from main on a review branch, merged into main.
__history holds 1,695 objects; the commit list returns main's first-parent chain:
| Count | |
|---|---|
| Block extent objects | 1,673 (221 MB) |
| Distinct blocks | 1,596 |
Blocks with exactly one object, typically one commit (<block>/143-143.lance) |
1,576 |
| Commits returned | 4,987 |
| Blocks holding the returned commits | 20 |
| Merge commits among them, each naming a different merged branch's block | 1,560 |
So about 93% of the objects the refresh reads hold no commit it returns.
Latency of GET /commits with no paging parameters:
| Server state | Time |
|---|---|
| Just started | 103 s |
| Immediately after | 1.6 s |
| Just restarted | 500 after 56 s |
| Next call | 227 s |
227 s over 1,673 objects is about 136 ms per object, consistent with a few sequential requests per extent. That per-object figure is inferred from the timing; request counts were not measured. The server logs nothing during the walk (no Lance scan lines), so a slow call cannot be told apart from a hung one.
Directions
- One first-parent walk for both forms. Build the unpaged list from the same first-parent walk the paged list uses, with no limit, and drop the requirement that the cache be closed under merged parents. The list never returns a merged branch's commits, so their blocks need not be read. Any caller that does need full closure under parents, such as ancestry or merge-base, keeps its own bounded reads.
- Concurrent extent reads. Wherever a full refresh remains, read extents with bounded concurrency instead of one at a time. Memory is already bounded per extent.
- Retries for reads. A GET is idempotent, and its retry cannot hide a mutation, so the single-attempt rule should apply to mutations only. (bug: one storage write with an unknown outcome stops the server until a manual unlock #1067, step 3.)
- Fold small branch blocks. Let
optimizecombine the single-commit extents left by merged review branches into fewer objects, preserving commit ids and slots. That bounds the object count for every history reader, not only the commit list. - Observability. Log the history objects read and the time spent when a cold refresh runs, so a slow walk is visible.
Acceptance
- On a cold handle, an unpaged
commit listreads a number of__historyobjects bounded by the extents that hold the returned chain, flat in the number of merged branches. - One transient read error during the walk does not fail the request.
- Gate this in a cost owner, for example
integration/warm_read_cost.rsor a GQT--measurecase: a graph with N merged single-commit review branches, where a coldcommit listrequest count must stay flat as N grows.
How this was measured
- Object counts: a LIST of the graph's
__history/prefix, keys and sizes only. - Commit and block counts: the returned commit ids (
hb1.<block>.<slot>.…) and theirmerged_parent_commit_idvalues. - Wall times: the edge CLI over HTTPS from outside the region. The client-to-server round trip, about 0.2 s, is negligible next to these times.
Activity
ragnorc commentedon Oct 20, 2026
Summary
A cold unpaged commit list still reads every
__historyextent. The paged list does not. I reproduced that growth on mainb692610c4aa31066cb6a9fd34830f56977288943. No merged or open pull request changes this walk. This issue does not supersede another issue, and I am not closing it.The user-visible problem is a cold
GET /commitswith nolimitand nopage_token. On a graph built from short review branches, the call reads objects that are not in the answer. The cost follows the number of merged branches. One failed read fails the whole call.Status
The issue is open. It has no comments. No pull request implements the fix.
These issues cross-reference it: #1067, #641, #642, #644, #724, #837, #895, #970, #971. None of them is a reproduction or a fix.
What changed since
d883c49The unpaged path is the same path the report describes.
limitorpage_token,GET /commitsstill callslist_commits.CommitGraph::lineageand the extent loop are unchanged. The history store now takes the Lance 13 session. That does not change which objects the loop reads.lance_access/scoped.rsstill setsclient_max_retriesto0andlance_aimd_max_retriesto0.Reproduction
Reproduced for the read-count claim. Not reproduced for the S3 latency numbers. Confirmed from code for the single-error claim. I did not inject a fault.
Environment:
b692610c4aa31066cb6a9fd34830f56977288943(checked againstorigin/mainon 2026-10-20)file://directory, debug test profileI added a temporary test next to the history counter in
crates/omnigraph/tests/integration/warm_read_cost.rs, ran it, and removed it. It is not on a branch.For N = 1, 3, and 6 the fixture did this:
review{i}from main.Merged.Then a new handle ran
list_commits, a second call on that same handle, and a cold paged walk withlimit100. A control graph had 6 inserts on main and no review branch.Command:
The whole run finished in 0.95 s. Results from
HistoryPlane::requests(the counterwarm_read_cost.rsalready uses; it sums the history wrapper and the opened store):Main-only control: 0 extent objects, 0 unpaged reads, 9 commits returned.
The test required unpaged reads to stay flat from N = 1 to N = 6. It failed. Reads went from 4 to 14. Objects on disk went from 1 to 6. Each object is one single-slot file,
__history/blocks/<block>/<slot>-<slot>.lance. The paged walk stayed at 0. The second call on the same handle stayed at 0.I did not split the counter by path. The object count is exact and equals N. The counter grew by 2 recorded reads per added branch (
2*N+2).Claim by claim
A cold unpaged list reads every merged-branch extent, and each extent waits for the previous one. Reproduced.
list_commitscallsload_commits, which callslineage(), which callsread_lineage_in. With no commit ids,read_selectedlists every extent and reads each one in aforloop (history.rsaround thelist_extentsloop). Objects on disk equal N. The counter grows with N. The loop is sequential in source.The answer is the first-parent chain, and most of those objects hold none of it. Reproduced, in a stronger local shape. The returned commits stay in the
__manifestbuffer under the default 256 KiB release budget. Their block count stays 2. Every extent is a merged parent outside that chain. The paged walk reads none of them. A main-only chain of 9 commits also reads none. So the reads follow merged branches, not the length of the answer.The filed graph had also released main's own history: 4,987 commits in 20 blocks, and 1,673 extent objects. I did not rebuild that released shape. The same full refresh would read those main blocks and the merged-parent blocks.
a_commit_list_page_reads_the_history_it_returnsalready shows that the whole listing reads more blocks when main releases more, and that one page reads less.The result lives only in process memory, and only after the walk completes. Confirmed in code, and the warm call matches.
HistoryCache::replaceruns afterread_lineage_inreturns. The second call recorded 0 reads. A new handle is cold. I did not restart a server.The server scope sets
client_max_retries=0for reads, so one transient read error fails the walk. Confirmed from code. Not injected. The local test uses an embedded file store, not the server scope.scoped.rsstill forces both retry knobs to 0. A served graph uses that scope, and history reads use that session's store registry. The extent loop uses?. The cache is not stored on error. The next call starts the walk again. Lance's defaultclient_max_retriesis 3. The scope overwrites it.download_retry_count(default 3) covers a short response body, not a GET that fails before a body.feat(commits): page a branch's commit list #1013 does not change the unpaged default. Confirmed.
server_commit_listcallslist_commitswhenlimitandpage_tokenare both absent. The CLI does the same.Cold calls of 103 s and 227 s, and a warm call of 1.6 s, on S3. Not reproduced. Local disk finished the N = 6 case inside a 0.95 s test. I cannot repeat the bucket round trips here. The warm half (0 reads on the same handle) matches the report's "the second call is cheap" claim. It does not match the 1.6 s figure.
Difference from the filed steps: I did not run
load --from main. A branch merged with no later commit on main fast-forwards and does not write this merged-parent extent. The fixture writes on main after the fork so each merge isMerged. That is the shape behind the report's 1,560 merge commits.Root cause
The list never returns a merged parent.
lineage()still requires the cache to be closed under every parent, merged parents included (HeldCommits::outside_parents, then thecompletecheck inlineage()). On a cold handle that check fails, and the code callsread_lineage_in. That function reads every extent.first_parent_pagealready walks only first parents and reads an extent only when the page does not already hold that commit. The unpaged list does not use it.A missing or unreadable merged-parent extent also fails the list today, through
unlisted_absent, even though the list does not return that commit.Code on this SHA:
crates/omnigraph/src/db/omnigraph.rslist_commits(about line 4168) andlist_commits_page(about line 4197)crates/omnigraph-server/src/handlers.rsserver_commit_list(thepagedflag about line 3492, the unpaged call about line 3565)crates/omnigraph-catalog/src/commit_graph.rslineage(about line 652) andload_commits(about line 728) andfirst_parent_page(about line 744)crates/omnigraph-catalog/src/history.rsread_selected(the loop about line 3061) andread_lineage_in(about line 3452)crates/omnigraph-core/src/lance_access/scoped.rs(about lines 71–73)Recommended design
Make the unpaged list the same first-parent walk as the paged list, with no page cap. Do not call
lineage()fromlist_commits.Keep the response the same when
limitandpage_tokenare absent. #1013 keeps that response byte-for-byte with the old list. Build the unpaged answer from the first-parent walk so the two forms cannot drift.load_commitsstill sorts bylineage_keyandlist_commitsreverses that. A page walks first parents by generation. The type comment sayslineage_keydoes not define feed order. A merge chain is the case to check, because a merged parent can have a generation off the first-parent line.This is the small correct change. A faster full refresh would still read the irrelevant objects.
Callers of
lineage()and what they actually need:build_change_feed_cutneeds first parents only. It finds genesis and builds the child index. A cold change feed pays the full refresh today. changes: continue change feeds across additive schema changes and outside the request's type filter #1048 and changes: wait for a branch to move instead of polling #1056 sit on that cut. They do not need every merged parent.lineage_holdsneeds first parents below the captured head. It can stop at the commit it seeks.retained_merge_basesneeds both parents.CommitGraph::merge_basealready fetches by id, with a visit cap and a byte cap, and it does not fill the parent-closed cache. Use that walk. Do not load every extent first.read_commitis enough.get_commitand diff endpoints already read one commit by id. They do not use this full refresh.After those callers move,
read_lineage_inhas no production caller. That is the shape invariant 11 and invariant 12 already ask for. Hot-path work follows the working set. A request does not rebuild a warm projection from full history.Bounded concurrency. Do not use it to fix the list. It still reads the irrelevant objects. Use it only for a reader that must see every extent, such as an explicit integrity check. A lineage read fetches a suffix of at most
TAIL_BYTES(512 KiB). A full record object can be 64 MiB (MAX_OBJECT_BYTES). Cap the number of reads in flight. Do not start one task per object.Retries. Put them in the storage-outcome work, not in a special case in
history.rs. #1067 step 3 and #1093 classify requests. A GET of an immutable extent can be retried. A conditional publication stays one attempt. Open PR #1096 splits those two clients and routes GET to the retrying client. It is not merged. It does not change which extents the list reads. On main, one GET error still fails this walk. Do not retry the whole walk as one mutation. Client retries do not fix the object count. An error that survives the retry budget still fails the call until the list stops reading every extent.Folding small blocks. Do not put that in this change.
optimizedoes not rewrite__history. A full list reads every objectlist_extentsreturns, and overlapping copies are compared. A fold that leaves the old objects in place makes that read worse. Replacing them is a retention decision (#645). #1082 says optimize on a served cluster requires a stop. #1094 and #1095 are about table versions, not these extents. Folding can help every history reader later. The list must be correct without it.Persistence. Do not store a second copy of the chain. The cache is process memory, and it is stored only after a successful walk. A restart repeats a cold walk. A durable chain beside
__historycan drift. The block-registry RFC already keeps derived block shape in__manifestfor merge-base, and__historystays the authority.ExtentCachecan keep objects this process has already read. That is enough once a cold list reads only the chain.Observability. Log a refresh that still runs: object count, bytes, and duration.
record_projection_full_refreshonly increments a test counter. It does nothing in production. Lance object-store metrics need themetricsfeature and a recorder, and they do not name__history. Open PR #1001 adds OTLP traces. The history log line does not need to wait for that pull request.What this choice gives up is a cache that survives a process restart. That is the right cut. A shadow chain is a second source of truth. After the fix, a cold list scales with the returned chain, so a restart does not need that copy.
The list also stops failing when a merged-parent extent is missing or unreadable. The list does not return that commit. A caller that needs the merged parent still fails its own read.
Test plan
Extend
integration/warm_read_cost.rs. Do not add a.gqtcase. The defect is a request count, anddocs/dev/testing.mdallows a deterministic operation count as a CI contract. The owner testa_commit_list_page_reads_the_history_it_returnsalready covers pages against releases on one branch. Extend that plane. Do not add a second harness.history_release_bytes, so main's chain is in__history. Assert that unpaged reads equal the paged walk of the whole chain. Assert that both stay flat in N against a main-only chain with the same number of releases.history.rs.crates/omnigraph-dst/cost_table.txthas no commit-list row. Update it only if a covered operation's storage calls change.Lance
Lance does not cause this, and a Lance upgrade will not fix it.
OmniGraph lists its own
__historyobjects and GETs them in its own loop. The pinned version is Lance 13.0.0. That is also the latest stable release, published 2026-10-07. Current pre-release is v14.0.0-beta.21, published 2026-10-19. No open pull request in this repo upgrades Lance. #1072, the move to 13.0.0, is already merged.I checked the Lance object-store guide and the Lance 13.0.0
client_max_retriesgetter. The default is 3. The server scope sets it to 0, so a change to Lance's default would not apply on the server until OmniGraph stops forcing 0. Open Lance work on retries (#8363 for OpenDAL, #6544 forrename_if_not_exists) does not remove this loop. Lance can emitlance_object_store_requests_totalwhen metrics are enabled. That does not tell an operator that a commit list is walking every history object.Related work
lineage()today.docs/rfcs/2026-10-10-merge-base-block-registry.md. Derived block shape for merge-base.__historystays the authority.__manifest. Different path.Nothing here supersedes #1068. The commit-list walk is still the open defect.
Limitations
Mergedby writing on main after the fork. A fast-forward does not write this extent.