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
22 changes: 21 additions & 1 deletion docs/internal/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,32 @@ is a bug factory.
| Cache | Key | Value | Invalidator | Bound |
|---|---|---|---|---|
| `repos/git.AheadBehindCached` | (repo_id, base_oid, head_oid) | (ahead, behind) | OID change ⇒ different key; LRU eviction | 4096 entries |
| `repo/treecache` last-commits | (repo_id, commit_oid, subpath) | basename → last `Commit` | OID change ⇒ different key; 10 min TTL; LRU | 2048 entries |
| `repo/treecache` commit count | (repo_id, commit_oid) | `rev-list --count` int | same | 2048 entries |
| `repo/treecache` languages | (repo_id, commit_oid) | language → bytes (from `ls-tree -r`) | same | 512 entries |
| `repo/treecache` contributors | (repo_id, commit_oid) | author tally (from `log -n 500`) | same | 512 entries |

Concrete uses:
- `branchesList` (S20 deferral H4) — replaces N `git rev-list`
invocations per page load with one cached lookup per branch.
Single-flight collapses concurrent misses on hot branches.
- The repo home / code tab (`internal/web/handlers/repo/treecache`,
built once in `internal/web/repo_wiring.go`, threaded through
`repo.Deps.TreeCache`). It is the most-crawled page on the site and
`meta-externalagent` walks it anonymously; before this cache one
cold render of an 81-entry directory forked git 90 times. It now
forks 10 times cold and 6 warm, independent of entry count. See
`docs/internal/code-tab.md`.

All four `treecache` entries are keyed on the **rendered commit OID**,
so there is no invalidation hook to wire and none to forget: a push
moves the ref, the OID changes, the new key misses, and the pre-push
entries age out on TTL and LRU eviction. The 10-minute TTL exists to
release memory from repos that stop being visited, not for
correctness. The heavier language and contributor caches take
capacity/4 slots because their values are not uniformly sized.
Sizing rationale and worst-case memory live in the package doc
comment.

## Planned caches (next iterations)

Expand All @@ -27,7 +48,6 @@ back grow large enough to bench-justify the cache.

| Cache | Key | Value | Invalidator |
|---|---|---|---|
| Tree at root | (repo_id, ref_oid) | rendered ls-tree result | push:process bumps default-OID |
| Ref list | (repo_id) | branches + tags | push:process |
| File list (finder) | (repo_id, ref_oid) | flat path slice | push:process |
| Default-branch OID | (repo_id) | OID string | push:process + default-branch swap |
Expand Down
118 changes: 96 additions & 22 deletions docs/internal/code-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,50 @@ default Code tab so independently-created mirrors don't produce dead
links. Unknown, external, absent, or malformed remotes stay as plain
`name @ shortsha` rows.

The S17 ship excludes the htmx-driven "last commit per entry" column
that the spec describes — an extra round-trip we can add later without
a schema change. The current page renders the listing immediately.
**Deferred to S18 (commits-per-entry)** — the spec calls out this
deferral path; the tree template has the column slot ready.
### Last commit per entry

Every row carries the most recent commit that touched it. The first
implementation ran one `git log -1 -- <path>` per entry, serially, so
a 100-entry directory forked git 100 times for a single anonymous
page view — and the repo home is the most-crawled page on the site.

It is now **one** invocation for the whole directory
(`repogit.EntryLastCommits`, `internal/repos/git/lastcommit.go`):

```
git log --max-count=2000 --name-only --no-renames \
--format=<RS>%H<US>%h<US>%an<US>%ae<US>%at<US>%s <oid> [-- <dir>]
```

read in reverse-chronological order off a pipe. The first time a path
under `dir/<name>/` appears, `<name>`'s answer is that commit. Once
every listed entry has an answer the walk kills git mid-stream rather
than draining the rest of history, so the common case reads only as
far back as the directory's least-recently-touched entry.

Two escape hatches keep the rendered output byte-identical to the
N-fork version:

- **The 2,000-commit bound.** An entry untouched inside the bound
comes back unresolved and the handler runs the old per-path
`git log -1` for exactly that entry.
- **Quoted paths.** Git quotes paths containing a newline or a double
quote even under `core.quotePath=false`; those never compare equal
to an `ls-tree` basename, so they too fall through to the per-path
query.

