From e11ecc53a3b5964692d69a0bf6276dee15b312b7 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 27 Aug 2025 15:26:11 +0100 Subject: [PATCH] appview/pages: rework caching mechanism Change-Id: vsmvwnuslqpylvywknmoslrtywkyxysm instead of loading all templates at once and storing into a map, we now memoize the results of `parse`. the first call to `parse` will require calculation but subsequent calls will be cached. this is simpler to reason about because the new execution model requires us to parse differently for each "base" template that is being used: - for timeline, it is necessary to parse with layouts/base - for repo-index, it is necessary to parse with layouts/base and layouts/repobase in that order the previous approach to loading also had a latent bug: all layouts were loaded atop each other in alphabetical order (order of iteration over the filesystem), and therefore it was not possible to selectively parse and execute templates on a subset of layouts. Signed-off-by: oppiliappan --- appview/pages/cache.go | 35 ++++++++ appview/pages/pages.go | 181 ++++++++++++++++++++++------------------- 2 files changed, 132 insertions(+), 84 deletions(-) create mode 100644 appview/pages/cache.go diff --git a/appview/pages/cache.go b/appview/pages/cache.go new file mode 100644 index 00000000..e0b3dd7d --- /dev/null +++ b/appview/pages/cache.go @@ -0,0 +1,35 @@ +package pages + +import ( + "sync" +) + +type TmplCache[K comparable, V any] struct { + data map[K]V + mutex sync.RWMutex +} + +func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { + return &TmplCache[K, V]{ + data: make(map[K]V), + } +} + +func (c *TmplCache[K, V]) Get(key K) (V, bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + val, exists := c.data[key] + return val, exists +} + +func (c *TmplCache[K, V]) Set(key K, value V) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.data[key] = value +} + +func (c *TmplCache[K, V]) Size() int { + c.mutex.RLock() + defer c.mutex.RUnlock() + return len(c.data) +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index cb743f38..0a383e63 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -42,8 +42,8 @@ import ( var Files embed.FS type Pages struct { - mu sync.RWMutex - t map[string]*template.Template + mu sync.RWMutex + cache *TmplCache[string, *template.Template] avatar config.AvatarConfig resolver *idresolver.Resolver @@ -65,7 +65,7 @@ func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { p := &Pages{ mu: sync.RWMutex{}, - t: make(map[string]*template.Template), + cache: NewTmplCache[string, *template.Template](), dev: config.Core.Dev, avatar: config.Avatar, rctx: rctx, @@ -74,12 +74,24 @@ func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { logger: slog.Default().With("component", "pages"), } - // Initial load of all templates - p.loadAllTemplates() + if p.dev { + p.embedFS = os.DirFS(p.templateDir) + } else { + p.embedFS = Files + } return p } +func (p *Pages) pathToName(s string) string { + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") +} + +// reverse of pathToName +func (p *Pages) nameToPath(s string) string { + return "templates/" + s + ".html" +} + func (p *Pages) fragmentPaths() ([]string, error) { var fragmentPaths []string err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { @@ -105,115 +117,116 @@ func (p *Pages) fragmentPaths() ([]string, error) { return fragmentPaths, nil } -func (p *Pages) loadAllTemplates() { - if p.dev { - p.embedFS = os.DirFS(p.templateDir) - } else { - p.embedFS = Files - } - - l := p.logger.With("handler", "loadAllTemplates") - templates := make(map[string]*template.Template) +func (p *Pages) fragments() (*template.Template, error) { fragmentPaths, err := p.fragmentPaths() if err != nil { - l.Error("failed to collect fragments", "err", err) - return + return nil, err } + funcs := p.funcMap() + // parse all fragments together - allFragments := template.New("").Funcs(p.funcMap()) + allFragments := template.New("").Funcs(funcs) for _, f := range fragmentPaths { - name := strings.TrimPrefix(f, "templates/") - name = strings.TrimSuffix(name, ".html") - pf, err := template.New(name).Funcs(p.funcMap()).ParseFS(p.embedFS, f) + name := p.pathToName(f) + + pf, err := template.New(name). + Funcs(funcs). + ParseFS(p.embedFS, f) if err != nil { - l.Error("failed to parse fragment", "name", name, "path", f) - return + return nil, err } + allFragments, err = allFragments.AddParseTree(name, pf.Tree) if err != nil { - l.Error("failed to add parse tree", "name", name, "path", f) - return + return nil, err } - templates[name] = allFragments.Lookup(name) } - // Then walk through and setup the rest of the templates - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, "html") { - return nil - } - // Skip fragments as they've already been loaded - if strings.Contains(path, "fragments/") { - return nil - } - // Skip layouts - if strings.Contains(path, "layouts/") { - return nil - } - name := strings.TrimPrefix(path, "templates/") - name = strings.TrimSuffix(name, ".html") - // Add the page template on top of the base - allPaths := []string{} - allPaths = append(allPaths, "templates/layouts/*.html") - allPaths = append(allPaths, fragmentPaths...) - allPaths = append(allPaths, path) - tmpl, err := template.New(name). - Funcs(p.funcMap()). - ParseFS(p.embedFS, allPaths...) - if err != nil { - return fmt.Errorf("setting up template: %w", err) - } - templates[name] = tmpl - l.Debug("loaded all templates") - return nil - }) + + return allFragments, nil +} + +// parse without memoization +func (p *Pages) rawParse(stack ...string) (*template.Template, error) { + paths, err := p.fragmentPaths() if err != nil { - l.Error("walking template dir", "err", err) - panic(err) + return nil, err + } + for _, s := range stack { + paths = append(paths, p.nameToPath(s)) } - l.Info("loaded all templates", "total", len(templates)) - p.mu.Lock() - defer p.mu.Unlock() - p.t = templates + funcs := p.funcMap() + top := stack[len(stack)-1] + parsed, err := template.New(top). + Funcs(funcs). + ParseFS(p.embedFS, paths...) + if err != nil { + return nil, err + } + + return parsed, nil } -func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { - // In dev mode, reparse templates from disk before executing - if p.dev { - p.loadAllTemplates() +func (p *Pages) parse(stack ...string) (*template.Template, error) { + key := strings.Join(stack, "|") + + // never cache in dev mode + if cached, exists := p.cache.Get(key); !p.dev && exists { + return cached, nil } - p.mu.RLock() - defer p.mu.RUnlock() - tmpl, exists := p.t[templateName] - if !exists { - return fmt.Errorf("template not found: %s", templateName) + result, err := p.rawParse(stack...) + if err != nil { + return nil, err } - if base == "" { - return tmpl.Execute(w, params) - } else { - return tmpl.ExecuteTemplate(w, base, params) + p.cache.Set(key, result) + return result, nil +} + +func (p *Pages) parseBase(top string) (*template.Template, error) { + stack := []string{ + "layouts/base", + top, } + return p.parse(stack...) } -func (p *Pages) execute(name string, w io.Writer, params any) error { - return p.executeOrReload(name, w, "layouts/base", params) +func (p *Pages) parseRepoBase(top string) (*template.Template, error) { + stack := []string{ + "layouts/base", + "layouts/repobase", + top, + } + return p.parse(stack...) } func (p *Pages) executePlain(name string, w io.Writer, params any) error { - return p.executeOrReload(name, w, "", params) + tpl, err := p.parse(name) + if err != nil { + return err + } + + return tpl.Execute(w, params) +} + +func (p *Pages) execute(name string, w io.Writer, params any) error { + tpl, err := p.parseBase(name) + if err != nil { + return err + } + + return tpl.ExecuteTemplate(w, "layouts/base", params) } func (p *Pages) executeRepo(name string, w io.Writer, params any) error { - return p.executeOrReload(name, w, "layouts/repobase", params) + tpl, err := p.parseRepoBase(name) + if err != nil { + return err + } + + return tpl.ExecuteTemplate(w, "layouts/base", params) } func (p *Pages) Favicon(w io.Writer) error { -- 2.43.0