M .build.yml => .build.yml +73 -4
@@ 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
M Makefile => Makefile +28 -1
@@ 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)
A beads/bench_test.go => beads/bench_test.go +220 -0
@@ 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")
+ }
+ }
+}
M docs/ci.md => docs/ci.md +161 -10
@@ 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
A web/markdown_bench_test.go => web/markdown_bench_test.go +157 -0
@@ 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_<NAME>_VER and ~/data/home/<repo>, 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 <p class="mem-raw">) 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")
+ }
+ }
+}