Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ Each rule's full story (why, traps, tests that pin it) is in the named doc.
- **The log is paged** — `s.commits` is a prefix of history, never the answer
to "does X exist / is X an ancestor"; ask the backend.
(`docs/dev/frontend.md`)
- **…and the backend prepares ONE walk for all those pages** (`git/log_cache.rs`,
#473). A sorted libgit2 revwalk materialises the whole ordered list before it
yields anything, so a walk per page cost the same per page — 157 s at page ten
of the kernel. Everything cached there is derivable from disk, so it must stay
a PURE ACCELERATOR: a lock failure is a miss and never a failed page, and the
proof is `log_walk_cache.rs` draining the same history warm and cold and
comparing. A first page is invalidated by its start oids, a continuation by
nothing (commits are immutable), the decorations by a ref fingerprint — add a
third cached thing and it needs its own answer to "what makes this wrong?".
(`docs/dev/backend.md`, `docs/dev/performance.md`)
- **A commit row's columns have a YIELD ORDER, and it is the template.** Every
track in `commitRowGrid` but the subject and the author is a fixed width, so
a new fixed column — or a wider one — comes straight out of the subject,
Expand Down
19 changes: 19 additions & 0 deletions docs/dev/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,25 @@ git/
│ write still excludes everything. Carries its own unit tests
│ with a fake handle, proving overlap by barrier rather than by
│ wall clock. See backend.md
├── log_cache.rs What `log_page` used to rebuild on every page (#473): the
│ prepared WALK and the REF MAP. libgit2's topological sort is
│ not incremental — setting a sort order sets `walk->limited`, so
│ `prepare_walk` traverses the whole reachable graph and
│ materialises the complete ordered list before yielding one oid,
│ which is why 500 commits and 1.5 M cost the same and why a walk
│ per page paid that price per page (page 10 of the kernel: 157
│ s). So the walk is prepared once, kept as a `Vec<Oid>` capped at
│ MAX_ORDER, and later pages are slices. Two different
│ invalidations on purpose: a FIRST page is keyed by (refspec,
│ start oids) so a moved ref misses, a CONTINUATION is keyed by
│ the frontier it was emitted with and consults no refs at all
│ (the set a frontier reaches is commits, and commits are
│ immutable). The ref map is keyed by a fingerprint of every ref
│ name + target, so a `git tag` in a terminal invalidates it like
│ one made in the app. PURE ACCELERATOR: a poisoned mutex is a
│ miss, never a failed page, and `close` drops the lot. Carries
│ its own unit tests; `tests/log_walk_cache.rs` holds the
│ transparency property end to end. See backend.md, performance.md
├── update_refs.rs Stacked branches (#240) — git's `rebase --update-refs`,
│ IMPLEMENTED not passed through, because our rebase is our own
│ libgit2 replay with no `git rebase` process to hand a flag to.
Expand Down
107 changes: 107 additions & 0 deletions docs/dev/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,113 @@ walk a `Vec<RefInfo>` per commit — `{ name, kind }`, where `kind` is
- `src-tauri/tests/log_decoration.rs` pins all of it, including a tag and a
branch of the same name staying two distinct decorations.

`collect_ref_map` no longer runs per page — see below.

## The paged log prepares ONE walk (#473)

`log_page` used to build a fresh revwalk per page. That reads like an obvious
design until you look at what libgit2 does with a sort order:

- `git_revwalk_sorting` sets `walk->limited` for ANY sorting but `NONE`, so
`prepare_walk` runs `limit_list` over the entire reachable graph;
- `sort_in_topological_order` then materialises the **complete** ordered list,
and only afterwards does `git_revwalk_next` start popping it.

So asking that walk for 500 commits and asking it for all 1,482,923 of them
cost the same thing, and one walk per page paid that price per page. The
benchmark measured it as a per-page cost *flat in depth*: on `torvalds/linux`
page one was 15.95 s and page ten 157.67 s — ten times one page. git pays for
the same sort once and then `--skip`s.

`git/log_cache.rs` keeps the finished sort instead. A walk is prepared once,
its output kept as a `Vec<Oid>` capped at `MAX_ORDER` (100,000 — two hundred
pages, 2 MB), and every later page is a slice of it. The cursor contract is
unchanged: `next_cursor` is still the frontier, still self-describing, and a
cursor the cache cannot place still prepares its own walk.

**There are two invalidation stories and they are deliberately different.**

- A **first page** is keyed by `(refspec, start oids)`. Any ref that moves
changes an oid in the key, so the next first page misses and rebuilds. That
is the whole mechanism — there is no write hook to forget to call.
- A **continuation** is keyed by the frontier it was emitted with, and consults
no refs at all. It does not need to: resuming from a cursor has always
ignored `refspec` (`push_page_start`), and the set a frontier reaches is made
of commits, which are immutable. New history arrives as CHILDREN of what is
there, never inside that set. So a walk prepared ten minutes ago has the same
tail today.

A cursor is placed by **exact match against the frontier we emitted**, never by
"find an order containing these oids". Two walks over one repository share
commits, so containment would happily resolve an `--all` cursor inside a
single-branch walk and continue the wrong history.

**The ref map goes the same way**, and it was worth as much: `collect_ref_map`
enumerated and peeled every ref on every page to decorate 500 rows — 16× git's
own work on a repository with 7,001 refs and 2,000 commits. It is now cached
against a `ref_fingerprint`: one enumeration of every ref's name and target
with **no peeling**, combined commutatively so enumeration order cannot change
it. A fingerprint rather than a write hook, because a `git tag` typed in a
terminal has to invalidate it exactly like one made in the app; and rather than
an mtime, because loose refs live in nested directories that no single
timestamp covers. `commits_since` and `commits_between` read the same cache.

**It is a pure accelerator, and that is a rule, not a description.** Everything
held is derivable from disk, a poisoned mutex degrades to a miss instead of
failing the page, and `close` drops the repository's entries. `tests/
log_walk_cache.rs` holds it down by draining the same history twice — through
one backend where the cache is hot, and through a fresh backend per page where
it can never hit — and comparing the two. Those tests pass just as well against
a cache that never hits, which is why the file also asserts on
`log_cache_stats()`: eleven pages must prepare exactly one walk and build
exactly one ref map, and a commit must make the next first page prepare a
second. All four failure modes were planted and watched go red.

**One behaviour is now visible that was always there.** When two commits share
a *second*, which lane comes out first is not something either walk promises:
libgit2 orders the topological queue by time through a `git_pqueue` whose
comparator is not stable, so the answer depends on insertion order — and a walk
restarted from a cursor inserts differently from one that ran straight through.
The walk-per-page version had the same freedom and used it at every page
boundary; one prepared walk is merely self-consistent where ten were not. What
holds either way is the actual contract — every commit exactly once, no parent
before its child — and that is what the same-second fixture asserts.

**The filtered walk reads the same order.** `log_filtered_page` is commit
search, and it had both defects for the same reason and in the same two lines.
It is also where they hurt most: a search that matches nothing recent walks a
long way before it fills a page, and the next page threw that walk away and
sorted all of history again.

It cannot go through `page_plan`, and the reason is worth keeping. A filtered
page visits far more commits than it returns, so it can run off the end of an
order capped at `MAX_ORDER` in the middle of a page — and a short page there is
indistinguishable, to the user and to the frontend, from "no more matches
exist". So `cached_filtered_plan` asks `WalkOrder::serves` with `usize::MAX`,
which admits any offset into a COMPLETE order and no offset at all into a capped
one; a miss falls back to the revwalk-per-page the filtered log always had. A
history past the cap is therefore no worse off than before #473. Filling the
page from the cached prefix and then continuing into a live walk is the version
that would help there too, and it needs a frontier the builder can hand back
mid-page.

It must also not PREPARE a walk to find that out: preparing one and then
discovering it is capped pays for the topological sort twice, on exactly the
repositories where that sort is most expensive. `LogCache::has_walk` answers
"has this walk been prepared before" without counting a hit or moving anything,
which is a different question from `first_page`'s "can this serve the page in
hand". The one time a filtered page does prepare and file a walk is when nothing
is filed for that key at all — the page was going to pay for a walk anyway, and
a search should not depend on the log having been read first to be fast, even
though in the app it always has been.

Both sources run one `matches` closure, through the generic `filtered_page`,
because a search that returned different matches depending on whether a walk
happened to be cached is the one bug this change must not have. The frontier it
emits is filed against an offset ONLY when the page came out of a cached order:
a page that walked for itself ends somewhere no order has an index for, and
filing that offset would put the next page at the wrong depth.

## Reading the log (#274)

Where it is — `tauri_plugin_log`'s `LogDir` target, i.e. Tauri's `app_log_dir`:
Expand Down
85 changes: 72 additions & 13 deletions docs/dev/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ not a good answer, and publishing it is the point of the exercise: the user who
opens a 1.4-million-commit repository and waits is the user this project was
written for.

### 2. The log walk is not slow — the topological SORT is, and it is re-paid per page
### 2. The log walk is not slow — the topological SORT is, and it was re-paid per page

The obvious reading of "our 500-commit page takes 252 ms and `git log -500`
takes 41 ms" is that the walk is six times too slow. It is wrong, and it nearly
Expand All @@ -179,34 +179,93 @@ not sort that way. Asked the same question, `git log --topo-order -500` costs
this benchmark are `--topo-order` for exactly that reason, and the near miss is
written up in the spec.

What survives is sharper. git pays for that sort **once and then skips**; we pay
it per page:
What survived was sharper. git pays for that sort **once and then skips**; we
paid it again on every page:

| | `deep` (50k commits) | `torvalds/linux` (1.5M commits) |
| --- | --- | --- |
| our first page | 252 ms | 15.95 s |
| our tenth page | 2.40 s | 157.67 s |
| `git log --topo-order --skip=4500 -500` | 193 ms | 9.68 s |

The per-page cost is flat in depth — ten pages cost ten times one page — which
is the signature of restarting the walk rather than continuing it. The cursor
already carries the frontier, so the walk logically continues; it is the sort
that is rebuilt.
The per-page cost was flat in depth — ten pages cost ten times one page — which
is the signature of restarting the walk rather than continuing one.

### 3. The ref map is rebuilt on every page, too
#### The measurement the fix rests on

`log_page` calls `collect_ref_map(repo)` per call, enumerating and peeling every
ref so the page can decorate its 500 commits. Two fixtures isolate it, and the
Fixed in #473 by preparing the walk once and paging out of what it produced.
The reason that is affordable is one measurement, and it is worth recording
because it is the opposite of what "just cache it" usually costs. Draining a
revwalk that has **already been prepared** is very nearly free, because libgit2
does the ordering during preparation and then hands commits out of a list it
already has:

| | prepare + take(500) | drain the whole rest of the order |
| --- | --- | --- |
| `deep` (50,000 commits) | 560.2 ms | **1.9 ms** (49,500 more) |
| `torvalds/linux` (1,482,923 commits) | 31.8 s | **63.4 ms** (1,482,423 more) |

0.2% more buys the entire order, which is why the cache holds a `Vec<Oid>`
rather than the prepared `Revwalk` — holding the walk would mean holding a
`git2::Repository` alive between IPC calls, outliving the lock acquisition
`git/repo_locks.rs` orders every access by, to save that 0.2%. The source-level
reason it comes out this way is in `src-tauri/src/git/log_cache.rs`:
`git_revwalk_sorting` sets `walk->limited`, so `prepare_walk` runs `limit_list`
over the whole reachable graph and `sort_in_topological_order` materialises the
complete ordered list before the first oid is yielded.

**The tables below have not been re-measured for that change**, and the numbers
in this section are the "before" they always were. Two sessions were benchmarking
this repository at once, and `$PGBENCH_HOME` is one shared directory: control
rows moved 25–30% on operations neither change touches (`wide` status 5.38 s →
3.92 s, `diff_commit` 1.53 s → 1.12 s), which is not a result, it is two
programs sharing a machine. Re-measure on a quiet machine, with one session
running, before publishing an after.

And re-measure **all four fixtures**, or knowingly keep the rest. The renderer
publishes every result it finds in `$PGBENCH_HOME/results`, which is deliberate
— it is what stops `pnpm bench --fixture deep` from silently deleting
`torvalds/linux` from the record — but it also means a one-fixture run
publishes fresh numbers for that fixture and whatever happens to be sitting
beside it. `test/benchmark.test.ts` cannot catch that: it checks the markdown
against the JSON, and both would be wrong together.

### 3. The ref map was rebuilt on every page, too

`log_page` called `collect_ref_map(repo)` per call, enumerating and peeling every
ref so the page could decorate its 500 commits. Two fixtures isolate it, and the
variable between them is refs rather than history:

| fixture | history | refs | ours | `git` work | ratio |
| --- | --- | --- | --- | --- | --- |
| `deep` | 50,000 commits | 1 | 252 ms | 194 ms | 1.3× |
| `refs` | 2,000 commits | 7,001 | 135 ms | 8.5 ms | **16×** |

Twenty-five times *less* history, and still most of the cost. This one looks
much cheaper to fix than the sort: cache the map per repository and invalidate
it on ref writes.
Twenty-five times *less* history, and still most of the cost.

Fixed in #473 alongside the walk, and two things found on the way are worth
keeping, because both are counter-intuitive:

* **The peeling is not the expense.** This issue was written as "enumerating and
peeling every ref"; measured on `refs`, one enumeration costs 113 ms and the
peel inside it costs 10 ms. So the map is validated on a FIRST page and reused
by the continuations behind it — revalidating per scroll cost 113 ms of a
117 ms page, for a question that cannot have changed.
* **Do not compute a fingerprint before building the map on a cold cache.** It
enumerates every ref twice for an answer that cannot match anything, and it
made a cold eleven-read fan-out on `refs` *slower* than the code it replaced
(219 ms → 364 ms). It hides, too: `open_screen` builds a fresh backend per
sample, so that path is always the cold one. `collect_ref_map` returns the
fingerprint from its own pass for this reason.

The same two defects were in `log_filtered_page`, which is commit search — the
surface where they hurt most, because a search that matches nothing recent walks
a long way before it fills a page, and the next page threw that walk away. It
reads the same prepared order now. One limit is deliberate and written down in
`cached_filtered_plan`: a search visits far more commits than it returns, so
only an order that is ALL of history can serve one, and a repository past
`MAX_ORDER` falls back to the walk-per-page it always had rather than to a short
page that would look like "no more matches exist".

### What is already good, and worth not breaking

Expand Down
Loading
Loading