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..f4c77ae --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,202 @@ +// 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 = New() +// +// 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 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 +// settable at the component level in the template as an override: +// +// // Set memory and default TTL +// var cache = New(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 [New]) 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(5 * time.Minute) +const defaultMem = 64 * 1024 * 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 + +// 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 New(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. +func WithTTL(d time.Duration) Option { + return func(c *Component) { + c.ttl = d + } +} + +// 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 maxBytes == 0 { + maxBytes = math.MaxInt + } + + c.lru = newLRU(maxBytes) + } +} + +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 { + if cc, isCached := c.lru.get(c.key); 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..08b49e3 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,341 @@ +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 = New(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 = New(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 = New() + 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 = New() + 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 = New(WithMaxMemory(64 * 1024)) + 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 = New(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) +} + +func TestDefaultMemory(t *testing.T) { + ctx := context.Background() + cache = New() + ctl := cache("") + + large := strings.Repeat("A", 30000000) + Outer("1", large).Render(ctx, io.Discard) + 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) { + ctx := context.Background() + + cache = New(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 = New(WithMaxMemory(64 * 1024)) + 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 = New(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..d9390e5 --- /dev/null +++ b/cache/test.templ @@ -0,0 +1,32 @@ +package cache + +import "time" + +var cache = New() + +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") } + } +}