Merge commits emit no file list under `--name-only`, which matches
what `git log -1 -- <path>` reports anyway: the attribution lands on
the side-branch commit that actually changed the file.

Measured on the handler test fixture
(`code_tree_forks_test.go`), one cold anonymous render of the root
tree:

| Directory | Before | After (cold) | After (warm cache) |
|---|---|---|---|
| 6 entries | 15 forks | 10 forks | 6 forks |
| 81 entries | 90 forks | 10 forks | 6 forks |

## Check status indicators

Expand Down Expand Up @@ -262,17 +301,58 @@ the floor S17 commits to.

## Caching

Currently **no caching layer**. Every request runs `git for-each-ref`,
`git ls-tree`, etc. That's fine for small-to-medium repos; the cost
shows up on big repos with deep trees. The S17 spec proposes a cache
keyed on `(repo_id, ref_oid, dir_path)` invalidated on push (S14's
`push:process` job is the right invalidation hook).

**Deferred** — the cache is purely performance polish. When we hit a
real-world repo where it matters, wire it in: file `internal/cache/`
plus a callback in `worker/jobs/push_process.go`. The handlers already
take a per-request `policy.Cache` so adding a per-process git cache is
mechanically straightforward.
`internal/web/handlers/repo/treecache` is the in-process cache behind
the repo home / code tab. One `*treecache.Cache` per process, built in
`internal/web/repo_wiring.go` and threaded through
`repo.Deps.TreeCache` — the same shape as `httpcache.PageCache`. It
memoizes the four git reads a tree render performs whose answers
depend only on the commit being rendered:

| Value | Key | Replaces |
|---|---|---|
| basename → last commit | (repo_id, commit_oid, subpath) | the single `log --name-only` walk |
| commit count | (repo_id, commit_oid) | `rev-list --count` |
| language → bytes | (repo_id, commit_oid) | recursive `ls-tree -r` |
| author tally | (repo_id, commit_oid) | `log -n 500` |

**Invalidation is structural, not hooked.** Every key carries the
rendered commit OID, so a push produces a different key; the pre-push
entries are never served again and age out on the 10-minute TTL or LRU
eviction. Nothing in `push:process` has to remember to call anything.
Sizes: 2,048 entries for the two cheap caches, 512 for the two heavier
ones. `nil` disables the cache entirely (tests, degraded boot) and
every read simply falls through to git.

Only the *derived* values are cached, never the raw walk output: the
recursive `ls-tree -r` on a large repo is megabytes of path text but
reduces to a handful of language byte counts, and the 500-commit
contributor walk reduces to one row per distinct author. Identity
resolution stays per-request — it reads the users table, and its
answer can change without any git ref moving.

Two things are deliberately still uncached per request: `for-each-ref`
(the ref list is what resolves the URL in the first place, so there is
no OID to key on yet) and `ls-tree` for the directory itself (cheap,
and it is what produces the entry names the other caches are keyed
against).

### Read deadlines

Every read-only git invocation on these paths runs under
`repogit.ReadTimeout` (30 s, `internal/repos/git/exec.go`).
`context.WithTimeout` keeps the earlier of the two deadlines, so a
tighter request deadline still wins. Blob *streaming* is excluded —
its duration is bounded by the client's download speed, not by git.
Pack/transport (`internal/git/protocol`, `handlers/githttp`) is a
separate path and is untouched.

### Counting git forks

`repogit.ForkCount()` is a process-wide counter incremented by the one
helper every subprocess in `internal/repos/git` goes through. It is
the measurement lever for this page: tests read it before and after a
request and assert the delta is constant in the entry count rather
than linear in it.

## Pitfalls + protections

Expand Down Expand Up @@ -302,12 +382,6 @@ mechanically straightforward.

These items are spec deliverables we ship in a later pass:

