Skip to content

perf(changes): resolve diff sides once, skip commit lookups for branch names, use full CLI pages - #968

Merged
ragnorc merged 19 commits into
ModernRelay:mainfrom
ragnorc:claude/graph-diff-request-costs
Oct 14, 2026
Merged

ragnorc merged 19 commits into
ModernRelay:mainfrom
ragnorc:claude/graph-diff-request-costs

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #966 (graph diff, branch review and merge dry run). Review that first; this PR's own change is the last commit.

This PR takes the three cheap fixes from a measured investigation of graph diff cost. The deeper bottlenecks it found are filed as issues (linked below).

Changes

1. A diff side that cannot be a commit id is never looked up as one. Every graph commit id is minted canonical, either a ULID or an hb1 address (is_valid_graph_commit_id). Classification now skips the commit lookup for any other spelling. Before this, a side like main took the lookup's miss path, which checks out and scans every live branch's __manifest.

Measured under DST: a one-change diff between two branch names cost about 22 requests per live branch. With 32 idle branches that was 795 requests, of which 23 read the table (about 7 s under the S3-like model). The new cost gate shows the same diff reading 5 __manifest objects with 1 idle branch and with 9. Without the gate it read 15 and 31.

A malformed hb1. spelling is now a typed ReadTargetNotFound instead of an internal error from the id parser.

2. One resolution serves authorization and the read. /diff used to classify each side once to authorize it (diff_read_target_branch), and the engine classified it again to read. That doubled the cost, and the two resolutions could disagree if a branch was created or deleted in between.

Omnigraph::prepare_diff_page now resolves the request once and reports the branch each two-point side reads (PreparedDiffPage::side_branches). The server authorizes those branches, then run_diff_page reads. diff_page is the one-call form for embedded callers. diff_read_target_branch is removed. A continuation still classifies each spelled side for authorization and reads the commits its token pins, as before.

3. The CLI asks for full pages when it follows every page. Every change page restarts its comparison: it re-plans, re-resolves its endpoints and re-sorts the remaining keys. Measured on a release build, a 100k-change diff over 1M rows took 19.5 s at the 1,000-change default and 7.3 s at 8,192.

commit changes, changes poll, diff and branch diff now ask for 8,192 changes per page when they paginate themselves, unless --limit is given. A single --page-token fetch keeps the 1,000 default, and so do the HTTP routes. The --limit help text no longer claims the server clamps it: an over-ceiling limit is refused.

Tests

  • New changes_cost::graph_diff_side_resolution_is_flat_in_live_branches: a branch-name diff with 1 and 9 idle branches, __manifest reads gated flat. It fails with the spelling gate removed (15 → 31 reads) and passes with it (5 → 5).
  • changes::graph_diff_refuses_an_ambiguous_or_unknown_side now also covers:
    • a malformed hb1. side, which is a typed not-found;
    • prepare_diff_page reporting ("main", "main") and (<commit>, "feature") as the side branches.
  • Rerun locally with RUST_MIN_STACK=16777216, all passing:
    • engine changes:: and changes_cost:: (65), and forbidden_apis (35; the new public methods are registered as read-only);
    • server auth_policy::, data_routes:: and openapi:: (224): the 404 parity for forbidden sides is unchanged;
    • CLI parity_matrix:: and cli_data:: (119);
    • the served GQT conformance for the four graph_diff_* cases;
    • workspace clippy, cargo fmt --check, check-docs.py and typos.

Not in this PR (filed as issues)

…ed contract

Amend RFC 0030 with a public graph diff over two read targets, a branch
review form against the merge base, `branch merge-base`, and a read-only
`branch merge --dry-run`. Keep `commit changes` as the cause-carrying unit,
retire the table-shaped `ChangeSet`, and reshape C6 around the per-commit
schema content storage format 14 retains. Adds D0 evidence gates, D1-D3
phases and a decision log recording how each premise was checked.
Implement RFC 0030 D1-D3 on the engine, HTTP and the CLI.

