* Refactor caching system to use pluggable stores The commit modernizes the caching implementation by introducing a pluggable store interface that allows different cache backends. Key changes: - Add Store interface for custom cache implementations - Create default TTL-based store for backwards compatibility - Add example LRU store for memory-bounded caching - Support cache store configuration via options pattern - Make cache cleanup logic implementation-specific - Add comprehensive tests and documentation The main goals were to: 1. Prevent unbounded memory growth through pluggable stores 2. Enable distributed caching support 3. Maintain backwards compatibility 4. Improve testability and maintainability Signed-off-by: franchb <hello@franchb.com> * Add custom cache stores docs and navigation Signed-off-by: franchb <hello@franchb.com> * Use GetOrCompute for atomic cache access The commit introduces an atomic GetOrCompute method to the cache interface and refactors all cache implementations to use it. This prevents race conditions and duplicate computations when multiple goroutines request the same uncached key simultaneously. The changes eliminate a time-of-check to time-of-use race condition in the original caching implementation, where separate Get/Set operations could lead to duplicate renders under high concurrency. With GetOrCompute, the entire check-compute-store operation happens atomically while holding the lock, ensuring only one goroutine computes a value for any given key. The API change is backwards compatible as the framework handles the GetOrCompute logic internally. Existing applications will automatically benefit from the * rename to WithCacheStore --------- Signed-off-by: franchb <hello@franchb.com> Co-authored-by: maddalax <jm@madev.me>
404 lines
11 KiB
Go
404 lines
11 KiB
Go
package h
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/maddalax/htmgo/framework/h/cache"
|
|
)
|
|
|
|
// A single key to represent the cache entry for non-per-key components.
|
|
const _singleCacheKey = "__htmgo_single_cache_key__"
|
|
|
|
type CachedNode struct {
|
|
cb func() *Element
|
|
isByKey bool
|
|
duration time.Duration
|
|
cache cache.Store[any, string]
|
|
}
|
|
|
|
type Entry struct {
|
|
expiration time.Time
|
|
html string
|
|
}
|
|
|
|
type GetElementFunc func() *Element
|
|
type GetElementFuncT[T any] func(T) *Element
|
|
type GetElementFuncT2[T any, T2 any] func(T, T2) *Element
|
|
type GetElementFuncT3[T any, T2 any, T3 any] func(T, T2, T3) *Element
|
|
type GetElementFuncT4[T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) *Element
|
|
|
|
type GetElementFuncWithKey[K comparable] func() (K, GetElementFunc)
|
|
type GetElementFuncTWithKey[K comparable, T any] func(T) (K, GetElementFunc)
|
|
type GetElementFuncT2WithKey[K comparable, T any, T2 any] func(T, T2) (K, GetElementFunc)
|
|
type GetElementFuncT3WithKey[K comparable, T any, T2 any, T3 any] func(T, T2, T3) (K, GetElementFunc)
|
|
type GetElementFuncT4WithKey[K comparable, T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) (K, GetElementFunc)
|
|
|
|
// CacheOption defines a function that configures a CachedNode.
|
|
type CacheOption func(*CachedNode)
|
|
|
|
// WithCacheStore allows providing a custom cache implementation for a cached component.
|
|
func WithCacheStore(store cache.Store[any, string]) CacheOption {
|
|
return func(c *CachedNode) {
|
|
c.cache = store
|
|
}
|
|
}
|
|
|
|
// DefaultCacheProvider is a package-level function that creates a default cache instance.
|
|
// Initially, this uses a TTL-based map cache, but could be swapped for an LRU cache later.
|
|
// Advanced users can override this for the entire application.
|
|
var DefaultCacheProvider = func() cache.Store[any, string] {
|
|
return cache.NewTTLStore[any, string]()
|
|
}
|
|
|
|
// Cached caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
|
// Please note this element is globally cached, and not per unique identifier / user.
|
|
// Use CachedPerKey to cache elements per unique identifier.
|
|
func Cached(duration time.Duration, cb GetElementFunc, opts ...CacheOption) func() *Element {
|
|
node := &CachedNode{
|
|
cb: cb,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func() *Element {
|
|
return element
|
|
}
|
|
}
|
|
|
|
// CachedPerKey caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
|
// The element is cached by the unique identifier that is returned by the callback function.
|
|
func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K], opts ...CacheOption) func() *Element {
|
|
node := &CachedNode{
|
|
isByKey: true,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func() *Element {
|
|
key, componentFunc := cb()
|
|
return &Element{
|
|
tag: CachedNodeByKeyEntry,
|
|
meta: &ByKeyEntry{
|
|
key: key,
|
|
parent: element,
|
|
cb: componentFunc,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
type ByKeyEntry struct {
|
|
key any
|
|
cb func() *Element
|
|
parent *Element
|
|
}
|
|
|
|
// CachedPerKeyT caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
|
// The element is cached by the unique identifier that is returned by the callback function.
|
|
func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T], opts ...CacheOption) func(T) *Element {
|
|
node := &CachedNode{
|
|
isByKey: true,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T) *Element {
|
|
key, componentFunc := cb(data)
|
|
return &Element{
|
|
tag: CachedNodeByKeyEntry,
|
|
meta: &ByKeyEntry{
|
|
key: key,
|
|
parent: element,
|
|
cb: componentFunc,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// CachedPerKeyT2 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
|
// The element is cached by the unique identifier that is returned by the callback function.
|
|
func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2], opts ...CacheOption) func(T, T2) *Element {
|
|
node := &CachedNode{
|
|
isByKey: true,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2) *Element {
|
|
key, componentFunc := cb(data, data2)
|
|
return &Element{
|
|
tag: CachedNodeByKeyEntry,
|
|
meta: &ByKeyEntry{
|
|
key: key,
|
|
parent: element,
|
|
cb: componentFunc,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// CachedPerKeyT3 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
|
// The element is cached by the unique identifier that is returned by the callback function.
|
|
func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3], opts ...CacheOption) func(T, T2, T3) *Element {
|
|
node := &CachedNode{
|
|
isByKey: true,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2, data3 T3) *Element {
|
|
key, componentFunc := cb(data, data2, data3)
|
|
return &Element{
|
|
tag: CachedNodeByKeyEntry,
|
|
meta: &ByKeyEntry{
|
|
key: key,
|
|
parent: element,
|
|
cb: componentFunc,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// CachedPerKeyT4 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
|
// The element is cached by the unique identifier that is returned by the callback function.
|
|
func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4], opts ...CacheOption) func(T, T2, T3, T4) *Element {
|
|
node := &CachedNode{
|
|
isByKey: true,
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
|
key, componentFunc := cb(data, data2, data3, data4)
|
|
return &Element{
|
|
tag: CachedNodeByKeyEntry,
|
|
meta: &ByKeyEntry{
|
|
key: key,
|
|
parent: element,
|
|
cb: componentFunc,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// CachedT caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
|
// Please note this element is globally cached, and not per unique identifier / user.
|
|
// Use CachedPerKey to cache elements per unique identifier.
|
|
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T], opts ...CacheOption) func(T) *Element {
|
|
node := &CachedNode{
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T) *Element {
|
|
node.cb = func() *Element {
|
|
return cb(data)
|
|
}
|
|
return element
|
|
}
|
|
}
|
|
|
|
// CachedT2 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
|
// Please note this element is globally cached, and not per unique identifier / user.
|
|
// Use CachedPerKey to cache elements per unique identifier.
|
|
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2], opts ...CacheOption) func(T, T2) *Element {
|
|
node := &CachedNode{
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2) *Element {
|
|
node.cb = func() *Element {
|
|
return cb(data, data2)
|
|
}
|
|
return element
|
|
}
|
|
}
|
|
|
|
// CachedT3 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
|
// Please note this element is globally cached, and not per unique identifier / user.
|
|
// Use CachedPerKey to cache elements per unique identifier.
|
|
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3], opts ...CacheOption) func(T, T2, T3) *Element {
|
|
node := &CachedNode{
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2, data3 T3) *Element {
|
|
node.cb = func() *Element {
|
|
return cb(data, data2, data3)
|
|
}
|
|
return element
|
|
}
|
|
}
|
|
|
|
// CachedT4 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
|
// Please note this element is globally cached, and not per unique identifier / user.
|
|
// Use CachedPerKey to cache elements per unique identifier.
|
|
func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4], opts ...CacheOption) func(T, T2, T3, T4) *Element {
|
|
node := &CachedNode{
|
|
duration: duration,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(node)
|
|
}
|
|
|
|
if node.cache == nil {
|
|
node.cache = DefaultCacheProvider()
|
|
}
|
|
|
|
element := &Element{
|
|
tag: CachedNodeTag,
|
|
meta: node,
|
|
}
|
|
|
|
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
|
node.cb = func() *Element {
|
|
return cb(data, data2, data3, data4)
|
|
}
|
|
return element
|
|
}
|
|
}
|
|
|
|
// ClearCache clears the cached HTML of the element. This is called automatically by the framework.
|
|
func (c *CachedNode) ClearCache() {
|
|
c.cache.Purge()
|
|
}
|
|
|
|
// ClearExpired is deprecated and does nothing. Cache expiration is now handled by the Store implementation.
|
|
func (c *CachedNode) ClearExpired() {
|
|
// No-op for backward compatibility
|
|
}
|
|
|
|
func (c *CachedNode) Render(ctx *RenderContext) {
|
|
if c.isByKey {
|
|
panic("CachedPerKey should not be rendered directly")
|
|
} else {
|
|
// For simple cached components, we use a single key
|
|
// Use GetOrCompute for atomic check-and-set
|
|
html := c.cache.GetOrCompute(_singleCacheKey, func() string {
|
|
return Render(c.cb())
|
|
}, c.duration)
|
|
ctx.builder.WriteString(html)
|
|
}
|
|
}
|
|
|
|
func (c *ByKeyEntry) Render(ctx *RenderContext) {
|
|
key := c.key
|
|
parentMeta := c.parent.meta.(*CachedNode)
|
|
|
|
// Use GetOrCompute for atomic check-and-set
|
|
html := parentMeta.cache.GetOrCompute(key, func() string {
|
|
return Render(c.cb())
|
|
}, parentMeta.duration)
|
|
ctx.builder.WriteString(html)
|
|
}
|