* **Last-commit-per-entry column** with htmx lazy load and pre-walked
`git log --name-status` cache → wire into S18 (commit history) where
the same walk powers the per-file history page.
* **Tree caching keyed on (repo_id, ref_oid, dir_path)**, push-event
invalidation → wire into S36 (performance pass) once we have a real
workload to measure.
* **Pagination at 1000 entries per directory** → cosmetic for huge
trees; add when someone hits `node_modules`-grade inflation.
* **Encoding detection for non-UTF-8 source files** → file reads are
Expand Down
15 changes: 12 additions & 3 deletions docs/internal/retro/2026-09-02-availability-sitrep.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,18 @@ The verification items below are still the operator's.
bulk DELETE in a migration. 0129 adds the `day` indexes the purge
needs (`repo_traffic_uniques` reuses its existing `created_at`
index). See `docs/internal/repository-insights.md`.
- [ ] Cache per-entry last-commit for the code tab (single
`git log --name-only` walk, or an LRU keyed by tree OID) and
cache `rev-list --count` / recursive `ls-tree` per head OID
- [x] Cache per-entry last-commit for the code tab — one streamed
`git log --name-only` walk per directory
(`repogit.EntryLastCommits`) replacing one `git log -1` per
entry, plus an OID-keyed LRU
(`internal/web/handlers/repo/treecache`) over that, the
`rev-list --count`, the `ls-tree -r` language aggregate and the
`log -n 500` contributor tally. Measured on the handler test
fixture: a cold root-tree render of an 81-entry directory went
**90 git forks → 10**, and 6 warm; a 6-entry directory 15 → 10.
Fork counts are now constant in the entry count
(`repogit.ForkCount()` + `code_tree_forks_test.go`). Read-only
git calls on these paths also gained a 30 s deadline.
- [x] `actionsobserver`: the `octet_length` sum now runs every 5 min on its
own cadence; the count and queue-depth gauges stay at 15 s

Expand Down
3 changes: 1 addition & 2 deletions internal/repos/git/blame.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -83,7 +82,7 @@ func Blame(ctx context.Context, gitDir string, opts BlameOptions) ([]BlameChunk,
return nil, ErrBlameTooLarge
}

cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
cmd := gitCmd(ctx, "-C", gitDir,
"blame", "--line-porcelain", opts.Ref, "--", opts.Path)
stdout, err := cmd.StdoutPipe()
if err != nil {
Expand Down
18 changes: 9 additions & 9 deletions internal/repos/git/branchops.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// When base or head doesn't exist on the repo we surface the typed
// ErrRefNotFound so callers can render "—" instead of a number.
func AheadBehind(ctx context.Context, gitDir, base, head string) (ahead, behind int, err error) {
cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
cmd := gitCmd(ctx, "-C", gitDir,
"rev-list", "--left-right", "--count", base+"..."+head)
out, runErr := cmd.Output()
if runErr != nil {
Expand Down Expand Up @@ -50,8 +50,8 @@ func CommitsBetween(ctx context.Context, gitDir, base, head string, max int) ([]
const sep = "\x1f"
const recordEnd = "\x1e"
format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%at", "%s"}, sep) + sep + "%b" + recordEnd
cmd := exec.CommandContext(
ctx, "git", "-C", gitDir,
cmd := gitCmd(
ctx, "-C", gitDir,
"log",
"--max-count="+strconv.Itoa(max),
"--format="+format,
Expand All @@ -78,7 +78,7 @@ func CommitsBetween(ctx context.Context, gitDir, base, head string, max int) ([]
// passing it gives git's exact-match semantics (no off-by-one
// races even when oldvalue happens to equal newvalue).
func UpdateRefCAS(ctx context.Context, gitDir, ref, newOID, oldOID string) error {
cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
cmd := gitCmd(ctx, "-C", gitDir,
"update-ref", ref, newOID, oldOID)
out, err := cmd.CombinedOutput()
if err == nil {
Expand Down Expand Up @@ -107,7 +107,7 @@ func DeleteBranch(ctx context.Context, gitDir, branch, oldOID string) error {
if branch == "" || strings.HasPrefix(branch, "-") {
return ErrRefNotFound
}
check := exec.CommandContext(ctx, "git", "-C", gitDir, "check-ref-format", "--branch", branch)
check := gitCmd(ctx, "-C", gitDir, "check-ref-format", "--branch", branch)
if out, err := check.CombinedOutput(); err != nil {
return fmt.Errorf("check-ref-format %s: %w (%s)", branch, err, strings.TrimSpace(string(out)))
}
Expand All @@ -116,7 +116,7 @@ func DeleteBranch(ctx context.Context, gitDir, branch, oldOID string) error {
if strings.TrimSpace(oldOID) != "" {
args = append(args, oldOID)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd := gitCmd(ctx, args...)
out, err := cmd.CombinedOutput()
if err == nil {
return nil
Expand All @@ -143,7 +143,7 @@ func DeleteBranch(ctx context.Context, gitDir, branch, oldOID string) error {
// refspec just updates the dst ref.
func FetchIntoNamespace(ctx context.Context, dstRepoDir, srcRepoDir, srcRef, dstRef string) error {
refspec := srcRef + ":" + dstRef
cmd := exec.CommandContext(ctx, "git", "-C", dstRepoDir,
cmd := gitCmd(ctx, "-C", dstRepoDir,
"fetch", "--quiet", "--no-tags", srcRepoDir, refspec)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("fetch %s into %s: %w (%s)", srcRef, dstRef, err, strings.TrimSpace(string(out)))
Expand All @@ -155,7 +155,7 @@ func FetchIntoNamespace(ctx context.Context, dstRepoDir, srcRepoDir, srcRef, dst
// Used by the pre-receive force-push detector: a fast-forward is
// `IsAncestor(old, new)`.
func IsAncestor(ctx context.Context, gitDir, a, b string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
cmd := gitCmd(ctx, "-C", gitDir,
"merge-base", "--is-ancestor", a, b)
err := cmd.Run()
if err == nil {
Expand All @@ -174,7 +174,7 @@ func IsAncestor(ctx context.Context, gitDir, a, b string) (bool, error) {
// SetSymbolicRef updates HEAD (or any other symbolic ref) atomically.
// Used by the default-branch change to point HEAD at the new branch.
func SetSymbolicRef(ctx context.Context, gitDir, ref, target string) error {
cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
cmd := gitCmd(ctx, "-C", gitDir,
"symbolic-ref", ref, target)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("symbolic-ref %s -> %s: %w (%s)", ref, target, err, out)
Expand Down
60 changes: 60 additions & 0 deletions internal/repos/git/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package git

import (
"context"
"os/exec"
"sync/atomic"
"time"
)

// Every git subprocess this package spawns goes through gitCmd so we
// have exactly one place that (a) counts forks and (b) can grow
// process-wide policy later (nice level, env scrubbing, tracing).
//
// The counter is the measurement lever for the code-tab work: a page
// that forks git O(entries) times shows up as a linear ForkCount
// delta, and the tests assert the delta is constant instead.
var forkCount atomic.Uint64

// ForkCount reports the cumulative number of git subprocesses this
// process has started through this package. It only ever increases;
// callers that want a per-operation number read it before and after
// and subtract. Exported for tests and the /metrics surface.
//
// Note the pack/transport paths (internal/git/protocol,
// internal/web/handlers/githttp) run their own `git upload-pack` /
// `receive-pack` processes and are deliberately NOT counted here —
// they are long-lived streams, not the short read forks this counter
// is about.
func ForkCount() uint64 { return forkCount.Load() }

// ReadTimeout bounds a single read-only git invocation on a request
// path. Reads on this side of the package are local-disk object
// lookups: `ls-tree`, `rev-list --count`, `log`, `cat-file`. On the
// production box the slowest of these is a full recursive `ls-tree`
// on the largest repo, measured in tens of milliseconds; 30s is three
// orders of magnitude of headroom and exists purely so a wedged or
// pathological invocation cannot pin a request goroutine (and its
// git subprocess) forever when a crawler is walking every SHA.
//
// Blob streaming (StreamBlob) is deliberately excluded: its duration
// is bounded by the client's download speed, not by git.
const ReadTimeout = 30 * time.Second

// gitCmd builds a git subprocess rooted at args and bumps ForkCount.
func gitCmd(ctx context.Context, args ...string) *exec.Cmd {
forkCount.Add(1)
// gitDir is RepoFS-validated at every call site and every
// user-controlled value is an argv element, never a shell string.
return exec.CommandContext(ctx, "git", args...)
}

// readCtx derives a deadline-bounded context for a read-only git
// invocation. context.WithTimeout keeps the earlier of the two
// deadlines, so a caller with a tighter request deadline still wins.
// Callers must defer the returned cancel.
func readCtx(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, ReadTimeout)
}
Loading
Loading