Further performance optimization
This commit is contained in:
@@ -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>`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user