vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 16m ago
4b8c9e1d
| 1 | # Load testing |
| 2 | |
| 3 | Vole ships a load harness that drives a real instance on a loopback port with a |
| 4 | temporary data directory. It runs the same server code an operator runs, behind |
| 5 | the `load` build tag so `go test ./...` and the verify workflow never execute it. |
| 6 | |
| 7 | Use it to answer a question with a number: did this change make a route faster, |
| 8 | and where does it stop? The limits in [Configuration](configuration.md) were |
| 9 | chosen from reasoning first; a run here is what replaces that with measurement. |
| 10 | |
| 11 | ## One command |
| 12 | |
| 13 | Every route: |
| 14 | |
| 15 | ```sh |
| 16 | VOLE_LOAD_SCENARIO=all VOLE_LOAD_CONCURRENCY=4 VOLE_LOAD_DURATION=5s \ |
| 17 | go test -tags load,sqlite_fts5 -run '^TestLoad$' -v ./server |
| 18 | ``` |
| 19 | |
| 20 | One route, or clones past the limit: |
| 21 | |
| 22 | ```sh |
| 23 | VOLE_LOAD_SCENARIO=clone VOLE_LOAD_CONCURRENCY=16 VOLE_LOAD_DURATION=30s \ |
| 24 | go test -tags load,sqlite_fts5 -run '^TestLoad$' -v ./server |
| 25 | ``` |
| 26 | |
| 27 | The build needs `sqlite_fts5` beside `libsqlite3` (see |
| 28 | [Getting started](getting-started.md)); `-tags load` on the command line replaces |
| 29 | `GOFLAGS`, so both belong on it. |
| 30 | |
| 31 | ## Knobs |
| 32 | |
| 33 | | Variable | Default | What it does | |
| 34 | |---|---|---| |
| 35 | | `VOLE_LOAD_SCENARIO` | `clone` | `all`, or one of `clone`, `clone-large`, `blob-small`, `blob-limit`, `raw-small`, `raw-limit`, `raw-range`, `overview`, `tree`, `commits`, `compare`, `change`, `issue`, `activity`, `instance-activity`, `home`, `search-code`, `search-find`, `artifact`, `artifact-range`, `site`, `comment`. | |
| 36 | | `VOLE_LOAD_CONCURRENCY` | `4` | Workers driving the scenario in parallel. | |
| 37 | | `VOLE_LOAD_DURATION` | `10s` | How long the workers keep going. | |
| 38 | |
| 39 | ## What it seeds |
| 40 | |
| 41 | Three repositories and a site: |
| 42 | |
| 43 | - `demo`: 300 commits; files at several sizes including one at the configured |
| 44 | `max_blob_bytes` (the harness sets 8 MiB, so the file is one a push could |
| 45 | deliver); an issue with five comments; a change request whose branch carries a |
| 46 | 4000-line diff, with five review comments and an approval by a second account; |
| 47 | a release tag with one artifact in the LFS store; and a `pages` branch served |
| 48 | at `demo.pages.test`. |
| 49 | - `small`: 50 commits and small files, so a clone is quick. |
| 50 | - `large`: 3000 commits and one 16 MiB incompressible blob, so a clone is not. |
| 51 | |
| 52 | `demo` and `large` are read once before the run so their indexes are built |
| 53 | outside the measurement. |
| 54 | |
| 55 | The clone scenario gives every worker a distinct `X-Forwarded-For` address (the |
| 56 | fixture trusts the header on loopback), so the global `git_http_max_concurrent` |
| 57 | is the limit that refuses rather than the per-client one. At the configured |
| 58 | `git_http_max_concurrent` the run stays at `200`; above it, refusals arrive as |
| 59 | `503` with `Retry-After`, and a clone caught by one reports the curl failure. |
| 60 | |
| 61 | ## The one scenario that writes |
| 62 | |
| 63 | Every scenario above reads. `comment` posts a comment as the fixture's writer |
| 64 | account, which is one commit on the metadata branch that every write in the instance |
| 65 | shares: the issue, the change request, the approval, and the release pointer all move |
| 66 | the same ref. It is the scenario to run after touching the write path, and the one |
| 67 | where throughput should stay flat as workers are added while latency grows with the |
| 68 | queue behind it — a rising error count or a falling throughput means writers are |
| 69 | fighting over the branch instead of taking turns. |
| 70 | |
| 71 | ## What it reports |
| 72 | |
| 73 | Per scenario: requests and throughput, latency at p50/p90/p99, HTTP statuses, |
| 74 | failure reasons, and the peak of process RSS, open file descriptors, goroutines, |
| 75 | and git child processes during the run. |
| 76 | |
| 77 | ## What it does not tell you |
| 78 | |
| 79 | - The server runs inside the test process, so RSS and goroutines include the |
| 80 | harness and its clients. |
| 81 | - Child processes are counted by name (`http-backend`, `upload-pack`, |
| 82 | `receive-pack`). The harness's own `git clone` processes are excluded. |
| 83 | - Loopback has no network cost and no TLS. The numbers are a floor and a way to |
| 84 | compare changes, not a prediction of a real deployment. |
| 85 | - A warm cache is not simulated beyond the index build: the first run after a |
| 86 | restart will be slower. |
| 87 | - The `raw-range` and `artifact-range` scenarios send a `Range` header and expect |
| 88 | `206` with only the requested bytes. Before #119 the raw route answered `200` |
| 89 | with the whole file, which is why the scenario exists. |
| 90 | |
| 91 | ## Adding a scenario |
| 92 | |
| 93 | A scenario is a step function and a deadline: each worker calls the step in a |
| 94 | loop until `VOLE_LOAD_DURATION` expires, and the step reports the HTTP status it |
| 95 | saw and any failure. The seeding, sampling and reporting in |
| 96 | `server/load_test.go` are shared, so a new route is a `case` and a function. |
| 97 |