- Omnigraph::diff_page reports the net entity changes between two
  branches or commits in the commit-changes shape, with a diff-page token
  that keeps the first page's resolved endpoints. A side naming both a
  branch and a commit is AmbiguousReadTarget; one naming neither is
  ReadTargetNotFound.
- Omnigraph::merge_base returns the commit a merge would classify
  against, by the merge's own rule.
- Session::branch_merge_dry_run runs a merge's capture, classification
  and validation and stops before any effect.
- GET /diff, GET /branches/diff, GET /branches/merge-base and dry_run on
  POST /branches/merge; CLI diff, branch diff, branch merge-base and
  branch merge --dry-run. A /diff side the actor cannot read answers the
  same 404 as an unknown side.
- The table-shaped diff_between/diff_commits and ChangeSet are removed;
  the DST oracle and tests move to diff_page.
- GQT gains --- diff, --- merge-base and --- merge-dry-run steps (RFC
  0045) with four cases on the engine, DST and served targets.
…a branch diff

Address the review of the graph diff amendment.

- A merge base is a valid feed start only on the source's first-parent
  chain. After the target was merged into the source, the base is the
  imported target commit and the feed refuses it; point at branch diff
  for the net change and add the shape to the D0 gates.
- The branch diff and merge base require read on both named branches,
  continuations included: their endpoints, the base and the source head,
  can both be readable while the target is not. Add the D0 gate.
- Defer GQ exposure of the diff surfaces to RFC 0056 rather than reject
  it.
…raph-diff-impl

# Conflicts:
#	docs/rfcs/0030-cdc-time-travel.md
…ed branch review

Follow the RFC 0030 review corrections:

- The user guide's merge-base recipe now holds only while the source has
  never merged the target in; after that the base is off the source's
  first-parent chain, the feed refuses it, and branch diff gives the net
  change. The existing merge-and-feed test asserts that shape.
- The branch diff and merge base already authorize read on source and
  target. A new policy test pins the case where both diff endpoints are
  readable but the target is not, on the first page, a continuation and
  the merge base.
…h names, use full CLI pages

- A diff side whose spelling is neither a ULID nor an hb1 address
  cannot be a commit id, so classification no longer looks it up as one.
  The lookup's miss path checked out every live branch's __manifest, so
  a diff between two branch names cost about 22 requests per live
  branch. A cost gate pins the reads flat in idle branches.
- Omnigraph::prepare_diff_page resolves a request once and reports the
  branch each two-point side reads; the server authorizes those and runs
  the page with run_diff_page, so authorization and the read share one
  resolution. diff_read_target_branch is removed.
- When the CLI follows every page of commit changes, changes poll, diff
  or branch diff, it asks for 8,192 changes per page unless --limit is
  set: every page restarts its comparison, so fewer pages cost less.
The DST history audit now checks each adjacent pair of recorded commits
through diff_page instead of the retired diff_commits. diff_page resolves
each side as a read target, which also checks the branch namespace so an
ambiguous ULID is refused, and the audit pays 4 more gets in total
(_audit l.get 724 -> 728). No engine operation's count changes.
A base off the source's first-parent chain is refused as a feed start. A merge of the target into a diverged source is one way to get there, and a later merge back can bring the base onto the chain again, so the RFC no longer says any past target-to-source merge prevents the recipe.
…raph-diff-impl

# Conflicts:
#	docs/rfcs/0030-cdc-time-travel.md
… statements

Review asked that the diff surfaces be GQ statements under branch_stmt,
beside `branch merge ... as commit`, rather than GQT step kinds.

- Grammar and AST: `branch diff <source> [into <target>]`, `branch diff
  <from> to <to>` with `after page "<token>"` and `limit <n>`, `branch
  merge-base <source> [into <target>]`, and `branch merge <source> [into
  <target>] dry run`, a BranchStmt::Read control read. A bare `dry run`
  is the clause, as `as commit` is.
- Server: the statements enter POST /query and are refused at POST
  /mutate as reads. Each runs its route's body (shared helpers now behind
  GET /diff, /branches/diff, /branches/merge-base and the merge dry run),
  so authorization, the 403/404 decisions and the dry run's 409 match.
