appview/pages: rework caching mechanism #537

merged
opened by oppi.li targeting master from push-mvmrzuxwmzvs

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

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 81 84 } 82 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" 93 + } 94 + 83 95 func (p *Pages) fragmentPaths() ([]string, error) { 84 96 var fragmentPaths []string 85 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) 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) 139 144 } 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 - }) 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() 176 152 if err != nil { 177 - l.Error("walking template dir", "err", err) 178 - panic(err) 153 + return nil, err 154 + } 155 + for _, s := range stack { 156 + paths = append(paths, p.nameToPath(s)) 179 157 } 180 158 181 - l.Info("loaded all templates", "total", len(templates)) 182 - p.mu.Lock() 183 - defer p.mu.Unlock() 184 - p.t = templates 159 + funcs := p.funcMap() 160 + top := stack[len(stack)-1] 161 + parsed, err := template.New(top). 162 + Funcs(funcs). 163 + ParseFS(p.embedFS, paths...) 164 + if err != nil { 165 + return nil, err 166 + } 167 + 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 {