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 var Files embed.FS 43 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver ··· 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 - t: make(map[string]*template.Template), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, ··· 74 logger: slog.Default().With("component", "pages"), 75 } 76 77 - // Initial load of all templates 78 - p.loadAllTemplates() 79 80 return p 81 } 82 83 func (p *Pages) fragmentPaths() ([]string, error) { 84 var fragmentPaths []string 85 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { ··· 105 return fragmentPaths, nil 106 } 107 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) 117 fragmentPaths, err := p.fragmentPaths() 118 if err != nil { 119 - l.Error("failed to collect fragments", "err", err) 120 - return 121 } 122 123 // parse all fragments together 124 - allFragments := template.New("").Funcs(p.funcMap()) 125 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) 129 if err != nil { 130 - l.Error("failed to parse fragment", "name", name, "path", f) 131 - return 132 } 133 allFragments, err = allFragments.AddParseTree(name, pf.Tree) 134 if err != nil { 135 - l.Error("failed to add parse tree", "name", name, "path", f) 136 - return 137 } 138 - templates[name] = allFragments.Lookup(name) 139 } 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 - }) 176 if err != nil { 177 - l.Error("walking template dir", "err", err) 178 - panic(err) 179 } 180 181 - l.Info("loaded all templates", "total", len(templates)) 182 - p.mu.Lock() 183 - defer p.mu.Unlock() 184 - p.t = templates 185 } 186 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() 191 } 192 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) 198 } 199 200 - if base == "" { 201 - return tmpl.Execute(w, params) 202 - } else { 203 - return tmpl.ExecuteTemplate(w, base, params) 204 } 205 } 206 207 - func (p *Pages) execute(name string, w io.Writer, params any) error { 208 - return p.executeOrReload(name, w, "layouts/base", params) 209 } 210 211 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 212 - return p.executeOrReload(name, w, "", params) 213 } 214 215 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 216 - return p.executeOrReload(name, w, "layouts/repobase", params) 217 } 218 219 func (p *Pages) Favicon(w io.Writer) error {
··· 42 var Files embed.FS 43 44 type Pages struct { 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver ··· 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 + cache: NewTmplCache[string, *template.Template](), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, ··· 74 logger: slog.Default().With("component", "pages"), 75 } 76 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 82 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" 93 + } 94 + 95 func (p *Pages) fragmentPaths() ([]string, error) { 96 var fragmentPaths []string 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { ··· 117 return fragmentPaths, nil 118 } 119 120 + func (p *Pages) fragments() (*template.Template, error) { 121 fragmentPaths, err := p.fragmentPaths() 122 if err != nil { 123 + return nil, err 124 } 125 126 + funcs := p.funcMap() 127 + 128 // parse all fragments together 129 + allFragments := template.New("").Funcs(funcs) 130 for _, f := range fragmentPaths { 131 + name := p.pathToName(f) 132 + 133 + pf, err := template.New(name). 134 + Funcs(funcs). 135 + ParseFS(p.embedFS, f) 136 if err != nil { 137 + return nil, err 138 } 139 + 140 allFragments, err = allFragments.AddParseTree(name, pf.Tree) 141 if err != nil { 142 + return nil, err 143 } 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)) 157 } 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...) 164 + if err != nil { 165 + return nil, err 166 + } 167 + 168 + return parsed, nil 169 } 170 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 177 } 178 179 + result, err := p.rawParse(stack...) 180 + if err != nil { 181 + return nil, err 182 } 183 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, 192 } 193 + return p.parse(stack...) 194 } 195 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...) 203 } 204 205 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 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) 221 } 222 223 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 224 + tpl, err := p.parseRepoBase(name) 225 + if err != nil { 226 + return err 227 + } 228 + 229 + return tpl.ExecuteTemplate(w, "layouts/base", params) 230 } 231 232 func (p *Pages) Favicon(w io.Writer) error {