- Engine and API types: Session::branch_read answers a statement for
  embedded callers; branch_read_output renders it. A branch diff answers
  one row per change, the type named by its name, with the compared
  commits and the continuation token in a new optional `page` object on
  ReadOutput, the shape RFC 0056 proposes.
- CLI: `omnigraph query -e` runs the statements locally and remotely; the
  table, kv and jsonl renderings show the page state.
- GQT: the three step kinds are withdrawn. Branch reads are `--- query`
  rows steps without a shape section, taking a bound `${name}` in a quoted
  side and in expected rows; the four graph_diff cases use them on the
  engine, DST and served targets.
- RFC 0030 §8.5, RFC 0045, RFC 0055, user docs, the changelog note and
  openapi.json follow.
…costs

# Conflicts:
#	crates/omnigraph-server/src/handlers.rs
…uest-costs

# Conflicts:
#	crates/omnigraph-cli/src/cli.rs
#	crates/omnigraph-cli/src/main.rs
#	crates/omnigraph-server/src/handlers.rs
#	crates/omnigraph/src/db/omnigraph.rs
#	crates/omnigraph/tests/forbidden_apis.rs
#	crates/omnigraph/tests/integration/changes.rs
Comment thread crates/omnigraph-server/src/handlers.rs Outdated
Comment thread crates/omnigraph/src/db/omnigraph.rs Outdated
Comment thread changelog.d/cli-change-pages-at-the-maximum.performance.md Outdated
Comment thread crates/omnigraph/src/db/omnigraph.rs Outdated
Comment thread crates/omnigraph-server/src/handlers.rs Outdated
Comment thread docs/user/branching/changes.md Outdated
@cursor

cursor Bot commented Oct 14, 2026

Copy link
Copy Markdown

Review of 81ac9d1a — recommendation: request changes (1 blocking finding)

This review is posted as a comment because this account cannot set a formal review state. Please read it as "changes requested".

What this PR does, and the impact

  • Why: a graph diff paid for lookups it did not need. A branch-name side took the commit lookup's miss path, which checks out every live branch's __manifest. /diff also resolved each side twice, once for authorization and once for the read. The CLI followed pages of 1,000 changes, and each page restarts its comparison.
  • What and how: the commit lookup is skipped for any spelling is_valid_graph_commit_id refuses. prepare_diff_page / run_diff_page give /diff one resolution for authorization and for the read. The CLI asks for 8,192 changes per page when it follows every page.
  • Impact: a diff between two branch names reads a flat number of __manifest objects (5 with 1 idle branch and with 9). That part is correct and well guarded. But the new order in /diff changes what a forbidden side reveals. Details below.

Blocking

  1. /diff is an existence oracle for a forbidden from side (handlers.rs line 4134, engine side at omnigraph.rs line 2951). Both sides are now resolved before either is authorized. from=feature&to=no-such-branch names no-such-branch when feature exists, and names from when it does not. On main the route answered 'feature' names no branch and no commit in both cases. The same order opens a forbidden commit's snapshot before the policy check. The fix and a regression test are in the inline comment.

Should fix in the same change

  1. The BranchNotFound side guess names the wrong side (handlers.rs line 4129). For a commit on a deleted branch, from=<commit>&to=main answers 'main' names no branch and no commit, which is false. Map the error where the side is known. Per-side authorization in the engine removes this arm.

Optional

  1. Docs wording and changelog: each page restarts its comparison; it does not resume it. The changelog also omits diff and branch diff.
  2. Merge note for #990: fix(policy)!: refuse branch names that are not canonical #990 takes sides as written. The new trim() copies then follow a different rule from the classifier.

