From 12c7ff77f8281cd0ca61177bcf07b9c031a04c97 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 16 Aug 2026 08:42:44 +0300 Subject: [PATCH] ci: publish this build's own coverage and benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board and detail projections in beads/ and the memory markdown render in web/ are what a request costs here — there is no SQL engine behind a bare store, so every table is read whole and projected in process. This repository had no Benchmark at all; those two paths now have one each, driven through the seams that already exist (a canned BrowseSession, an index built by the real PrefixesAcross), so what is timed is the projection and not the disk. The manifest gains a coverage and a bench task, both after publish so a rejected upload cannot cost a shipped apk, and both guarded: the profile must be non-empty, the benchmark names must be in the file (an empty benchfmt body uploads and reports success), and a build handed no token says so and exits 0 with the artifact still attached. --- .build.yml | 77 ++++++++++++- Makefile | 29 ++++- beads/bench_test.go | 220 +++++++++++++++++++++++++++++++++++++ docs/ci.md | 171 ++++++++++++++++++++++++++-- web/markdown_bench_test.go | 157 ++++++++++++++++++++++++++ 5 files changed, 639 insertions(+), 15 deletions(-) create mode 100644 beads/bench_test.go create mode 100644 web/markdown_bench_test.go 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 @@ secrets: # 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 @@ environment: # 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 @@ tasks: # 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 @@ tasks: # `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 @@ vet: 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-bin: 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 @@ Four, all account-level and shared with the sibling services: | `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 @@ of the pipeline and stops at `cacher_init`, which is the right place to notice. `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 @@ -56,6 +66,24 @@ 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 `cacher` is an S3-backed cache helper, installed from pages.sr.ht. @@ -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") + } + } +}