From 3170d1c6d947753b2d5c4d83f75b4e7062950967 Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 30 Sep 2026 15:48:30 +0200 Subject: [PATCH] Further performance optimization --- embed.go | 2 +- main.go | 17 ++++++++++---- render.go | 2 +- search.go | 18 ++++++++++++--- static.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++- static_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++ thumb.go | 13 +++++++++-- 7 files changed, 154 insertions(+), 13 deletions(-) diff --git a/embed.go b/embed.go index b14b26a..3331bcc 100644 --- a/embed.go +++ b/embed.go @@ -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(`">`) + w.WriteString(`" loading="lazy">`) if captioned { w.WriteString(`
`) } diff --git a/main.go b/main.go index 28112b2..768db2d 100644 --- a/main.go +++ b/main.go @@ -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) } diff --git a/render.go b/render.go index e5060ab..871b357 100644 --- a/render.go +++ b/render.go @@ -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 diff --git a/search.go b/search.go index 31bc7f0..cdc4aa6 100644 --- a/search.go +++ b/search.go @@ -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 { diff --git a/static.go b/static.go index f497e11..f682977 100644 --- a/static.go +++ b/static.go @@ -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=. An unknown name fails the template so a typo or a // removed file surfaces as a render error instead of a silent 404. @@ -57,18 +99,36 @@ 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") - w.Header().Set("ETag", `"`+v+`"`) + 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) }) diff --git a/static_test.go b/static_test.go index e02392b..45d662f 100644 --- a/static_test.go +++ b/static_test.go @@ -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") + } +} diff --git a/thumb.go b/thumb.go index 6c18b68..67b196e 100644 --- a/thumb.go +++ b/thumb.go @@ -12,6 +12,7 @@ import ( "os" "path" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -48,8 +49,13 @@ var ( thumbLocks = map[string]*sync.Mutex{} thumbLocksMu sync.Mutex - digestCache = map[string]digestEntry{} + 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