From 01a55c21f06725a804cc733d2ebe2690099a4cf9 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 16 Sep 2024 22:36:49 -0700 Subject: [PATCH 1/9] Initial commit of component cache --- cache/.gitignore | 2 + cache/README.md | 3 + cache/cache.go | 212 ++++++++++++++++++++++++++++ cache/cache_test.go | 330 ++++++++++++++++++++++++++++++++++++++++++++ cache/go.mod | 5 + cache/go.sum | 4 + cache/lru.go | 159 +++++++++++++++++++++ cache/test.templ | 32 +++++ 8 files changed, 747 insertions(+) create mode 100644 cache/.gitignore create mode 100644 cache/README.md create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 cache/go.mod create mode 100644 cache/go.sum create mode 100644 cache/lru.go create mode 100644 cache/test.templ diff --git a/cache/.gitignore b/cache/.gitignore new file mode 100644 index 0000000..3955453 --- /dev/null +++ b/cache/.gitignore @@ -0,0 +1,2 @@ +test_templ.go +coverage.* diff --git a/cache/README.md b/cache/README.md new file mode 100644 index 0000000..6fc2f07 --- /dev/null +++ b/cache/README.md @@ -0,0 +1,3 @@ +# cache + +Package cache implements an in-memory `templ` component cache. See the [package documentation](https://pkg.go.dev/github.com/templ-go/x/cache) for usage details. diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..9daed2c --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,212 @@ +/* +Package cache implements an in-memory [templ] component cache. This may offer performance +improvements for application with slow or deeply-nested components. To use, +create an instance of the cache and wrap the desired component: + + var cache = NewCache() + + templ MyPage() { + @cache("my_key") { + @ExpensiveComponent() + } + } + +# Details + +The rendered component will be cached and associated with the given key. The key should +be unique for the wrapped component. Any string can be used, so consider deriving +the key from parameters the component depends on. For example: + + templ CheckoutPage(user_id int) { + @cache(fmt.Sprintf("item_list-%d", user_id)) { + @ItemList(user_id) + } + } + +The cache defaults to 64k of storage and a 1 minute time-to-live (TTL) foritems. Once the +storage limit is reached, the least recently used items will be deleted. When a cached item +expires, it will be re-rendered when next needed. The storage and TTL are configurable when +the cache is created by including the [WithTTL] or [WithMaxMemory] options. The TTL is also +settable at the component level in the template as an override: + + // Set memory and default TTL + var cache = NewCache(WithMaxMemory(512000), WithTTL(5*time.Minute)) + + templ Homepage() { + @cache("menu") { + This will be cached for 5 minutes. + } + + @cache("stock-quote", WithTTL(30*time.Second)) { + This is rerendered every 30 seconds. + } + } + +The cache has functions for use outside of a template to access stats, reset, disable, etc. +To use these functions, first obtain a component instance with any key: + + cacheCtl := cache("") // any key works + cacheCtl.Remove("key_to_remove") // manually remove an item from the cache + +Cache instances (created with [NewCache]) are independent. They don't share any memory and may +have different settings. +*/ +package cache + +import ( + "bytes" + "context" + "io" + "math" + "time" + + "github.com/a-h/templ" +) + +const defaultTTL = time.Duration(time.Minute) +const defaultMem = 64 * 1024 + +// Component is the cache component for use in templates. +type Component struct { + ttl time.Duration + key string + initialized bool + lru *lru +} + +type Option func(c *Component) + +// ComponentBuilder creates CacheComponents for use in templates. +// +// See the package documentation for usage examples. +type ComponentBuilder func(key string, opts ...Option) Component + +// NewCache creates a cache and returns a builder function +// that can be used in templates. It accepts zero or more functional +// options (WithTTL(), WithMaxMemory()). +func NewCache(opts ...Option) ComponentBuilder { + base := Component{ + ttl: defaultTTL, + lru: newLRU(defaultMem), + } + + for _, opt := range opts { + opt(&base) + } + base.initialized = true + + return func(key string, opts ...Option) Component { + dupe := base + dupe.key = key + + for _, opt := range opts { + opt(&dupe) + } + + return dupe + } +} + +// WithTTL sets the default expiration duration for the cache, +// or the expiration for an individual component. If the duration +// is 0 then there is no expiration. +func WithTTL(d time.Duration) Option { + return func(c *Component) { + if d == 0 { + d = 100 * 365 * 24 * time.Hour + } + + c.ttl = d + } +} + +// WithMaxMemory sets the maximum memory used for the cache. Note +// that this will be ignored when set on individual components. If the +// size is 0 then there is no memory limit. +func WithMaxMemory(maxMem int) Option { + return func(c *Component) { + // This can't be changed after initialization + if c.initialized { + return + } + + if maxMem == 0 { + maxMem = math.MaxInt + } + + c.lru = newLRU(maxMem) + } +} + +type Stats struct { + MaxMemory int // maximum configured memory + UsedMemory int // memory used by cached items (including expired but not deleted items) + Items int // cached item count (including expired but not deleted items) + Reads int // total cache reads + Hits int // total cache hits +} + +// Stats returns basic cache statistics. These will be reset with Reset(). +func (c Component) Stats() Stats { + l := c.lru + + return Stats{ + MaxMemory: l.maxMem, + UsedMemory: l.mem, + Items: l.list.Len(), + Reads: l.reads, + Hits: l.hits, + } +} + +// Remove removes/invalidates the cached data for associated with key, if it exists. +func (c Component) Remove(key string) { + c.lru.deleteKey(key) +} + +// Disable will turn off (or back on) caching. This also has the effect of wiping the cache. +func (c *Component) Disable(disable bool) { + if disable { + c.lru.reset() + } + + c.lru.disabled = disable +} + +// Reset erases the cache and resets statistics. +func (c *Component) Reset() { + c.lru.reset() +} + +// Render will render child components, using cached data and caching results as needed. +func (c Component) Render(ctx context.Context, w io.Writer) error { + cc, isCached := c.lru.get(c.key) + if isCached { + _, err := w.Write(cc) + return err + } + + // Get children. + children := templ.GetChildren(ctx) + ctx = templ.ClearChildren(ctx) + + if children == nil { + return nil + } + + // Render children to a buffer. + var buf bytes.Buffer + err := children.Render(ctx, &buf) + + if err != nil { + return err + } + + // Cache the result. + c.lru.put(c.key, buf.Bytes(), c.ttl) + + // Write the result to the output. + _, err = w.Write(buf.Bytes()) + + return err +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..afa5f57 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,330 @@ +package cache + +import ( + "bytes" + "context" + "fmt" + "io" + "math/rand" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func TestCorrectness(t *testing.T) { + ctx := context.Background() + + cache = NewCache(WithTTL(10 * time.Millisecond)) + + // Does it render what we expect? + var buf bytes.Buffer + Outer("A", "AAA").Render(ctx, &buf) + equals(t, "AAA", buf.String()) + + buf.Reset() + Outer("B", "BBB").Render(ctx, &buf) + equals(t, "BBB", buf.String()) + + // This will be a cache read + buf.Reset() + Outer("A", "AAA").Render(ctx, &buf) + equals(t, "AAA", buf.String()) + + // It is actually caching? + + // This should be slow + tRender := timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + // This should be fast + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender < 5*time.Millisecond, "expected fast rendering") + + // Different key, so this should be slow again + tRender = timeIt(func() { Slow("T").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + // Now fast + tRender = timeIt(func() { Slow("T").Render(ctx, io.Discard) }) + assert(t, tRender < 5*time.Millisecond, "expected fast rendering") + + // Remove the item + ctl := cache("") + ctl.Remove("T") + tRender = timeIt(func() { Slow("T").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") +} + +func TestEviction(t *testing.T) { + ctx := context.Background() + + cache = NewCache(WithMaxMemory(100), WithTTL(50*time.Millisecond)) + ctl := cache("") + + Outer("A", "AAA").Render(ctx, io.Discard) + Outer("B", "BBB").Render(ctx, io.Discard) + Outer("C", "CCC").Render(ctx, io.Discard) + Outer("D", "DDD").Render(ctx, io.Discard) + + // Only three elements will first into the cache of 100 bytes + equals(t, 3, ctl.Stats().Items) + + // Wait long enough for everything to expire. Adding a new element + // will trigger evictList() and purge everything. + time.Sleep(60 * time.Millisecond) + Outer("E", "EEE").Render(ctx, io.Discard) + + equals(t, 1, ctl.Stats().Items) +} + +func TestDisable(t *testing.T) { + ctx := context.Background() + + cache = NewCache() + ctl := cache("") + ctl.Disable(true) + + // These should all be slow since the cache is disabled + tRender := timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + // Reenable cache + ctl.Disable(false) + + // First render will be slow + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + // Second should be fast + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender < 5*time.Millisecond, "expected fast rendering") +} + +func TestReset(t *testing.T) { + ctx := context.Background() + + cache = NewCache() + ctl := cache("") + + tRender := timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") + + // Fast response with cached data + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender < 5*time.Millisecond, "expected fast rendering") + + assert(t, ctl.Stats().UsedMemory > 0, "expected positive memory usage") + + ctl.Reset() + + assert(t, ctl.Stats().UsedMemory == 0, "expected no memory usage") + + // Slow response following cache reset + tRender = timeIt(func() { Slow("S").Render(ctx, io.Discard) }) + assert(t, tRender > 150*time.Millisecond, "expected slow rendering") +} + +func TestMaxMemory(t *testing.T) { + ctx := context.Background() + + cache = NewCache() + ctl := cache("") + + large := strings.Repeat("A", 50000) + Outer("1", large).Render(ctx, io.Discard) + equals(t, 50025, ctl.Stats().UsedMemory) + + Outer("2", large).Render(ctx, io.Discard) + equals(t, 50025, ctl.Stats().UsedMemory) + + cache = NewCache(WithMaxMemory(110000)) + ctl = cache("") + + Outer("1", large).Render(ctx, io.Discard) + equals(t, 50025, ctl.Stats().UsedMemory) + + Outer("2", large).Render(ctx, io.Discard) + equals(t, 2*50025, ctl.Stats().UsedMemory) + + // Unlimited + cache = NewCache(WithMaxMemory(0)) + ctl = cache("") + + large = strings.Repeat("A", 500000) + Outer("1", large).Render(ctx, io.Discard) + equals(t, 500025, ctl.Stats().UsedMemory) +} + +func TestLRUOrder(t *testing.T) { + ctx := context.Background() + + cache = NewCache(WithMaxMemory(110)) + + ctl := cache("") + + equals(t, 0, ctl.Stats().UsedMemory) + + Outer("A", "AAA").Render(ctx, io.Discard) + equals(t, 1, ctl.Stats().Items) + + Outer("A", "AAA").Render(ctx, io.Discard) + equals(t, 1, ctl.Stats().Items) + + Outer("B", "BBB").Render(ctx, io.Discard) + equals(t, 2, ctl.Stats().Items) + + Outer("C", "CCC").Render(ctx, io.Discard) + equals(t, 3, ctl.Stats().Items) + + // There is only room for 3 elements so this should push one + // out and leave the cache size unchanged + Outer("D", "DDD").Render(ctx, io.Discard) + equals(t, 3, ctl.Stats().Items) + + // Cache is now: D, C, B + equals(t, "D", peekFront(ctl)) + equals(t, "B", peekBack(ctl)) + + Outer("B", "BBB").Render(ctx, io.Discard) + + // Cache is now: B, D, C + equals(t, "B", peekFront(ctl)) + equals(t, "C", peekBack(ctl)) + + Outer("E", "EEE").Render(ctx, io.Discard) + + // Cache is now: E, B, D + equals(t, "E", peekFront(ctl)) + equals(t, "D", peekBack(ctl)) +} + +// Test a high-concurrency situation. +// +// To check the efficacy of this test, I disabled the mutexes in the LRU and found that +// it panics... so they're definitely service a purpose! +func TestConcurrency(t *testing.T) { + // t.Skip() + ctx := context.Background() + + cache = NewCache() + ctl := cache("") + + var wg sync.WaitGroup + + // Let 100 goroutines fight over 10000 cache entries + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + for i := 0; i < 10000; i++ { + r := rand.Intn(10000) + key := fmt.Sprintf("Key %d", r) + val := fmt.Sprintf("Val %d", r) + + var buf bytes.Buffer + Outer(key, val).Render(ctx, &buf) + equals(t, val, buf.String()) + } + + wg.Done() + + }() + } + + wg.Wait() + reads := 100 * 10000 + equals(t, reads, ctl.Stats().Reads) + hits := ctl.Stats().Hits + + ratio := float64(hits) / float64(reads) + // equals(t, 100*10000, ratio) + // Though it will vary slightly from run to run, the cache can hold + // about 1647 items, and since we're randomly choosing from 10000 + // entries the hit rate is around 16.7%. + assert(t, ratio > 0.15 && ratio < 0.175, "expected hit ratio near 0.167. Got %f", ratio) +} + +func TestLRUTTL(t *testing.T) { + ctx := context.Background() + + cache = NewCache(WithTTL(200 * time.Millisecond)) + + ctl := cache("") + equals(t, 0, ctl.Stats().UsedMemory) + + Outer("A", "AAA").Render(ctx, io.Discard) + OuterTTL("B", "BBB", 300*time.Millisecond).Render(ctx, io.Discard) + + time.Sleep(150 * time.Millisecond) + + var buf bytes.Buffer + Outer("A", "A-updated").Render(ctx, &buf) + equals(t, "AAA", buf.String()) + + buf.Reset() + Outer("B", "B-updated").Render(ctx, &buf) + equals(t, "BBB", buf.String()) + + time.Sleep(60 * time.Millisecond) + + buf.Reset() + Outer("A", "A-updated").Render(ctx, &buf) + equals(t, "A-updated", buf.String()) + + buf.Reset() + Outer("B", "B-updated").Render(ctx, &buf) + equals(t, "BBB", buf.String()) + + time.Sleep(100 * time.Millisecond) + + buf.Reset() + Outer("B", "B-updated").Render(ctx, &buf) + equals(t, "B-updated", buf.String()) +} + +func timeIt(f func()) time.Duration { + start := time.Now() + + f() + + return time.Since(start) +} + +func peekFront(c Component) string { + return c.lru.list.Front().Value.(*entry).key +} + +func peekBack(c Component) string { + return c.lru.list.Back().Value.(*entry).key +} + +/* + * Testing helpers, courtesy of https://github.com/benbjohnson/testing + */ + +// assert fails the test if the condition is false. +func assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// equals fails the test if exp is not equal to act. +func equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} diff --git a/cache/go.mod b/cache/go.mod new file mode 100644 index 0000000..26669d1 --- /dev/null +++ b/cache/go.mod @@ -0,0 +1,5 @@ +module cache + +go 1.21 + +require github.com/a-h/templ v0.2.778 diff --git a/cache/go.sum b/cache/go.sum new file mode 100644 index 0000000..94b4d4c --- /dev/null +++ b/cache/go.sum @@ -0,0 +1,4 @@ +github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM= +github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/cache/lru.go b/cache/lru.go new file mode 100644 index 0000000..9ecd548 --- /dev/null +++ b/cache/lru.go @@ -0,0 +1,159 @@ +package cache + +import ( + "container/list" + "sync" + "time" +) + +// lru implements a cache with Least Recently Used eviction +// policy. Expiration per item is tracked as well. Both expiration and +// eviction are handled lazily on read or write. For example, an expired +// key will be deleted when it is read, or if _evictExpired is called +// due to the memory limit being reached. +// +// All cache operations are under mutex protection. +type lru struct { + lock sync.RWMutex + maxMem int + mem int + cache map[string]*list.Element + list *list.List + earliestExpiration time.Time + disabled bool + + // stats + reads int + hits int +} + +type entry struct { + key string + value []byte + expiration time.Time +} + +// size calculates the total storage for item, including 24 bytes for +// the expiration time.Time. +func (e *entry) size() int { + return len(e.key) + len(e.value) + 24 +} + +func newLRU(maxMem int) *lru { + return &lru{ + maxMem: maxMem, + mem: 0, + cache: make(map[string]*list.Element), + list: list.New(), + earliestExpiration: time.Now().Add(24 * time.Hour), + } +} + +func (c *lru) reset() { + c.lock.Lock() + defer c.lock.Unlock() + + c.mem = 0 + c.list = list.New() + c.cache = make(map[string]*list.Element) +} + +func (c *lru) get(key string) ([]byte, bool) { + c.lock.Lock() + defer c.lock.Unlock() + + if c.disabled { + return nil, false + } + + c.reads++ + + if elem, ok := c.cache[key]; ok { + e := elem.Value.(*entry) + if time.Now().Before(e.expiration) { + c.hits++ + c.list.MoveToFront(elem) + return e.value, true + } + c._deleteKey(e.key) + } + + return nil, false +} + +func (c *lru) put(key string, value []byte, ttl time.Duration) { + c.lock.Lock() + defer c.lock.Unlock() + + if c.disabled { + return + } + + expiration := time.Now().Add(ttl) + + // Make sure the key is gone. Updating is possible but complicates size tracking. + c._deleteKey(key) + + newEntry := &entry{key: key, value: value, expiration: expiration} + elem := c.list.PushFront(newEntry) + c.cache[key] = elem + c.mem += newEntry.size() + + if expiration.Before(c.earliestExpiration) { + c.earliestExpiration = expiration + } + + // Bring cache size within max size + if c.mem > c.maxMem { + c._evictExpired() + + for c.mem > c.maxMem && c.list.Len() > 1 { + oldest := c.list.Back() + if oldest != nil { + c._deleteKey(oldest.Value.(*entry).key) + } + } + } +} + +func (c *lru) deleteKey(key string) { + c.lock.Lock() + defer c.lock.Unlock() + + c._deleteKey(key) +} + +// _deleteKey should only be called with the lock held. +func (c *lru) _deleteKey(key string) { + elem := c.cache[key] + if elem == nil { + return + } + + c.list.Remove(elem) + e := elem.Value.(*entry) + c.mem -= e.size() + delete(c.cache, e.key) +} + +// _evictExpired should only be called with the lock held. +func (c *lru) _evictExpired() { + now := time.Now() + if now.Before(c.earliestExpiration) { + return + } + + c.earliestExpiration = now.Add(24 * time.Hour) + + var next *list.Element + for elem := c.list.Back(); elem != nil; elem = next { + next = elem.Prev() + + e := elem.Value.(*entry) + if now.After(e.expiration) { + c._deleteKey(e.key) + } else if e.expiration.Before(c.earliestExpiration) { + c.earliestExpiration = e.expiration + } + } +} diff --git a/cache/test.templ b/cache/test.templ new file mode 100644 index 0000000..3438812 --- /dev/null +++ b/cache/test.templ @@ -0,0 +1,32 @@ +package cache + +import "time" + +var cache = NewCache() + +templ Outer(key, val string) { + @cache(key) { + @Inner(val) + } +} + +templ OuterTTL(key, val string, ttl time.Duration) { + @cache(key, WithTTL(ttl)) { + @Inner(val) + } +} + +templ Inner(label string) { +{ label } +} + +func delayPrint(s string) string { + time.Sleep(200*time.Millisecond) + return s +} + +templ Slow(key string) { + @cache(key) { + Slow: {delayPrint("boo")} + } +} From 6a63dcbc58f33129a678de67246c5ae9c8988594 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 16 Sep 2024 22:49:10 -0700 Subject: [PATCH 2/9] Update package comment --- cache/cache.go | 104 ++++++++++++++++++++++++------------------------- 1 file changed, 51 insertions(+), 53 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index 9daed2c..1f9d3c1 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,56 +1,54 @@ -/* -Package cache implements an in-memory [templ] component cache. This may offer performance -improvements for application with slow or deeply-nested components. To use, -create an instance of the cache and wrap the desired component: - - var cache = NewCache() - - templ MyPage() { - @cache("my_key") { - @ExpensiveComponent() - } - } - -# Details - -The rendered component will be cached and associated with the given key. The key should -be unique for the wrapped component. Any string can be used, so consider deriving -the key from parameters the component depends on. For example: - - templ CheckoutPage(user_id int) { - @cache(fmt.Sprintf("item_list-%d", user_id)) { - @ItemList(user_id) - } - } - -The cache defaults to 64k of storage and a 1 minute time-to-live (TTL) foritems. Once the -storage limit is reached, the least recently used items will be deleted. When a cached item -expires, it will be re-rendered when next needed. The storage and TTL are configurable when -the cache is created by including the [WithTTL] or [WithMaxMemory] options. The TTL is also -settable at the component level in the template as an override: - - // Set memory and default TTL - var cache = NewCache(WithMaxMemory(512000), WithTTL(5*time.Minute)) - - templ Homepage() { - @cache("menu") { - This will be cached for 5 minutes. - } - - @cache("stock-quote", WithTTL(30*time.Second)) { - This is rerendered every 30 seconds. - } - } - -The cache has functions for use outside of a template to access stats, reset, disable, etc. -To use these functions, first obtain a component instance with any key: - - cacheCtl := cache("") // any key works - cacheCtl.Remove("key_to_remove") // manually remove an item from the cache - -Cache instances (created with [NewCache]) are independent. They don't share any memory and may -have different settings. -*/ +// Package cache implements an in-memory [templ] component cache. This may offer performance +// improvements for applications with slow or deeply-nested components. To use, +// create an instance of the cache and wrap the desired component: +// +// var cache = NewCache() +// +// templ MyPage() { +// @cache("my_key") { +// @ExpensiveComponent() +// } +// } +// +// # Details +// +// The rendered component will be cached and associated with the given key. The key should +// be unique for the wrapped component. Any string can be used, so consider deriving +// the key from parameters the component depends on. For example: +// +// templ CheckoutPage(user_id int) { +// @cache(fmt.Sprintf("item_list-%d", user_id)) { +// @ItemList(user_id) +// } +// } +// +// The cache defaults to 64k of storage and a 1 minute time-to-live (TTL) for items. Once the +// storage limit is reached, the least recently used items will be deleted. When a cached item +// expires, it will be re-rendered when next needed. The storage and TTL are configurable when +// the cache is created by including the [WithTTL] or [WithMaxMemory] options. The TTL is also +// settable at the component level in the template as an override: +// +// // Set memory and default TTL +// var cache = NewCache(WithMaxMemory(512000), WithTTL(5*time.Minute)) +// +// templ Homepage() { +// @cache("menu") { +// This will be cached for 5 minutes. +// } +// +// @cache("stock-quote", WithTTL(30*time.Second)) { +// This is rerendered every 30 seconds. +// } +// } +// +// The cache has functions for use outside of a template to access stats, reset, disable, etc. +// To use these functions, first obtain a component instance with any key: +// +// cacheCtl := cache("") // any key works +// cacheCtl.Remove("key_to_remove") // manually remove an item from the cache +// +// Cache instances (created with [NewCache]) are independent. They don't share any memory and may +// have different settings. package cache import ( From 605cb924b132c9cec1722809bb2393240c64a916 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Fri, 20 Sep 2024 07:54:14 -0700 Subject: [PATCH 3/9] Update storage and TTL defaults --- cache/cache.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index 1f9d3c1..e37e988 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -22,7 +22,7 @@ // } // } // -// The cache defaults to 64k of storage and a 1 minute time-to-live (TTL) for items. Once the +// The cache defaults to 64MB of storage and a 5 minute time-to-live (TTL) for items. Once the // storage limit is reached, the least recently used items will be deleted. When a cached item // expires, it will be re-rendered when next needed. The storage and TTL are configurable when // the cache is created by including the [WithTTL] or [WithMaxMemory] options. The TTL is also @@ -61,8 +61,8 @@ import ( "github.com/a-h/templ" ) -const defaultTTL = time.Duration(time.Minute) -const defaultMem = 64 * 1024 +const defaultTTL = time.Duration(5 * time.Minute) +const defaultMem = 64 * 1024 * 1024 // Component is the cache component for use in templates. type Component struct { From b1e16edd10255013b12c29ff3045ecb0619e8400 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 07:59:13 -0700 Subject: [PATCH 4/9] Update cache/cache.go Co-authored-by: Adrian Hesketh --- cache/cache.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index e37e988..9aac9b1 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -178,8 +178,7 @@ func (c *Component) Reset() { // Render will render child components, using cached data and caching results as needed. func (c Component) Render(ctx context.Context, w io.Writer) error { - cc, isCached := c.lru.get(c.key) - if isCached { + if cc, isCached := c.lru.get(c.key); isCached { _, err := w.Write(cc) return err } From 20a90bcde72330d35ba6ac32e18ae466b454c096 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 07:59:39 -0700 Subject: [PATCH 5/9] Update cache/cache.go Co-authored-by: Adrian Hesketh --- cache/cache.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cache/cache.go b/cache/cache.go index 9aac9b1..9952590 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -186,7 +186,6 @@ func (c Component) Render(ctx context.Context, w io.Writer) error { // Get children. children := templ.GetChildren(ctx) ctx = templ.ClearChildren(ctx) - if children == nil { return nil } From eed890f16af1c57c2e3508d035d8b5700d7a1cff Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 07:59:49 -0700 Subject: [PATCH 6/9] Update cache/cache.go Co-authored-by: Adrian Hesketh --- cache/cache.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cache/cache.go b/cache/cache.go index 9952590..d33bb2f 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -193,7 +193,6 @@ func (c Component) Render(ctx context.Context, w io.Writer) error { // Render children to a buffer. var buf bytes.Buffer err := children.Render(ctx, &buf) - if err != nil { return err } From bbac50b6603fa530817d31a10eabe9af327fdf14 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 07:56:01 -0700 Subject: [PATCH 7/9] Fix tests broken during default change --- cache/cache_test.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/cache/cache_test.go b/cache/cache_test.go index afa5f57..f25a144 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -137,7 +137,7 @@ func TestReset(t *testing.T) { func TestMaxMemory(t *testing.T) { ctx := context.Background() - cache = NewCache() + cache = NewCache(WithMaxMemory(64 * 1024)) ctl := cache("") large := strings.Repeat("A", 50000) @@ -155,14 +155,25 @@ func TestMaxMemory(t *testing.T) { Outer("2", large).Render(ctx, io.Discard) equals(t, 2*50025, ctl.Stats().UsedMemory) +} - // Unlimited - cache = NewCache(WithMaxMemory(0)) - ctl = cache("") +func TestDefaultMemory(t *testing.T) { + ctx := context.Background() + cache = NewCache() + ctl := cache("") - large = strings.Repeat("A", 500000) + large := strings.Repeat("A", 30000000) Outer("1", large).Render(ctx, io.Discard) - equals(t, 500025, ctl.Stats().UsedMemory) + equals(t, 30000025, ctl.Stats().UsedMemory) + + Outer("2", large).Render(ctx, io.Discard) + equals(t, 60000050, ctl.Stats().UsedMemory) + + // This will push over the 64MB limit and evict + // one ~30MB string. + small := strings.Repeat("A", 10000000) + Outer("3", small).Render(ctx, io.Discard) + equals(t, 40000050, ctl.Stats().UsedMemory) } func TestLRUOrder(t *testing.T) { @@ -216,7 +227,7 @@ func TestConcurrency(t *testing.T) { // t.Skip() ctx := context.Background() - cache = NewCache() + cache = NewCache(WithMaxMemory(64 * 1024)) ctl := cache("") var wg sync.WaitGroup From 1e81c5adac397331132b82ee87e003899e3a0786 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 08:06:45 -0700 Subject: [PATCH 8/9] s/NewCache/New --- cache/cache.go | 10 +++++----- cache/cache_test.go | 20 ++++++++++---------- cache/test.templ | 26 +++++++++++++------------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index d33bb2f..e71b211 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -2,7 +2,7 @@ // improvements for applications with slow or deeply-nested components. To use, // create an instance of the cache and wrap the desired component: // -// var cache = NewCache() +// var cache = New() // // templ MyPage() { // @cache("my_key") { @@ -29,7 +29,7 @@ // settable at the component level in the template as an override: // // // Set memory and default TTL -// var cache = NewCache(WithMaxMemory(512000), WithTTL(5*time.Minute)) +// var cache = New(WithMaxMemory(512000), WithTTL(5*time.Minute)) // // templ Homepage() { // @cache("menu") { @@ -47,7 +47,7 @@ // cacheCtl := cache("") // any key works // cacheCtl.Remove("key_to_remove") // manually remove an item from the cache // -// Cache instances (created with [NewCache]) are independent. They don't share any memory and may +// Cache instances (created with [New]) are independent. They don't share any memory and may // have different settings. package cache @@ -79,10 +79,10 @@ type Option func(c *Component) // See the package documentation for usage examples. type ComponentBuilder func(key string, opts ...Option) Component -// NewCache creates a cache and returns a builder function +// New creates a cache and returns a builder function // that can be used in templates. It accepts zero or more functional // options (WithTTL(), WithMaxMemory()). -func NewCache(opts ...Option) ComponentBuilder { +func New(opts ...Option) ComponentBuilder { base := Component{ ttl: defaultTTL, lru: newLRU(defaultMem), diff --git a/cache/cache_test.go b/cache/cache_test.go index f25a144..08b49e3 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -18,7 +18,7 @@ import ( func TestCorrectness(t *testing.T) { ctx := context.Background() - cache = NewCache(WithTTL(10 * time.Millisecond)) + cache = New(WithTTL(10 * time.Millisecond)) // Does it render what we expect? var buf bytes.Buffer @@ -62,7 +62,7 @@ func TestCorrectness(t *testing.T) { func TestEviction(t *testing.T) { ctx := context.Background() - cache = NewCache(WithMaxMemory(100), WithTTL(50*time.Millisecond)) + cache = New(WithMaxMemory(100), WithTTL(50*time.Millisecond)) ctl := cache("") Outer("A", "AAA").Render(ctx, io.Discard) @@ -84,7 +84,7 @@ func TestEviction(t *testing.T) { func TestDisable(t *testing.T) { ctx := context.Background() - cache = NewCache() + cache = New() ctl := cache("") ctl.Disable(true) @@ -113,7 +113,7 @@ func TestDisable(t *testing.T) { func TestReset(t *testing.T) { ctx := context.Background() - cache = NewCache() + cache = New() ctl := cache("") tRender := timeIt(func() { Slow("S").Render(ctx, io.Discard) }) @@ -137,7 +137,7 @@ func TestReset(t *testing.T) { func TestMaxMemory(t *testing.T) { ctx := context.Background() - cache = NewCache(WithMaxMemory(64 * 1024)) + cache = New(WithMaxMemory(64 * 1024)) ctl := cache("") large := strings.Repeat("A", 50000) @@ -147,7 +147,7 @@ func TestMaxMemory(t *testing.T) { Outer("2", large).Render(ctx, io.Discard) equals(t, 50025, ctl.Stats().UsedMemory) - cache = NewCache(WithMaxMemory(110000)) + cache = New(WithMaxMemory(110000)) ctl = cache("") Outer("1", large).Render(ctx, io.Discard) @@ -159,7 +159,7 @@ func TestMaxMemory(t *testing.T) { func TestDefaultMemory(t *testing.T) { ctx := context.Background() - cache = NewCache() + cache = New() ctl := cache("") large := strings.Repeat("A", 30000000) @@ -179,7 +179,7 @@ func TestDefaultMemory(t *testing.T) { func TestLRUOrder(t *testing.T) { ctx := context.Background() - cache = NewCache(WithMaxMemory(110)) + cache = New(WithMaxMemory(110)) ctl := cache("") @@ -227,7 +227,7 @@ func TestConcurrency(t *testing.T) { // t.Skip() ctx := context.Background() - cache = NewCache(WithMaxMemory(64 * 1024)) + cache = New(WithMaxMemory(64 * 1024)) ctl := cache("") var wg sync.WaitGroup @@ -267,7 +267,7 @@ func TestConcurrency(t *testing.T) { func TestLRUTTL(t *testing.T) { ctx := context.Background() - cache = NewCache(WithTTL(200 * time.Millisecond)) + cache = New(WithTTL(200 * time.Millisecond)) ctl := cache("") equals(t, 0, ctl.Stats().UsedMemory) diff --git a/cache/test.templ b/cache/test.templ index 3438812..d9390e5 100644 --- a/cache/test.templ +++ b/cache/test.templ @@ -2,31 +2,31 @@ package cache import "time" -var cache = NewCache() +var cache = New() templ Outer(key, val string) { - @cache(key) { - @Inner(val) - } + @cache(key) { + @Inner(val) + } } templ OuterTTL(key, val string, ttl time.Duration) { - @cache(key, WithTTL(ttl)) { - @Inner(val) - } + @cache(key, WithTTL(ttl)) { + @Inner(val) + } } templ Inner(label string) { -{ label } + { label } } func delayPrint(s string) string { - time.Sleep(200*time.Millisecond) - return s + time.Sleep(200 * time.Millisecond) + return s } templ Slow(key string) { - @cache(key) { - Slow: {delayPrint("boo")} - } + @cache(key) { + Slow: { delayPrint("boo") } + } } From e22a52784d442c80ff16e262001e251bcac38745 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 24 Sep 2024 09:29:28 -0700 Subject: [PATCH 9/9] Address code review comments --- cache/cache.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index e71b211..f4c77ae 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -106,33 +106,28 @@ func New(opts ...Option) ComponentBuilder { } // WithTTL sets the default expiration duration for the cache, -// or the expiration for an individual component. If the duration -// is 0 then there is no expiration. +// or the expiration for an individual component. func WithTTL(d time.Duration) Option { return func(c *Component) { - if d == 0 { - d = 100 * 365 * 24 * time.Hour - } - c.ttl = d } } -// WithMaxMemory sets the maximum memory used for the cache. Note -// that this will be ignored when set on individual components. If the -// size is 0 then there is no memory limit. -func WithMaxMemory(maxMem int) Option { +// WithMaxMemory sets the maximum memory (in bytes) used for the cache. +// Note that this will be ignored when set on individual components. If +// the size is 0 then there is no memory limit. +func WithMaxMemory(maxBytes int) Option { return func(c *Component) { // This can't be changed after initialization if c.initialized { return } - if maxMem == 0 { - maxMem = math.MaxInt + if maxBytes == 0 { + maxBytes = math.MaxInt } - c.lru = newLRU(maxMem) + c.lru = newLRU(maxBytes) } }