diff --git a/.build.yml b/.build.yml index 110a8ab3f7c46341eac12e5020662a69644a85d8..3b19b743aae2894b481c393f20418ad31a2f3fa5 100644 --- a/.build.yml +++ b/.build.yml @@ -1,6 +1,7 @@ # builds.sr.ht manifest for dolt.sr.ht. One linear pipeline: install the cache # helper, assemble the shared SCSS, restore caches, package with abuild, publish -# the apk, save caches. +# the apk, save caches, and upload this build's own coverage and benchmarks to +# cov.sr.ht and bench.sr.ht. # # The reasoning behind every task lives in docs/ci.md, not here: builds.sr.ht # stores the submitted manifest in a varchar(16384), so a manifest over 16 KiB @@ -26,8 +27,8 @@ # S3 credentials for the cacher CI cache (Garage `docker-cache` bucket), # same pair the bencher and ci-cacher builds use. - 7dde4219-0783-4581-a67d-c94749de3600 # ~/.s3-cache-key-id - 0e5b3530-6f19-4f30-9b73-9339dd382e46 # ~/.s3-cache-key-secret - # A tokens.sr.ht working token carrying artifacts:upload, the same secret the - # sibling services mount. It is what publish_artifacts sends. + # One tokens.sr.ht working token, the same one the siblings mount, carrying + # artifacts:upload, cov:upload and bench:upload. See docs/ci.md#secrets. - c7968415-1a6d-4ca0-a188-150fb7f57b65 # ~/.srht-token sources: - https://git.srht.bigb.es/~bigbes/sr-ht-dolt @@ -47,6 +48,16 @@ # drifts from the rest of the instance. BOOTSTRAP_REV is the submodule commit # core.sr.ht pins at that tag; bump the two together. docs/ci.md#environment. CORE_VER: "0.84.5" BOOTSTRAP_REV: 779ad9f174ea5ab7e755f6df0ec9e5912d67dd16 + # Dogfooding. Both repo names are the `sources:` line read as ~owner/repo. + # See docs/ci.md#coverage and docs/ci.md#bench. + COVER_ORIGIN: https://cov.srht.bigb.es + COVER_REPO: "~bigbes/sr-ht-dolt" + BENCH_ORIGIN: https://bench.srht.bigb.es + BENCH_REPO: "~bigbes/sr-ht-dolt" +# Literal paths relative to $HOME, and not a fallback. docs/ci.md#artifacts. +artifacts: + - cover.out + - bench.txt submitter: git.sr.ht: allow-refs: @@ -169,7 +180,9 @@ # `make vet` and `make test` rather than bare go commands: the Makefile is # where -tags gms_pure_go and CGO_ENABLED=0 are named, and a second copy # of those here is a second copy to forget. docs/ci.md#test. make vet - make test + # `make cover` and not `make test`: same suites, plus the flags the upload + # needs, so the profile is a by-product of the gate. docs/ci.md#test. + make cover COVERPROFILE="$HOME/cover.out" # Printed, not gated, for the same reason as cache_restore's: this task # runs before abuild, so anything the suites left in the checkout is a # "-dirty" apk, and this is where it would show. docs/ci.md#test. @@ -252,3 +265,59 @@ # purpose. Without --force an upload skips a key already there, so no # `cacher exists ||` guard is needed. See docs/ci.md#cache_save. cacher dir upload "$KEY_MOD" ~/go/pkg/mod cacher dir upload "$KEY_GOC" ~/.cache/go-build + - coverage: | + # Dogfooding: the profile the test task wrote, POSTed to this instance's + # own cov.sr.ht. Before bench, whose run is minutes. docs/ci.md#coverage. + cd "$REPO" + # Missing or empty is a 400 about a body rather than about the build. + test -s "$HOME/cover.out" || { echo "no ~/cover.out" >&2; exit 1; } + if [ ! -r ~/.srht-token ]; then + echo "no ~/.srht-token: no cov.sr.ht credentials in this build" + echo "the profile is this build's cover.out artifact and is not lost" + exit 0 + fi + # Both ref prefixes stripped (this builds tags too), key is the idempotency + # key, no Content-Type (the service sniffs), set +x so the header stays out + # of the log, --fail-with-body so a rejection is loud and readable. + # docs/ci.md#the-two-requests. + ref="${GIT_REF#refs/heads/}"; ref="${ref#refs/tags/}" + url="$COVER_ORIGIN/api/v1/repos/$COVER_REPO/reports" + url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL" + echo "uploading cover.out to $url" + set +x + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $(cat ~/.srht-token)" \ + --data-binary "@$HOME/cover.out" \ + "$url" + echo + - bench: | + # Dogfooding: this service's own benchmarks, to this instance's own + # bench.sr.ht. Last and its own task on purpose, and this VM measures a + # shape rather than a number. docs/ci.md#bench. + cd "$REPO" + # -s so the recipe is not echoed into the body, and a redirect and a cat + # and NOT `| tee` — tee's exit status would let a failed run pass. + make -s bench > "$HOME/bench.txt" + cat "$HOME/bench.txt" + # `go test -bench` matching nothing prints `ok` and exits 0, and an empty + # body is valid benchfmt, so the names are checked. docs/ci.md#the-two-greps + grep -q '^BenchmarkBoardBuild' "$HOME/bench.txt" + grep -q '^BenchmarkMemoryBodyRender' "$HOME/bench.txt" + if [ ! -r ~/.srht-token ]; then + echo "no ~/.srht-token: no bench.sr.ht credentials in this build" + echo "the run is above and is this build's bench.txt artifact" + exit 0 + fi + # The coverage request's shape, plus visibility= — which acts only on the + # POST that creates $BENCH_REPO. + ref="${GIT_REF#refs/heads/}"; ref="${ref#refs/tags/}" + url="$BENCH_ORIGIN/api/v1/repos/$BENCH_REPO/runs" + url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL" + url="$url&visibility=public" + echo "uploading bench.txt to $url" + set +x + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $(cat ~/.srht-token)" \ + --data-binary "@$HOME/bench.txt" \ + "$url" + echo diff --git a/Makefile b/Makefile index 7a361fb945a8d09aa3f1eba020f473ae08d9db96..61f787b6582d6c6b8896bb3a2a02b39c39e599d3 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,33 @@ test: $(GO) test $(GO_TAGSFLAG) ./... +# The coverage profile cov.sr.ht is fed, and the one place `go test` grows the +# two flags that decide what that profile means. -covermode=atomic records real +# hit counts rather than a set/unset bit, which is what a trend across commits +# needs; COVERPROFILE is a variable because CI writes it to $HOME (where +# `artifacts:` looks) while a checkout wants it in the checkout. +# +# It runs exactly the suites `test` runs, so CI runs one of the two and not +# both. +COVERPROFILE?=cover.out + +cover: + $(GO) test $(GO_TAGSFLAG) -covermode=atomic -coverprofile="$(COVERPROFILE)" ./... + $(GO) tool cover -func="$(COVERPROFILE)" | tail -1 + +# BENCH_COUNT is `go test -count` for the `bench` target. bench.sr.ht marks a +# point measured under six repetitions "low n" — a point's confidence interval +# only becomes finite at six — so ten is what an uploaded run carries. It is a +# variable so a checkout can say `make bench BENCH_COUNT=1` when it only wants +# to know that the benchmarks still run. +BENCH_COUNT?=10 + +# -run='^$$' so no test runs beside the benchmarks: a run that also executes the +# suites charges their wall clock to the benchmark task and, in CI, would need +# the Postgres the suites need. +bench: + $(GO) test $(GO_TAGSFLAG) -run='^$$' -bench=. -benchmem -count=$(BENCH_COUNT) ./... + # `install` still means "build it, check it, then copy it", which is what a # person at a checkout wants. The build is a prerequisite and install-files is # invoked from the recipe rather than listed as a third prerequisite: @@ -165,7 +192,7 @@ clean-share: rm -f static/main.min.css static/main.css $(CSS) -.PHONY: all all-bin all-share css check-css build vet test +.PHONY: all all-bin all-share css check-css build vet test cover bench .PHONY: install install-files install-bin install-share .PHONY: clean clean-bin clean-share $(BINARIES) diff --git a/beads/bench_test.go b/beads/bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..49ae4a87e4d24633b8c40b21ad8edbe07d399fba --- /dev/null +++ b/beads/bench_test.go @@ -0,0 +1,220 @@ +package beads + +import ( + "context" + "fmt" + "net/url" + "testing" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- what these measure ------------------------------------------------------- +// +// The board and the detail pane are what a hosted Dolt database costs to look +// at. There is no SQL engine behind either: a bare NBS store has no working +// set, so every table is read whole and projected in process (see the package +// doc), and that projection is the per-request work — the row reads themselves +// are the store's, and are the same reads whatever the page does with them. +// +// So the benchmarks below drive Build through a session that hands the rows +// over already read. That is deliberately the seam the projections were given: +// what is timed is the bucketing, the dependency walk, the label join, the +// filter options and the sorts, and nothing about how fast a disk is that day. +// +// The corpus is benchIssues issues with a dependency edge for most of them, +// which is the size a real bd tracker on this instance reaches; Max is 2000, so +// the fixture sits inside the cap and no clip path is being measured instead of +// the projection. + +// benchIssues is how many issues the synthetic tracker holds. Chosen to be a +// realistic large tracker while staying under Max (2000), so what is measured +// is the projection and not the truncation branch. +const benchIssues = 1200 + +// benchStatuses cycles the three status categories over the corpus so all four +// lanes are populated and statusCategory is exercised on both its custom-status +// hit and its heuristic fallback. +var benchStatuses = []string{"open", "in_progress", "closed", "blocked"} + +// benchSession builds a fakeSession over a synthetic tracker: issues, the +// dependency edges between them, labels, custom statuses, comments and events. +// Every table the board and the detail pane read is present, so nothing is +// measured through the optional-table degradation path. +func benchSession() *fakeSession { + issues := &browse.RowPage{ + Columns: []string{ + "id", "title", "status", "priority", "issue_type", "assignee", + "created_at", "started_at", "updated_at", "closed_at", "close_reason", + "description", "design", "acceptance_criteria", "notes", + "created_by", "owner", "estimated_minutes", "external_ref", "spec_id", + "is_blocked", + }, + } + for i := 0; i < benchIssues; i++ { + id := fmt.Sprintf("bench-%04d", i) + status := benchStatuses[i%len(benchStatuses)] + closedAt, closeReason := "", "" + if status == "closed" { + closedAt = fmt.Sprintf("2026-03-%02d", i%28+1) + closeReason = "resolved in review" + } + issues.Rows = append(issues.Rows, []string{ + id, + fmt.Sprintf("Issue %d: the projection this benchmark measures", i), + status, + fmt.Sprint(i % 5), + []string{"feature", "bug", "chore", "epic"}[i%4], + fmt.Sprintf("dev%d", i%7), + fmt.Sprintf("2026-01-%02d", i%28+1), + fmt.Sprintf("2026-02-%02d", i%28+1), + fmt.Sprintf("2026-02-%02d", i%28+1), + closedAt, + closeReason, + "A description long enough that copying it is not free, written the " + + "way an issue body is written and not as a placeholder token.", + "The design note, likewise.", + "Given a projection, when it runs, then it produces the same lanes.", + "Notes.", + fmt.Sprintf("dev%d", i%3), + fmt.Sprintf("dev%d", i%3), + "90", + "", + "", + fmt.Sprint(i % 9 / 8), // roughly one issue in nine carries the flag + }) + } + issues.Total = len(issues.Rows) + + // A chain of edges: every issue past the first depends on an earlier one, so + // the transitive walk in the detail pane has a real tree to descend and the + // blocked/blocking counts are non-trivial for most cards. + deps := &browse.RowPage{Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}} + for i := 1; i < benchIssues; i++ { + deps.Rows = append(deps.Rows, []string{ + fmt.Sprintf("d-%04d", i), + fmt.Sprintf("bench-%04d", i), + fmt.Sprintf("bench-%04d", i/2), // a binary tree, so depth is log2(n) + []string{"blocks", "related", "parent-child"}[i%3], + }) + } + deps.Total = len(deps.Rows) + + labels := &browse.RowPage{Columns: []string{"issue_id", "label"}} + for i := 0; i < benchIssues; i++ { + labels.Rows = append(labels.Rows, + []string{fmt.Sprintf("bench-%04d", i), fmt.Sprintf("area-%d", i%11)}, + []string{fmt.Sprintf("bench-%04d", i), fmt.Sprintf("release-%d", i%3)}, + ) + } + labels.Total = len(labels.Rows) + + statuses := &browse.RowPage{ + Columns: []string{"name", "category"}, + Rows: [][]string{ + {"open", "open"}, + {"in_progress", "in_progress"}, + {"closed", "closed"}, + // "blocked" is deliberately absent, so a quarter of the corpus falls + // through to the name heuristics — the branch a tracker with a status + // bd does not know about actually takes. + }, + } + statuses.Total = len(statuses.Rows) + + comments := &browse.RowPage{Columns: []string{"id", "issue_id", "author", "text", "created_at"}} + events := &browse.RowPage{Columns: []string{ + "id", "issue_id", "actor", "event_type", "old_value", "new_value", "comment", "created_at", + }} + for i := 0; i < benchIssues; i++ { + id := fmt.Sprintf("bench-%04d", i) + comments.Rows = append(comments.Rows, []string{ + fmt.Sprintf("c-%04d", i), id, fmt.Sprintf("dev%d", i%7), + "A comment on this issue, of the length a comment has.", + fmt.Sprintf("2026-02-%02d", i%28+1), + }) + events.Rows = append(events.Rows, []string{ + fmt.Sprintf("e-%04d", i), id, fmt.Sprintf("dev%d", i%7), + "status_changed", "open", "in_progress", "", + fmt.Sprintf("2026-02-%02d", i%28+1), + }) + } + comments.Total = len(comments.Rows) + events.Total = len(events.Rows) + + return &fakeSession{rowsByTable: map[string]*browse.RowPage{ + "issues": issues, + "dependencies": deps, + "labels": labels, + "custom_statuses": statuses, + "comments": comments, + "events": events, + }} +} + +// BenchmarkBoardBuild is the whole board: four lanes bucketed out of the issue +// rows, the dependency edges aggregated into blocked/blocking counts, the label +// join, the filter dropdown options and the per-lane sort. +func BenchmarkBoardBuild(b *testing.B) { + sess := benchSession() + ctx := context.Background() + + b.ReportAllocs() + for b.Loop() { + data, err := Build(ctx, sess, "main", url.Values{}) + if err != nil { + b.Fatalf("build: %v", err) + } + // Asserted rather than assumed: a projection that silently produced an + // empty board would otherwise be the fastest one here. + if data.Total != benchIssues { + b.Fatalf("board carried %d cards, want %d", data.Total, benchIssues) + } + } +} + +// BenchmarkBoardBuildFiltered is the same board under the sticky filters, which +// is the request a reader who narrowed the board sends: every row is still read +// and still matched, and only the survivors are bucketed. +func BenchmarkBoardBuildFiltered(b *testing.B) { + sess := benchSession() + ctx := context.Background() + query := url.Values{ + "q": {"projection"}, + "type": {"bug"}, + "label": {"area-3"}, + "assignee": {"dev2"}, + } + + b.ReportAllocs() + for b.Loop() { + data, err := Build(ctx, sess, "main", query) + if err != nil { + b.Fatalf("build: %v", err) + } + if data.Total == 0 { + b.Fatal("the filtered board matched nothing; the filter is measuring an empty loop") + } + } +} + +// BenchmarkDetailBuild is one issue's pane: the same four tables plus comments +// and events, the transitive dependency walk in both directions, the raw-row +// projection and the merged, time-ordered history. +func BenchmarkDetailBuild(b *testing.B) { + sess := benchSession() + ctx := context.Background() + // An issue deep in the chain, so the transitive walk has something to walk. + query := url.Values{"issue": {fmt.Sprintf("bench-%04d", benchIssues-1)}} + + b.ReportAllocs() + for b.Loop() { + data, err := Build(ctx, sess, "main", query) + if err != nil { + b.Fatalf("build: %v", err) + } + if data.Issue == nil { + b.Fatal("the detail pane resolved no issue") + } + } +} diff --git a/docs/ci.md b/docs/ci.md index 3fb63ee50e09a5f239fe08bc849b024e61c04128..1536decf1ce6514eadf6d43156d453e9a5bc5d9a 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -7,8 +7,9 @@ particular. Rationale therefore lives here, and the manifest carries pointers. The pipeline is one linear job on `alpine/edge`: install the cache helper, assemble the shared SCSS, decide a version, restore caches, start a Postgres, -test, package with `abuild`, publish the apk to -`repo.bigb.es/alpine/v3.22/bigbes`, save caches. +test (with coverage), package with `abuild`, publish the apk to +`repo.bigb.es/alpine/v3.22/bigbes`, save caches, upload the coverage profile to +cov.sr.ht and this build's own benchmarks to bench.sr.ht. It is triggered by a push to the **sourcehut** side. A push to sourcecraft cannot reach builds.sr.ht; the gitsync mirror is what puts the commit on @@ -39,7 +40,7 @@ |---|---|---| | `apk-ci-s3` | `~/.apk-ci.env` | `publish` | | `7dde4219-…` | `~/.s3-cache-key-id` | `cacher_init` | | `0e5b3530-…` | `~/.s3-cache-key-secret` | `cacher_init` | -| `c7968415-…` | `~/.srht-token` | `publish_artifacts` | +| `c7968415-…` | `~/.srht-token` | `publish_artifacts`, `coverage`, `bench` | They are file secrets. Listing them is what turns `publish` and the cache tasks on; a manual submission that asks for no secrets still runs the interesting part @@ -48,6 +49,15 @@ `apk-ci-s3` is referenced by name and the other two by UUID, which is only because that is how they were written in the donor manifests; both forms work. +`~/.srht-token` holds a tokens.sr.ht **working token**, and it is one secret +shared with every sibling service rather than a per-service one. That is what +centralising issuance buys: the credential is minted once, for a person, and +carries the grants of every service it is meant to reach — so this one must +carry `artifacts:upload` for `publish_artifacts`, `cov:upload` for `coverage` +and `bench:upload` for `bench`. Grants are compared literally, so a token +missing one of the three fails that one task and no other; there is no partial +credit and no fallback. + ## environment `CORE_VER` must track the deployment's `SRHT_CORE_VER`, and `BOOTSTRAP_REV` is @@ -55,6 +65,24 @@ the Bootstrap 4 submodule commit `core.sr.ht` pins at that tag. The two being out of step means this service renders against different partials than the rest of the instance, which shows up as a page that is subtly the wrong shape and as nothing at all in any log. Bump them together. + +`COVER_REPO` and `BENCH_REPO` are both `~bigbes/sr-ht-dolt`, and that is not a +guess from the checkout directory: it is the `sources:` line +(`https://git.srht.bigb.es/~bigbes/sr-ht-dolt`) read as `~owner/repo`. The +directory this repository is cloned into is `sourcehut-dolt` on at least one +machine, which is exactly the name that would have been wrong. + +## artifacts + +Two, both literal paths relative to `$HOME`: `cover.out` (written by `test`) and +`bench.txt` (written by `bench`). `artifacts:` has no globbing, which is why the +`Makefile` takes `COVERPROFILE` as a variable — CI points it at `$HOME` and a +checkout leaves it in the checkout. + +They are **not** a fallback for the two uploads. They are what a build handed no +secrets still leaves behind, so a manual submission that asked for none can +still be read, and a rejected upload can be replayed by hand from the exact +bytes the build produced. ## cacher @@ -237,12 +265,23 @@ the same kind of reason. Neither tag is set here, deliberately. ## test -`make vet` and `make test`, not bare `go` commands: the Makefile is where +`make vet` and `make cover`, not bare `go` commands: the Makefile is where `-tags gms_pure_go` and `CGO_ENABLED=0` are named, and a second copy of those two here is a second copy to forget. The tags are not optional — a `go vet` or `go test` without `gms_pure_go` pulls `go-icu-regex` in and wants ICU headers the builder has never had. +It is `make cover` rather than `make test` because the two run the same suites +over the same tree; `cover` only adds `-covermode=atomic -coverprofile=…`. Doing +it in one task means the profile is a by-product of the gate that already had to +pass, and not a second full run of the suites whose result nothing checks. +`atomic` and not the default `set`: the profile carries real hit counts, which +is what a trend across commits is read off, and `set` would flatten every count +to a bit. `COVERPROFILE="$HOME/cover.out"` because that is where `artifacts:` +looks, and because `abuild` packages this checkout in place: a profile written +into the checkout is one more file in the tree the `version` task had just +proved clean. + The guard on an empty `DOLTSRHT_TEST_PG` exists because the failure it prevents is silent. If the `postgres` task did not export the DSN — or if someone reorders the two tasks — every database suite skips with a friendly message, @@ -406,14 +445,126 @@ the old name must be bumped along with it. And `dolt-git-hook` is one of the three binaries `build()` now compiles with `-trimpath`, so the file the subpackage ships changes bytes on the next build even where nothing else did. +## coverage + +The profile the `test` task already wrote, POSTed to this instance's own +cov.sr.ht. Dogfooding: the service that hosts the coverage of the sibling +services now publishes this one's. + +It is its own task, and it comes **after** `publish` and `cache_save` for the +reason every upload here does — a rejected report must not cost an apk that was +built, signed and shipped. It comes **before** `bench` because the profile is +already in hand while the benchmark run is minutes long, and a long run has no +business standing between a finished profile and its upload. + +Three things in it are not decoration: + +- `test -s "$HOME/cover.out"` before anything else. A missing or empty profile + is a `test` task that did not write one, and the service would answer that + with a 400 about a body — a message about the request, when the fact is about + the build. +- The `~/.srht-token` gate. Without the file this build was handed no secrets; + that is a manual submission, not a failure, and the profile is still this + build's `cover.out` artifact. **With** the file the upload is fatal on + purpose: a build that has the credential and cannot publish is a build that + should say so. +- `set +x` immediately before the `curl`. The task runs under `set -x`, and the + `Authorization` header would otherwise be printed into a build log that is + public. + +## bench + +This service's own benchmarks, uploaded to this instance's own bench.sr.ht, +`&visibility=public` so the repository the first POST creates is readable. + +### What is measured, and why those + +There is no SQL engine behind a hosted database here: a bare NBS store has no +working set, so every table is read whole and projected in process. That +projection — not a query planner, and not the disk — is what a page costs, and +it is what the benchmarks drive: + +- `BenchmarkBoardBuild` / `BenchmarkBoardBuildFiltered` / `BenchmarkDetailBuild` + (`beads/bench_test.go`) run `beads.Build` over a synthetic tracker of 1200 + issues, 1199 dependency edges in a binary tree, two labels per issue, a + comment and an event each. That is the board a reader opens: the lane + bucketing, the transitive dependency walk, the label join, the filter options + and the per-lane sorts. The rows arrive through the package's own + `BrowseSession` seam, already read, so what is timed is the projection and + not how fast the builder's disk was that morning. 1200 is under `beads.Max` + (2000) deliberately — over it the projection measures its truncation branch + instead. +- `BenchmarkMemoryBodyRender` / `…NoIndex` (`web/markdown_bench_test.go`) render + one memory body through `memoryLinks.Body`: goldmark parses it, `[[slug]]` + resolves against the cross-database index, and every text node is scanned for + id-shaped tokens. The index is built by the real `beads.PrefixesAcross` over a + fake session, outside the timed loop, because what the render costs depends on + how many prefixes and slugs the index holds. The `NoIndex` variant is the same + body with no index at all — the difference between the two is what resolution + costs, which is the number worth watching. + +Every one of them asserts its own result inside the loop (the board carried +1200 cards, the wikilink resolved). A projection that silently produced nothing +would otherwise be the fastest entry in the file. + +None of them needs Postgres and none needs the `dolt` CLI, which is why this +task can sit at the end of the pipeline without a DSN guard: the `browse` tests' +fixture needs the CLI and skips without it, but nothing benchmarked here goes +through that fixture. + +### The two greps + +`go test -bench` that matches nothing prints `ok` and exits `0`, and a file with +no benchmark lines in it is *valid* benchfmt. A renamed or deleted benchmark +would therefore upload an empty run and report success. So the names are checked +against the file before the upload: + +```sh +grep -q '^BenchmarkBoardBuild' "$HOME/bench.txt" +grep -q '^BenchmarkMemoryBodyRender' "$HOME/bench.txt" +``` + +One per benchmarked package, so losing either package's benchmarks is loud. + +`make -s bench > "$HOME/bench.txt"` and then `cat`, and deliberately **not** +`| tee`: a pipeline's exit status is the last command's, so `tee` would let a +failing benchmark run pass. `-s` keeps make from echoing the recipe into a file +the parser will read. + +`BENCH_COUNT` is 10. bench.sr.ht marks a point measured under six repetitions +"low n" — a point's confidence interval only becomes finite at six — so a run +uploaded with fewer is a run nobody can read a regression off. A checkout that +only wants to know the benchmarks still run says `make bench BENCH_COUNT=1`. + +A builder VM this small measures a **shape**, not a number: the absolute ns/op +is worth nothing next to a laptop's, and the point of uploading it is that it is +measured the same way every time. + +## The two requests + +Both are a POST with the token as a Bearer header and the file as +`--data-binary`, and both carry the same four query parameters: + +| parameter | value | why | +|---|---|---| +| `commit` | `git rev-parse HEAD` | what the numbers are about | +| `ref` | `$GIT_REF` with both prefixes stripped | see below | +| `key` | `$JOB_ID` | the idempotency key: a resubmitted job replaces, not duplicates | +| `job_url` | `$JOB_URL` | the build a report links back to | + +`ref="${GIT_REF#refs/heads/}"; ref="${ref#refs/tags/}"` strips **both** +prefixes because this pipeline builds tags too (`allow-refs` carries +`refs/tags/v*`), and a tag build would otherwise report `ref=refs/tags/v0.2.0`. +`GIT_REF` is absent altogether on a manually submitted build, which is fine: the +parameter is optional. + +The coverage POST sends **no `Content-Type`** — cov.sr.ht sniffs the body, and a +wrong declared type is worse than none. Both use `curl -sS --fail-with-body`, +which prints the service's JSON error *and* still exits non-zero; plain `--fail` +would swallow the only sentence saying what was wrong. + ## What is not here -- **No coverage upload.** The sibling services POST their profile to - cover.sr.ht at the end of the pipeline. Adding it here needs a token secret - and a repository on cover. -- **No artifacts.** There is nothing to download: the apk goes to S3, and its - name changes every commit, which `artifacts:` cannot express (it has no - globbing). - **No matrix.** One architecture, one image. - **No `gofmt` gate.** See [test](#test). - **No `integration` or `spike` build tag.** See diff --git a/web/markdown_bench_test.go b/web/markdown_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f307ac9f325ae513c1afa2b061c9aa9a2b315a3d --- /dev/null +++ b/web/markdown_bench_test.go @@ -0,0 +1,157 @@ +package web + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "sourcecraft.dev/bigbes/sr-ht-dolt/beads" + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- what this measures -------------------------------------------------------- +// +// The memory pane renders every memory body it shows through memoryLinks.Body: +// goldmark parses the markdown, the wikilink parser resolves [[slug]] against +// the cross-database index, and the AST transformer scans every text node for +// id-shaped tokens and links the ones whose prefix the index knows. That is the +// per-memory cost of the page, and a memory list renders many of them per +// request. +// +// The index is built once, outside the timed loop, and built through the real +// beads.PrefixesAcross over a fake session rather than hand-assembled: what the +// render costs depends on how many prefixes and slugs the index holds, and the +// only honest source of an index of that shape is the function that builds one. + +// benchMemoryDatabases is how many sibling trackers the caller may browse. Each +// contributes one issue prefix and a handful of memory slugs, so the scan has +// real prefixes to hit and real ones to miss. +const benchMemoryDatabases = 6 + +// benchMemorySlugs is how many memories each of those trackers stores. +const benchMemorySlugs = 12 + +// benchSession is a beads.ReadySession over one canned config table. Only +// Branches and Rows are reached by PrefixesAcross; Tables and Log are here to +// satisfy the interface and would be a bug to call. +type benchSession struct { + config *browse.RowPage +} + +func (s *benchSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) { + if table != "config" { + return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) + } + return s.config, nil +} + +func (s *benchSession) Branches(context.Context) ([]browse.Branch, error) { + return []browse.Branch{{Name: "main", Head: "0123456789abcdef"}}, nil +} + +func (s *benchSession) Tables(context.Context, string) ([]browse.TableInfo, error) { + return nil, fmt.Errorf("web: Tables is not part of the prefix index read") +} + +func (s *benchSession) Log(context.Context, string, string, int) ([]browse.CommitInfo, string, error) { + return nil, "", fmt.Errorf("web: Log is not part of the prefix index read") +} + +func (s *benchSession) Close() error { return nil } + +// benchPrefixIndex builds the cross-database link index the render resolves +// against: benchMemoryDatabases trackers, each naming its own issue prefix and +// storing benchMemorySlugs memories. +func benchPrefixIndex() *beads.PrefixIndex { + dbs := make([]beads.ReadyDatabase, 0, benchMemoryDatabases) + configs := make(map[int]*browse.RowPage, benchMemoryDatabases) + for i := 0; i < benchMemoryDatabases; i++ { + dbs = append(dbs, beads.ReadyDatabase{ + ID: i + 1, + OwnerName: "bigbes", + Name: fmt.Sprintf("sr-ht-%d", i), + }) + rows := [][]string{{"issue_prefix", fmt.Sprintf("sr-ht-%d", i)}} + for j := 0; j < benchMemorySlugs; j++ { + rows = append(rows, []string{ + fmt.Sprintf("kv.memory.memory-%d-%d", i, j), + "stored elsewhere; the index only needs the key", + }) + } + page := &browse.RowPage{Columns: []string{"key", "value"}, Rows: rows} + page.Total = len(rows) + configs[i+1] = page + } + + open := func(_ context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { + page, ok := configs[d.ID] + if !ok { + return nil, fmt.Errorf("web: no config for database %s", d.Slug()) + } + return &benchSession{config: page}, nil + } + return beads.PrefixesAcross(context.Background(), dbs, open, &beads.PrefixCache{}, time.Now()) +} + +// benchMemoryBody is one memory of the shape the corpus actually holds: a bold +// leader, code spans, a bullet list, a fenced recipe, a table, prose carrying +// both resolvable and unresolvable ids, wikilinks that hit and wikilinks that +// miss, and the angle-bracket placeholders the raw-HTML-as-text rendering +// exists for. +var benchMemoryBody = strings.Repeat(`**Why:** the pipeline builds tags too, and a tag build reports the tag. + +- see [[memory-0-3]] for the deploy order, and [[memory-4-7]] for the token +- sr-ht-0-46c.2 blocks sr-ht-3-9a1, which is what sr-ht-5-nex was filed against +- an id nothing owns, other-repo-12b, stays text + +`+"```"+`sh +ref="${GIT_REF#refs/heads/}" +curl -sS --fail-with-body -X POST "$ORIGIN/api/v1/repos/$REPO/reports" +`+"```"+` + +| step | what it does | +| ---- | ------------ | +| push | `+"`labng push`"+` is the deploy | +| wait | the apk index catches up within 15 minutes | + +The placeholder spellings are SRHT__VER and ~/data/home/, which +CommonMark reads as tags and this renderer shows as the text they are. See +[[memory-2-11]] and https://dolt.srht.bigb.es/~bigbes/sr-ht-2 for the rest. + +`, 8) + +// BenchmarkMemoryBodyRender is one memory body rendered the way the pane +// renders it: parsed, its wikilinks resolved against the index, its ids scanned +// and linked, and the whole document written out as HTML. +func BenchmarkMemoryBodyRender(b *testing.B) { + links := &memoryLinks{index: benchPrefixIndex()} + + b.ReportAllocs() + b.SetBytes(int64(len(benchMemoryBody))) + for b.Loop() { + html := string(links.Body(benchMemoryBody)) + // A render that resolved nothing would be the fastest one here, and the + // fallback path (an escaped

) is a whole document too. + if !strings.Contains(html, `href="/~bigbes/sr-ht-0/view/memory?key=memory-0-3"`) { + b.Fatal("the wikilink did not resolve; this is measuring the wrong render") + } + } +} + +// BenchmarkMemoryBodyRenderNoIndex is the same body with no index at all — +// what a pane gets when the database listing failed. Every reference is left as +// text, so the difference between the two is what resolution costs. +func BenchmarkMemoryBodyRenderNoIndex(b *testing.B) { + var links *memoryLinks + + b.ReportAllocs() + b.SetBytes(int64(len(benchMemoryBody))) + for b.Loop() { + html := string(links.Body(benchMemoryBody)) + if strings.Contains(html, "/view/memory?key=") { + b.Fatal("an index-less render resolved a memory reference") + } + } +}