Improve caching

This commit is contained in:
2026-09-28 22:19:17 +02:00
parent 3070471c38
commit 65ce1b414e
15 changed files with 236 additions and 67 deletions
+1 -1
View File
@@ -24,4 +24,4 @@
</table> </table>
{{end}} {{end}}
</div> </div>
<script src="/_/diary/calendar.js"></script> <script src="{{asset "diary/calendar.js"}}"></script>
+6 -6
View File
@@ -78,10 +78,10 @@
<div id="editor" class="editor-cm"></div> <div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea> <textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form> </form>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script> <script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="/_/editor/tables.js"></script> <script src="{{asset "editor/tables.js"}}"></script>
<script src="/_/editor/dates.js"></script> <script src="{{asset "editor/dates.js"}}"></script>
<script src="/_/editor/movie.js"></script> <script src="{{asset "editor/movie.js"}}"></script>
<script src="/_/editor/wikicomplete.js"></script> <script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="/_/editor/main.js"></script> <script src="{{asset "editor/main.js"}}"></script>
{{end}} {{end}}
+1 -1
View File
@@ -47,4 +47,4 @@
{{range .Charts}}{{template "fitnessChart" .}}{{end}} {{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}} {{end}}
</div> </div>
<script src="/_/fitness/fitness.js"></script> <script src="{{asset "fitness/fitness.js"}}"></script>
+11 -11
View File
@@ -4,19 +4,19 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" /> <link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" /> <link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="/_/modal.js"></script> <script src="{{asset "modal.js"}}"></script>
<script src="/_/global-shortcuts.js"></script> <script src="{{asset "global-shortcuts.js"}}"></script>
<script src="/_/history-nav.js"></script> <script src="{{asset "history-nav.js"}}"></script>
<script src="/_/scroll-sync.js" defer></script> <script src="{{asset "scroll-sync.js"}}" defer></script>
<script src="/_/search-suggest.js" defer></script> <script src="{{asset "search-suggest.js"}}" defer></script>
<script src="/_/tree-picker.js"></script> <script src="{{asset "tree-picker.js"}}"></script>
<script src="/_/companion.js" defer></script> <script src="{{asset "companion.js"}}" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script> {{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}} <script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body> <body>
+7 -7
View File
@@ -1,4 +1,4 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}} {{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}} {{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button> <button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
@@ -44,17 +44,17 @@
</tbody> </tbody>
</table> </table>
{{end}} {{end}}
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}} {{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
{{end}} {{end}}
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script> <script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}} {{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
<script src="/_/page/tasks.js"></script> <script src="{{asset "page/tasks.js"}}"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
<script src="/_/page/sections.js"></script> <script src="{{asset "page/sections.js"}}"></script>
{{end}} {{end}}
<script src="/_/page/contents.js"></script> <script src="{{asset "page/contents.js"}}"></script>
{{end}} {{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}} {{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Save link</title> <title>Save link</title>
<link rel="icon" href="/_/favicon.ico" /> <link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="stylesheet" href="/_/style.css" /> <link rel="stylesheet" href="{{asset "style.css"}}" />
<style> <style>
body { padding: 0.8rem; } body { padding: 0.8rem; }
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; } .qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
+1 -1
View File
@@ -1,4 +1,4 @@
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}} {{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}} {{define "searchQuery"}}{{.Query}}{{end}}
+1 -1
View File
@@ -335,7 +335,7 @@ type calendarData struct {
Years []calYear Years []calYear
} }
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html")) var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now() today := time.Now()
+1 -1
View File
@@ -470,7 +470,7 @@ func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
// === Rendering === // === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML { func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now()) data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
+5 -30
View File
@@ -2,12 +2,9 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"flag" "flag"
"html/template" "html/template"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@@ -23,26 +20,11 @@ import (
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle, // tmplFuncs is shared by every layout-based template: asset links versioned
// appended as ?v=… to its <script> src. The bundle is served immutable under a // static files (see static.go); the layout header renders the maintenance
// stable filename, so without this query a rebuilt bundle would never reach a // widget.
// client that already cached the old one (this is the editor cache-bust knob).
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
func hashAsset(name string) string {
b, err := assets.ReadFile(name)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:12]
}
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle;
// the layout header renders the maintenance widget.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion }, "asset": assetURL,
"fileIcon": fileIcon, "fileIcon": fileIcon,
"icon": readIcon, "icon": readIcon,
"maintenanceWidget": maintenanceWidget, "maintenanceWidget": maintenanceWidget,
@@ -137,14 +119,7 @@ func main() {
} }
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey} h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
staticFS, _ := fs.Sub(assets, "assets") http.Handle("/_/", serveStatic())
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
static.ServeHTTP(w, r)
}))
http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
) )
var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html")) var quickAddTmpl = template.Must(template.New("quickadd.html").Funcs(assetFuncs).ParseFS(assets, "assets/quickadd.html"))
type quickAddData struct { type quickAddData struct {
To, URL, Title string To, URL, Title string
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
)
// assetVersions maps each embedded asset (path relative to assets/, e.g.
// "page/toc.js") to a short hash of its content. Templates reference assets
// through assetURL, which appends the hash as ?v=…, so a versioned URL names
// one exact file content and can be cached forever; a rebuilt asset gets a
// new URL.
var assetVersions = hashAssets()
func hashAssets() map[string]string {
versions := map[string]string{}
_ = 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
}
sum := sha256.Sum256(b)
versions[strings.TrimPrefix(p, "assets/")] = hex.EncodeToString(sum[:])[:12]
return nil
})
return versions
}
// 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.
func assetURL(name string) (string, error) {
v, ok := assetVersions[name]
if !ok {
return "", fmt.Errorf("unknown asset %q", name)
}
return "/_/" + name + "?v=" + v, nil
}
// assetFuncs is for the standalone fragment templates that do not take the
// full tmplFuncs.
var assetFuncs = template.FuncMap{"asset": assetURL}
// serveStatic serves the embedded assets under /_/. Embedded files carry no
// modtime, so http.FileServer alone sends no validators and browsers refetch
// every asset on every page. Instead:
// - a URL carrying the current ?v= hash is immutable;
// - fonts are immutable too (referenced unversioned from style.css, never
// 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.
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]
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+`"`)
}
static.ServeHTTP(w, r)
})
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"io/fs"
"net/http"
"net/http/httptest"
"regexp"
"testing"
)
// Every {{asset "…"}} in the templates must name an embedded file, or the page
// fails to render.
func TestTemplateAssetsExist(t *testing.T) {
ref := regexp.MustCompile(`\{\{asset "([^"]+)"\}\}`)
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, _ := assets.ReadFile(p)
for _, m := range ref.FindAllSubmatch(b, -1) {
if _, err := assetURL(string(m[1])); err != nil {
t.Errorf("%s: %v", p, err)
}
}
return nil
})
}
func TestServeStaticCaching(t *testing.T) {
h := serveStatic()
get := func(url, ifNoneMatch string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, url, nil)
if ifNoneMatch != "" {
r.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
v := assetVersions["style.css"]
versioned, _ := assetURL("style.css")
if w := get(versioned, ""); w.Code != 200 || w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Errorf("versioned: %d %q", w.Code, w.Header().Get("Cache-Control"))
}
for _, u := range []string{"/_/style.css", "/_/style.css?v=stale"} {
w := get(u, "")
if w.Code != 200 || w.Header().Get("Cache-Control") != "no-cache" || w.Header().Get("ETag") != `"`+v+`"` {
t.Errorf("%s: %d %q %q", u, w.Code, w.Header().Get("Cache-Control"), w.Header().Get("ETag"))
}
}
if w := get("/_/style.css", `"`+v+`"`); w.Code != http.StatusNotModified {
t.Errorf("revalidate: got %d, want 304", w.Code)
}
if w := get("/_/fonts/IosevkaSlab.woff2", ""); w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Errorf("font: %q", w.Header().Get("Cache-Control"))
}
}
+30 -2
View File
@@ -199,6 +199,9 @@ func pathDigest(srcFS string, info os.FileInfo) string {
// On a cache hit (path + mtime + size unchanged) the returned data is nil, // On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read // so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse. // once and the contents are returned for the caller to reuse.
//
// Hits come from memory or, after a restart, from the on-disk index (see
// digestIndexPath), so an unchanged file is hashed only once ever.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) { func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock() digestCacheMu.Lock()
d, ok := digestCache[srcFS] d, ok := digestCache[srcFS]
@@ -207,18 +210,43 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
return d.hex, nil, nil return d.hex, nil, nil
} }
indexFS := digestIndexPath(srcFS, info)
if b, err := os.ReadFile(indexFS); err == nil && len(b) == sha256.Size*2 {
h := string(b)
rememberDigest(srcFS, info, h)
return h, nil, nil
}
data, err := os.ReadFile(srcFS) data, err := os.ReadFile(srcFS)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:]) h := hex.EncodeToString(sum[:])
rememberDigest(srcFS, info, h)
// Best effort: a failed write only costs a re-hash after the next restart.
if err := os.MkdirAll(filepath.Dir(indexFS), 0755); err == nil {
if err := writeFileAtomic(indexFS, []byte(h), 0644); err != nil {
log.Printf("thumb digest index %s: %v", indexFS, err)
}
}
return h, data, nil
}
// digestIndexPath is where the content digest of srcFS is persisted, keyed on
// path + mtime + size like pathDigest, so overwriting the file misses and
// re-hashes. Entries for changed or deleted files are left behind; each is 64
// bytes.
func digestIndexPath(srcFS string, info os.FileInfo) string {
key := pathDigest(srcFS, info)
return filepath.Join(thumbCacheDir, "digests", key[:2], key)
}
func rememberDigest(srcFS string, info os.FileInfo, h string) {
digestCacheMu.Lock() digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h} digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock() digestCacheMu.Unlock()
return h, data, nil
} }
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) { func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
+33
View File
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"image" "image"
"image/jpeg" "image/jpeg"
"os"
"path/filepath"
"testing" "testing"
) )
@@ -54,3 +56,34 @@ func BenchmarkResizeBox(b *testing.B) {
} }
}) })
} }
// After a restart (empty memory cache) the digest comes from the on-disk index
// without reading the source file.
func TestSourceDigestPersists(t *testing.T) {
old := thumbCacheDir
thumbCacheDir = t.TempDir()
t.Cleanup(func() { thumbCacheDir = old })
src := filepath.Join(t.TempDir(), "a.jpg")
if err := os.WriteFile(src, []byte("photo"), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(src)
h1, data, err := sourceDigest(src, info)
if err != nil || data == nil {
t.Fatalf("first call: %v, data=%v", err, data)
}
digestCacheMu.Lock()
delete(digestCache, src)
digestCacheMu.Unlock()
// Removing the source proves the second call does not read it again.
if err := os.Remove(src); err != nil {
t.Fatal(err)
}
h2, data, err := sourceDigest(src, info)
if err != nil || data != nil || h2 != h1 {
t.Fatalf("after restart: h=%s data=%v err=%v, want %s from index", h2, data, err, h1)
}
}