~bigbes/sr-ht-dolt

12c7ff77f8281cd0ca61177bcf07b9c031a04c97 — Eugene Blikh 2 days ago 377c616
ci: publish this build's own coverage and benchmarks

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.
5 files changed, 639 insertions(+), 15 deletions(-)

M .build.yml
M Makefile
A beads/bench_test.go
M docs/ci.md
A web/markdown_bench_test.go
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")
		}
	}
}