measure the render path, then remember pictures instead of caching pages

The entry said to measure first and put the number in the commit, so: a plain page
renders in 14µs, a twelve-picture gallery in 1.23ms. Of that, ~102µs per picture
was reading, hashing and decoding bytes the previous request had already read.

Remembering that one fact — keyed by path, size and modification time — brings the
same gallery to 63µs. 19.5× faster, 21× fewer bytes allocated, twenty-odd lines.
After which nothing is slow enough to justify caching whole pages, so ADR-0044
declines the page cache and leaves the parked validity model parked, now with a
measurement rather than an intuition behind its trigger.

That parked model has five axes and was written before any code existed. The
problem it would have been built for turned out to be one repeated file read.

Benchmarks live in internal/web so they measure through the real handler, which is
also what conventions.md wants before any cache goes in the render path. The
invalidation risk has its own test: an edited picture is a different key, so the
memo cannot serve yesterday's dimensions. Everything runs clean under -race, since
the map is read by concurrent requests.
This commit is contained in:
Claude Opus 5
2026-07-31 11:00:37 +06:00
committed by bdeshi
parent 57f5520de8
commit b061f4590f
6 changed files with 181 additions and 4 deletions
+19
View File
@@ -595,3 +595,22 @@ bundle an author wants out of the feed has no way to say so; and entries without
titles only, until `summary` is parsed.
Revisit if: someone wants a dated bundle excluded, or one section kept out of the main feed. *That* is the
real trigger for declared types, and it is now a sharper one than "feeds exist".
## ADR-0044 — No page cache; the cost was repeated picture inspection
Date: 2026-07-30 · Status: accepted (the parked cache validity model stays parked)
Decision: do not build a page cache. Instead remember what each picture is — its size and its derivative names
— keyed by path, file size and modification time. Rendering stays request-time with no stored output, no
validity records and no invalidation graph.
Why: measured before deciding, as the entry required. A plain page rendered in 14µs and a twelve-picture
gallery in 1.23ms, of which ~102µs per picture was reading, hashing and decoding bytes already read on the
previous request. Remembering that one fact takes a map behind a mutex and brings the same gallery to 63µs —
19.5× faster, 21× fewer bytes allocated — after which nothing on the site is slow enough to justify caching
whole pages. A validity model with five axes, written before any code existed, would have been built to solve a
problem that turned out to be one repeated file read.
Consequence: cheap — twenty-odd lines, no stored HTML, and the only invalidation question is "did the file
change", answered by the filesystem. Expensive — one map grows with the number of pictures on the site and is
never evicted, which is correct for a single-author site and wrong for an unbounded one; and every future
"cache the page" instinct now has to beat 63µs rather than 1.23ms.
Revisit if: a page render exceeds a few milliseconds after this, or output stops being a pure function of
content — a comment stream, a per-visitor fragment. Then the parked validity model is the right shape, and its
five axes will have consumers instead of guesses.
+5 -4
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `420458c` on 2026-07-30 — update this line every change.
**Verified against:** `02adf84` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -15,7 +15,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 409 |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and picture inspection (ADR-0042) | 534 |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 |
| `internal/ext/widows/` | second feature: joins the last two words of a paragraph or heading with a non-breaking space, over the tree so code spans are safe | 108 |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 20 |
| `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 |
@@ -24,7 +24,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`), wiring, startup including the derivative pass — the only place things are assembled | 88 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404 | 2334 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path | 2457 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -73,7 +73,8 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is now mechanical — `verify.sh` rejects `WithUnsafe`, so authored Markdown cannot become HTML — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
| `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) |
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | No cache exists anywhere yet, and a site of this size resolves in microseconds. Measuring first is the rule (queue 16) | The page cache (queue 16), which is the thing that makes the cost visible |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn |
## Open questions
+4
View File
@@ -13,6 +13,10 @@ not this file, decides the shape.
## Cache validity model
Status: still parked, and now with a measurement behind it (ADR-0044). Rendering a page costs ~63µs once
repeated picture inspection is remembered; the parked model solves a problem the site does not have. Its trigger
is a render exceeding a few milliseconds, or output that stops being a pure function of content.
Deferred because no cache exists, and the harness forbids one until requests feel slow.
## Cache validity is one record with five axes
+30
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
@@ -131,6 +132,20 @@ func encode(w *os.File, img image.Image, source string) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 82})
}
// inspected remembers what each picture is.
//
// Measured, not assumed: without this the same bytes were read, hashed and decoded on every request, costing
// ~102µs per picture — a twelve-picture gallery spent 1.2ms of its 1.24ms doing work it had already done
// (BenchmarkGalleryPage in internal/web). conventions.md allows a cache in the render path once a benchmark
// asks for one, and this is the smallest thing the benchmark asks for.
//
// Keyed by name, size and modification time, so an edited picture is inspected again rather than remembered
// wrongly. A map behind a mutex because requests are concurrent and there is one entry per picture on the site.
var (
inspectedMu sync.Mutex
inspected = map[string]render.Picture{}
)
// Picture describes one image for a fragment: where to fetch it, what a browser may choose instead, and the
// intrinsic size, so a page reserves the right box before any bytes arrive.
//
@@ -148,6 +163,18 @@ func picture(origin render.Origin, file string) (render.Picture, bool) {
return p, true
}
name := path.Join(origin.Dir, file)
info, err := fs.Stat(origin.Files, name)
if err != nil {
slog.Error("picture unreadable", "path", name, "err", err)
return p, true
}
key := fmt.Sprintf("%s\x00%d\x00%d", name, info.Size(), info.ModTime().UnixNano())
inspectedMu.Lock()
remembered, known := inspected[key]
inspectedMu.Unlock()
if known {
return remembered, true
}
data, err := fs.ReadFile(origin.Files, name)
if err != nil {
slog.Error("picture unreadable", "path", name, "err", err)
@@ -171,6 +198,9 @@ func picture(origin render.Origin, file string) (render.Picture, bool) {
sources = append(sources, file+" "+strconv.Itoa(cfg.Width)+"w")
p.Srcset = strings.Join(sources, ", ")
}
inspectedMu.Lock()
inspected[key] = p
inspectedMu.Unlock()
return p, true
}
+32
View File
@@ -12,6 +12,9 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"khosra/internal/render"
)
// wide builds a real encoded picture of a given width, so the tests exercise decoding rather than a stub.
@@ -118,3 +121,32 @@ func TestOriginalsAreNeverTouched(t *testing.T) {
t.Error("the author's own file was modified")
}
}
func TestAnEditedPictureIsInspectedAgain(t *testing.T) {
// The whole risk of remembering: serving yesterday's size or srcset. The key carries size and modification
// time, so an edit is a different key rather than a stale hit.
fsys := fstest.MapFS{
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n")},
"content/art/set/one.jpg": {Data: wide(t, 1200, false), ModTime: time.Unix(1000, 0)},
}
origin := render.Origin{Dir: "content/art/set", Files: fsys}
first, ok := picture(origin, "one.jpg")
if !ok || first.Width != 1200 {
t.Fatalf("first inspection = %+v", first)
}
if again, _ := picture(origin, "one.jpg"); again != first {
t.Errorf("a second look at the same file should be identical, got %+v", again)
}
fsys["content/art/set/one.jpg"] = &fstest.MapFile{Data: wide(t, 800, false), ModTime: time.Unix(2000, 0)}
edited, ok := picture(origin, "one.jpg")
if !ok {
t.Fatal("still a picture")
}
if edited.Width != 800 {
t.Errorf("width = %d, want 800 — the edit was not noticed", edited.Width)
}
if edited.Srcset == first.Srcset {
t.Error("the srcset should name different derivatives after an edit")
}
}
+91
View File
@@ -0,0 +1,91 @@
package web
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/yuin/goldmark"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
// photo is a real encoded JPEG, so a benchmark measures decoding rather than a stub.
func photo(b *testing.B, width int) []byte {
b.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, width*2/3))
for x := range width {
for y := range width * 2 / 3 {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 90, 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, nil); err != nil {
b.Fatal(err)
}
return out.Bytes()
}
func benchHandler(b *testing.B, pictures int) http.Handler {
b.Helper()
fsys := fstest.MapFS{
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-01\n---\nJust prose, several words of it.\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\ndate: 2026-01-02\n---\n{{< gallery >}}\n")},
}
shot := photo(b, 1600)
for i := range pictures {
fsys[fmt.Sprintf("content/art/set/%02d.jpg", i)] = &fstest.MapFile{Data: shot}
}
bundles, err := content.Scan(fsys)
if err != nil {
b.Fatal(err)
}
r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{shortcodes.New(p)}
})
if err != nil {
b.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
}
func serveOnce(b *testing.B, h http.Handler, path string) {
b.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
b.Fatalf("GET %s = %d", path, rec.Code)
}
}
func BenchmarkPlainPage(b *testing.B) {
h := benchHandler(b, 0)
for b.Loop() {
serveOnce(b, h, "/posts/plain/")
}
}
func BenchmarkGalleryPage(b *testing.B) {
for _, n := range []int{1, 6, 12} {
b.Run(fmt.Sprintf("pictures=%d", n), func(b *testing.B) {
h := benchHandler(b, n)
for b.Loop() {
serveOnce(b, h, "/art/set/")
}
})
}
}
func BenchmarkListing(b *testing.B) {
h := benchHandler(b, 0)
for b.Loop() {
serveOnce(b, h, "/posts/")
}
}