Monorepo for Tangled tangled.org

appview/pages: rework caching mechanism

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 <me@oppi.li>

oppi.li e11ecc53 21a3b2a6

verified
Changed files
+132 -84
appview
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+97 -84
appview/pages/pages.go
··· 42 42 var Files embed.FS 43 43 44 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 47 48 48 avatar config.AvatarConfig 49 49 resolver *idresolver.Resolver ··· 65 65 66 66 p := &Pages{ 67 67 mu: sync.RWMutex{}, 68 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 69 69 dev: config.Core.Dev, 70 70 avatar: config.Avatar, 71 71 rctx: rctx, ··· 74 74 logger: slog.Default().With("component", "pages"), 75 75 } 76 76 77 - // Initial load of all templates 78 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 79 82 80 83 return p 84 + } 85 + 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 + 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 81 93 } 82 94 83 95 func (p *Pages) fragmentPaths() ([]string, error) { ··· 105 117 return fragmentPaths, nil 106 118 } 107 119 108 - func (p *Pages) loadAllTemplates() { 109 - if p.dev { 110 - p.embedFS = os.DirFS(p.templateDir) 111 - } else { 112 - p.embedFS = Files 113 - } 114 - 115 - l := p.logger.With("handler", "loadAllTemplates") 116 - templates := make(map[string]*template.Template) 120 + func (p *Pages) fragments() (*template.Template, error) { 117 121 fragmentPaths, err := p.fragmentPaths() 118 122 if err != nil { 119 - l.Error("failed to collect fragments", "err", err) 120 - return 123 + return nil, err 121 124 } 122 125 126 + funcs := p.funcMap() 127 + 123 128 // parse all fragments together 124 - allFragments := template.New("").Funcs(p.funcMap()) 129 + allFragments := template.New("").Funcs(funcs) 125 130 for _, f := range fragmentPaths { 126 - name := strings.TrimPrefix(f, "templates/") 127 - name = strings.TrimSuffix(name, ".html") 128 - pf, err := template.New(name).Funcs(p.funcMap()).ParseFS(p.embedFS, f) 131 + name := p.pathToName(f) 132 + 133 + pf, err := template.New(name). 134 + Funcs(funcs). 135 + ParseFS(p.embedFS, f) 129 136 if err != nil { 130 - l.Error("failed to parse fragment", "name", name, "path", f) 131 - return 137 + return nil, err 132 138 } 139 + 133 140 allFragments, err = allFragments.AddParseTree(name, pf.Tree) 134 141 if err != nil { 135 - l.Error("failed to add parse tree", "name", name, "path", f) 136 - return 142 + return nil, err 137 143 } 138 - templates[name] = allFragments.Lookup(name) 144 + } 145 + 146 + return allFragments, nil 147 + } 148 + 149 + // parse without memoization 150 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 151 + paths, err := p.fragmentPaths() 152 + if err != nil { 153 + return nil, err 154 + } 155 + for _, s := range stack { 156 + paths = append(paths, p.nameToPath(s)) 139 157 } 140 - // Then walk through and setup the rest of the templates 141 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 142 - if err != nil { 143 - return err 144 - } 145 - if d.IsDir() { 146 - return nil 147 - } 148 - if !strings.HasSuffix(path, "html") { 149 - return nil 150 - } 151 - // Skip fragments as they've already been loaded 152 - if strings.Contains(path, "fragments/") { 153 - return nil 154 - } 155 - // Skip layouts 156 - if strings.Contains(path, "layouts/") { 157 - return nil 158 - } 159 - name := strings.TrimPrefix(path, "templates/") 160 - name = strings.TrimSuffix(name, ".html") 161 - // Add the page template on top of the base 162 - allPaths := []string{} 163 - allPaths = append(allPaths, "templates/layouts/*.html") 164 - allPaths = append(allPaths, fragmentPaths...) 165 - allPaths = append(allPaths, path) 166 - tmpl, err := template.New(name). 167 - Funcs(p.funcMap()). 168 - ParseFS(p.embedFS, allPaths...) 169 - if err != nil { 170 - return fmt.Errorf("setting up template: %w", err) 171 - } 172 - templates[name] = tmpl 173 - l.Debug("loaded all templates") 174 - return nil 175 - }) 158 + 159 + funcs := p.funcMap() 160 + top := stack[len(stack)-1] 161 + parsed, err := template.New(top). 162 + Funcs(funcs). 163 + ParseFS(p.embedFS, paths...) 176 164 if err != nil { 177 - l.Error("walking template dir", "err", err) 178 - panic(err) 165 + return nil, err 179 166 } 180 167 181 - l.Info("loaded all templates", "total", len(templates)) 182 - p.mu.Lock() 183 - defer p.mu.Unlock() 184 - p.t = templates 168 + return parsed, nil 185 169 } 186 170 187 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 188 - // In dev mode, reparse templates from disk before executing 189 - if p.dev { 190 - p.loadAllTemplates() 171 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 172 + key := strings.Join(stack, "|") 173 + 174 + // never cache in dev mode 175 + if cached, exists := p.cache.Get(key); !p.dev && exists { 176 + return cached, nil 191 177 } 192 178 193 - p.mu.RLock() 194 - defer p.mu.RUnlock() 195 - tmpl, exists := p.t[templateName] 196 - if !exists { 197 - return fmt.Errorf("template not found: %s", templateName) 179 + result, err := p.rawParse(stack...) 180 + if err != nil { 181 + return nil, err 198 182 } 199 183 200 - if base == "" { 201 - return tmpl.Execute(w, params) 202 - } else { 203 - return tmpl.ExecuteTemplate(w, base, params) 184 + p.cache.Set(key, result) 185 + return result, nil 186 + } 187 + 188 + func (p *Pages) parseBase(top string) (*template.Template, error) { 189 + stack := []string{ 190 + "layouts/base", 191 + top, 204 192 } 193 + return p.parse(stack...) 205 194 } 206 195 207 - func (p *Pages) execute(name string, w io.Writer, params any) error { 208 - return p.executeOrReload(name, w, "layouts/base", params) 196 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 197 + stack := []string{ 198 + "layouts/base", 199 + "layouts/repobase", 200 + top, 201 + } 202 + return p.parse(stack...) 209 203 } 210 204 211 205 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 212 - return p.executeOrReload(name, w, "", params) 206 + tpl, err := p.parse(name) 207 + if err != nil { 208 + return err 209 + } 210 + 211 + return tpl.Execute(w, params) 212 + } 213 + 214 + func (p *Pages) execute(name string, w io.Writer, params any) error { 215 + tpl, err := p.parseBase(name) 216 + if err != nil { 217 + return err 218 + } 219 + 220 + return tpl.ExecuteTemplate(w, "layouts/base", params) 213 221 } 214 222 215 223 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 216 - return p.executeOrReload(name, w, "layouts/repobase", params) 224 + tpl, err := p.parseRepoBase(name) 225 + if err != nil { 226 + return err 227 + } 228 + 229 + return tpl.ExecuteTemplate(w, "layouts/base", params) 217 230 } 218 231 219 232 func (p *Pages) Favicon(w io.Writer) error {