Checked and correct

  • Spelling gate premise. is_valid_graph_commit_id is the one rule for public commit ids. Storage upgrade refuses a non-canonical id (omnigraph-catalog/src/history.rs, canonical_ulid → legacy_error). Normal open serves only stamps 14–16. So no stored commit has a spelling the gate skips. Lance branch names allow every character of a ULID or an hb1 address, and such a branch name still gets both lookups. So AmbiguousReadTarget still works. The malformed hb1. case now gives a typed 404, not a 500. This is an improvement.
  • CLI page size. Only the follow-every-page loops change; --page-token passes limit as given. 8,192 is also the ceiling in v0.12.0 and v0.13.0, so a new CLI against an older server is safe. The 4 MiB default byte budget still bounds each page.
  • Cost gate. It is a real guard. With the spelling gate disabled locally, it fails at 15 → 31 __manifest reads. With the gate, it passes at 5 → 5.

Tradeoffs and liability

  • Spelling gate: low liability. One function decides what a commit id can look like, and minting, upgrade and this lookup all use it. A future id format must be added there first, which is already true today.
  • Two-phase public API: medium liability as written. prepare_diff_page + run_diff_page move the rule "authorize each side before you read or resolve more" out of the engine. Each transport must now apply it: the HTTP route, the GQ branch diff statement, and the MCP read tools in feat(server): schema and ad-hoc read tools on /mcp, metadata at the advertised path #993/feat(server): /mcp for every credential source, read parity, resources and opt-in writes #994. After five more transports, five call sites must get the order right, and finding 1 shows that this is easy to get wrong. A read predicate passed into the engine keeps the rule in one place, and keeps this PR's cost win.
  • CLI ceiling coupling: low. The CLI sends the server's compile-time ceiling. If a later server lowers it, followed pages fail with 413 rather than degrade. This is acceptable today.
  • Not in scope, still open: a commit-id side still pays the per-branch fanout on a miss (performance: a commit lookup that misses the current branch checks out every live branch's __manifest #972). A continuation reclassifies both sides on every page, so it pays that fanout once per page. The larger CLI pages reduce that count. No Lance API changes here; the fanout is OmniGraph's record_in_live_branches, not Lance branch behavior.

Validation

Clean detached worktree at 81ac9d1a464b0fbfb4f339fe1c8c017f70b88de6, merge base ff80af2fbca81ceb396faae8f1d58931442e6c01, RUST_MIN_STACK=16777216.

Command Result
engine --test integration changes:: changes_cost:: 64 passed, 0 failed, 1 ignored (an existing repair ignore)
changes_cost::graph_diff_side_resolution_is_flat_in_live_branches --nocapture manifest_reads 5 → 5 (1 → 9 idle branches)
same test, with the spelling gate disabled locally (reverted) fails: 15 → 31
server --test integration auth_policy:: data_routes:: openapi:: 224 passed, 0 failed
temporary /diff probe test (not pushed), run at head and at merge base results in the inline comments
cargo fmt --all --check pass
check-docs.py (pinned venv), typos 1.50.1, check-agents-md.sh pass (269 Markdown files)
CI on 81ac9d1a all checks green, including Test Workspace, Lint (clippy), GQ Logic Tests and CI Gate

Limitations: I did not run CLI parity_matrix:: / cli_data::, served GQT, forbidden_apis or workspace clippy locally; I relied on CI for those. I did not run the S3 or Azure suites. I did not measure the 19.5 s → 7.3 s CLI timing.

`/diff` resolved both sides before it authorized either, so with a
forbidden `from` an unknown `to` answered first and the error named
whichever side the policy hid. The engine now takes the caller's read
check (`diff_page_readable`) and authorizes each two-point side as soon
as it is classified, before the next side is looked at or a snapshot
opens; a refused side is the not-found of a side that names nothing. A
continuation authorizes its sides again before it reopens the token's
commits. The two-phase `prepare_diff_page` / `run_diff_page` API is
gone, so no transport has to get the order right.

A diff endpoint commit whose writing branch is gone is the not-found of
the spelling that named it, mapped where the engine knows that
spelling, instead of the transport guessing a side from a branch name.
One helper decides a side's spelling.

The CLI page-size note and the changes guide say that every page
restarts its comparison, and the note names `diff` and `branch diff`.
…uest-costs

# Conflicts:
#	crates/omnigraph/src/db/omnigraph.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant