Further performance optimization

This commit is contained in:
2026-09-30 15:48:30 +02:00
parent 65ce1b414e
commit 3170d1c6d9
7 changed files with 154 additions and 13 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, e
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
w.WriteString(`" loading="lazy"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
+12 -5
View File
@@ -175,14 +175,21 @@ type reqStartKeyT struct{}
var reqStartKey = reqStartKeyT{}
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
func elapsedMS(r *http.Request) int64 {
// requestStart returns when the request entered ServeHTTP, or now if unknown.
func requestStart(r *http.Request) time.Time {
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
return time.Since(start).Milliseconds()
return start
}
return 0
return time.Now()
}
// renderTimer is embedded in template data. The footer calls RenderMS while
// the template executes, so the time includes the page's own rendering (and
// the header's maintenance widget), not just the work before it.
type renderTimer struct{ start time.Time }
func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() }
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
@@ -407,7 +414,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
if editMode {
t = editTmpl
}
data.RenderMS = elapsedMS(r)
data.renderTimer = renderTimer{requestStart(r)}
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
+1 -1
View File
@@ -63,7 +63,7 @@ type pageData struct {
SpecialContent template.HTML
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
renderTimer
}
// Allowed values for the listing view settings. Unknown values in the file or
+15 -3
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"unicode"
"unicode/utf8"
)
// searchSectionCap bounds how many rows each results section renders. The
@@ -46,7 +47,7 @@ type searchPageData struct {
PageTotal int
FileTotal int
IndexBuiltAt time.Time
RenderMS int64
renderTimer
}
// indexEntry is the shared scoreable core of both folder and file index
@@ -132,7 +133,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
IndexBuiltAt: builtAt,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
data.renderTimer = renderTimer{requestStart(r)}
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("search template error: %v", err)
}
@@ -278,7 +279,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 {
best = 20
}
case fuzzy && levenshtein(w, qt) <= 2:
case fuzzy && withinTwoEdits(w, qt):
if best < 5 {
best = 5
}
@@ -575,6 +576,17 @@ func tokenize(s string) []string {
return tokens
}
// withinTwoEdits reports whether a and b are at most 2 edits apart. Lengths
// differing by more than 2 rule that out without running levenshtein, which
// skips nearly every word pair in the per-query scoring loop.
func withinTwoEdits(a, b string) bool {
d := utf8.RuneCountInString(a) - utf8.RuneCountInString(b)
if d < -2 || d > 2 {
return false
}
return levenshtein(a, b) <= 2
}
// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
+60
View File
@@ -1,6 +1,8 @@
package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -8,6 +10,7 @@ import (
"io/fs"
"net/http"
"strings"
"time"
)
// assetVersions maps each embedded asset (path relative to assets/, e.g.
@@ -34,6 +37,45 @@ func hashAssets() map[string]string {
return versions
}
// assetGzip holds a gzip copy of every embedded asset that compresses well,
// keyed like assetVersions. Built once at startup so serving compressed costs
// no CPU per request; already-compressed files (fonts) are left out.
var assetGzip = gzipAssets()
func gzipAssets() map[string][]byte {
out := map[string][]byte{}
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := assets.ReadFile(p)
if err != nil {
return err
}
var buf bytes.Buffer
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
zw.Write(b)
zw.Close()
// Not worth a Content-Encoding round trip unless it saves ≥10%.
if buf.Len() < len(b)*9/10 {
out[strings.TrimPrefix(p, "assets/")] = buf.Bytes()
}
return nil
})
return out
}
// acceptsGzip reports whether the client accepts a gzip response body.
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
enc, q, _ := strings.Cut(strings.TrimSpace(enc), ";")
if strings.TrimSpace(enc) == "gzip" && strings.ReplaceAll(q, " ", "") != "q=0" {
return true
}
}
return false
}
// assetURL is the "asset" template func: {{asset "style.css"}} renders
// /_/style.css?v=<hash>. An unknown name fails the template so a typo or a
// removed file surfaces as a render error instead of a silent 404.
@@ -57,19 +99,37 @@ var assetFuncs = template.FuncMap{"asset": assetURL}
// change);
// - anything else (unversioned or stale ?v=, e.g. icons from CSS) gets the
// content hash as ETag and revalidates, answered with 304 when unchanged.
//
// Assets with a precompressed copy (see assetGzip) are sent gzipped to
// clients that accept it; the gzip variant gets its own ETag.
func serveStatic() http.Handler {
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/_/")
v, ok := assetVersions[name]
gz, hasGz := assetGzip[name]
useGz := hasGz && acceptsGzip(r)
if hasGz {
w.Header().Set("Vary", "Accept-Encoding")
}
switch {
case ok && r.URL.Query().Get("v") == v, strings.HasPrefix(name, "fonts/"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case ok:
w.Header().Set("Cache-Control", "no-cache")
if useGz {
w.Header().Set("ETag", `"`+v+`-gz"`)
} else {
w.Header().Set("ETag", `"`+v+`"`)
}
}
if useGz {
w.Header().Set("Content-Encoding", "gzip")
// ServeContent derives Content-Type from the name's extension.
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(gz))
return
}
static.ServeHTTP(w, r)
})
}
+53
View File
@@ -1,10 +1,14 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
@@ -56,3 +60,52 @@ func TestServeStaticCaching(t *testing.T) {
t.Errorf("font: %q", w.Header().Get("Cache-Control"))
}
}
func TestServeStaticGzip(t *testing.T) {
h := serveStatic()
get := func(url, acceptEncoding, ifNoneMatch string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, url, nil)
if acceptEncoding != "" {
r.Header.Set("Accept-Encoding", acceptEncoding)
}
if ifNoneMatch != "" {
r.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
want, _ := assets.ReadFile("assets/style.css")
w := get("/_/style.css", "gzip, deflate, br", "")
if w.Code != 200 || w.Header().Get("Content-Encoding") != "gzip" || w.Header().Get("Vary") != "Accept-Encoding" {
t.Fatalf("gzip: %d %q %q", w.Code, w.Header().Get("Content-Encoding"), w.Header().Get("Vary"))
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("content-type: %q", ct)
}
zr, err := gzip.NewReader(w.Body)
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(zr)
if !bytes.Equal(got, want) {
t.Errorf("gzip body does not decompress to style.css")
}
etag := w.Header().Get("ETag")
if w := get("/_/style.css", "gzip", etag); w.Code != http.StatusNotModified {
t.Errorf("gzip revalidate: got %d, want 304", w.Code)
}
for _, ae := range []string{"", "identity", "gzip;q=0"} {
w := get("/_/style.css", ae, "")
if w.Header().Get("Content-Encoding") != "" || !bytes.Equal(w.Body.Bytes(), want) {
t.Errorf("Accept-Encoding %q: got encoding %q", ae, w.Header().Get("Content-Encoding"))
}
}
if w := get("/_/fonts/IosevkaSlab.woff2", "gzip", ""); w.Header().Get("Content-Encoding") != "" {
t.Errorf("font should not be re-compressed")
}
}
+10 -1
View File
@@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -50,6 +51,11 @@ var (
digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
// photos otherwise decodes one full-size image per browser connection at
// once, which on the NAS means memory pressure and CPU thrash.
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
)
// digestEntry remembers the digest of a source file so repeated requests do
@@ -179,7 +185,10 @@ func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS s
return
}
if err := generateThumb(cacheFS, write); err != nil {
thumbGenSlots <- struct{}{}
err = generateThumb(cacheFS, write)
<-thumbGenSlots
if err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return