Skip to content

Commit

Permalink
feat: templates caching
Browse files Browse the repository at this point in the history
  • Loading branch information
tarampampam committed Jul 2, 2024
1 parent 075bc8f commit 5698daa
Show file tree
Hide file tree
Showing 19 changed files with 315 additions and 57 deletions.
6 changes: 3 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const defaultJSONFormat string = `{
"service_name": {{ service_name | json }},
"service_port": {{ service_port | json }},
"request_id": {{ request_id | json }},
"timestamp": {{ now.Unix }}
"timestamp": {{ nowUnix }}
}{{ end }}
}
` // an empty line at the end is important for better UX
Expand All @@ -91,7 +91,7 @@ const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
<serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID>
<timestamp>{{ now.Unix }}</timestamp>
<timestamp>{{ nowUnix }}</timestamp>
</details>{{ end }}
</error>
` // an empty line at the end is important for better UX
Expand All @@ -107,7 +107,7 @@ Ingress Name: {{ ingress_name }}
Service Name: {{ service_name }}
Service Port: {{ service_port }}
Request ID: {{ request_id }}
Timestamp: {{ now.Unix }}{{ end }}
Timestamp: {{ nowUnix }}{{ end }}
` // an empty line at the end is important for better UX

//nolint:lll
Expand Down
111 changes: 111 additions & 0 deletions internal/http/handlers/error_page/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package error_page

import (
"bytes"
"crypto/md5" //nolint:gosec
"encoding/gob"
"sync"
"time"

"gh.tarampamp.am/error-pages/internal/template"
)

type (
// RenderedCache is a cache for rendered error pages. It's safe for concurrent use.
// It uses a hash of the template and props as a key.
//
// To remove expired items, call ClearExpired method periodically (a bit more often than the ttl).
RenderedCache struct {
ttl time.Duration

mu sync.RWMutex
items map[[32]byte]cacheItem // map[template_hash[0:15];props_hash[16:32]]cache_item
}

cacheItem struct {
content []byte
addedAtNano int64
}
)

// NewRenderedCache creates a new RenderedCache with the specified ttl.
func NewRenderedCache(ttl time.Duration) *RenderedCache {
return &RenderedCache{ttl: ttl, items: make(map[[32]byte]cacheItem)}
}

// genKey generates a key for the cache item by hashing the template and props.
func (rc *RenderedCache) genKey(template string, props template.Props) [32]byte {
var (
key [32]byte
th, ph = hash(template), hash(props) // template hash, props hash
)

copy(key[:16], th[:]) // first 16 bytes for the template hash
copy(key[16:], ph[:]) // last 16 bytes for the props hash

return key
}

// Has checks if the cache has an item with the specified template and props.
func (rc *RenderedCache) Has(template string, props template.Props) bool {
var key = rc.genKey(template, props)

rc.mu.RLock()
_, ok := rc.items[key]
rc.mu.RUnlock()

return ok
}

// Put adds a new item to the cache with the specified template, props, and content.
func (rc *RenderedCache) Put(template string, props template.Props, content []byte) {
var key = rc.genKey(template, props)

rc.mu.Lock()
rc.items[key] = cacheItem{content: content, addedAtNano: time.Now().UnixNano()}
rc.mu.Unlock()
}

// Get returns the content of the item with the specified template and props.
func (rc *RenderedCache) Get(template string, props template.Props) ([]byte, bool) {
var key = rc.genKey(template, props)

rc.mu.RLock()
item, ok := rc.items[key]
rc.mu.RUnlock()

return item.content, ok
}

// ClearExpired removes all expired items from the cache.
func (rc *RenderedCache) ClearExpired() {
rc.mu.Lock()

var now = time.Now().UnixNano()

for key, item := range rc.items {
if now-item.addedAtNano > rc.ttl.Nanoseconds() {
delete(rc.items, key)
}
}

rc.mu.Unlock()
}

// Clear removes all items from the cache.
func (rc *RenderedCache) Clear() {
rc.mu.Lock()
clear(rc.items)
rc.mu.Unlock()
}

// hash returns an MD5 hash of the provided value (it may be any built-in type).
func hash(in any) [16]byte {
var b bytes.Buffer

if err := gob.NewEncoder(&b).Encode(in); err != nil {
return [16]byte{} // never happens because we encode only built-in types
}

return md5.Sum(b.Bytes()) //nolint:gosec
}
86 changes: 86 additions & 0 deletions internal/http/handlers/error_page/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package error_page_test

import (
"strconv"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"

"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"gh.tarampamp.am/error-pages/internal/template"
)

func TestRenderedCache_CRUD(t *testing.T) {
t.Parallel()

var cache = error_page.NewRenderedCache(time.Millisecond)

t.Run("has", func(t *testing.T) {
assert.False(t, cache.Has("template", template.Props{}))
cache.Put("template", template.Props{}, []byte("content"))
assert.True(t, cache.Has("template", template.Props{}))

assert.False(t, cache.Has("template", template.Props{Code: 1}))
assert.False(t, cache.Has("foo", template.Props{Code: 1}))
})

t.Run("exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{})

assert.True(t, ok)
assert.Equal(t, []byte("content"), got)

cache.Clear()

assert.False(t, cache.Has("template", template.Props{}))
})

t.Run("not exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{Code: 2})

assert.False(t, ok)
assert.Nil(t, got)
})

t.Run("race condition provocation", func(t *testing.T) {
var wg sync.WaitGroup

for i := 0; i < 100; i++ {
wg.Add(2)

go func(i int) {
defer wg.Done()

cache.Get("template", template.Props{})
cache.Put("template"+strconv.Itoa(i), template.Props{}, []byte("content"))
cache.Has("template", template.Props{})
}(i)

go func() {
defer wg.Done()

cache.ClearExpired()
}()
}

wg.Wait()
})
}

func TestRenderedCache_Expiring(t *testing.T) {
t.Parallel()

var cache = error_page.NewRenderedCache(10 * time.Millisecond)

cache.Put("template", template.Props{}, []byte("content"))
cache.ClearExpired()
assert.True(t, cache.Has("template", template.Props{}))

<-time.After(10 * time.Millisecond)

assert.True(t, cache.Has("template", template.Props{})) // expired, but not cleared yet
cache.ClearExpired()
assert.False(t, cache.Has("template", template.Props{})) // cleared
}
111 changes: 81 additions & 30 deletions internal/http/handlers/error_page/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"

Expand All @@ -15,7 +16,32 @@ import (
)

// New creates a new handler that returns an error page with the specified status code and format.
func New(cfg *config.Config, log *logger.Logger) fasthttp.RequestHandler { //nolint:funlen,gocognit,gocyclo
func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, closeCache func()) { //nolint:funlen,gocognit,gocyclo,lll
// if the ttl will be bigger than 1 second, the template functions like `nowUnix` will not work as expected
const cacheTtl = 900 * time.Millisecond // the cache TTL

var (
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
stopOnce sync.Once
)

// run a goroutine that will clear the cache from expired items. to stop the goroutine - close the stop channel
// or call the closeCache
go func() {
var timer = time.NewTimer(cacheTtl)
defer func() { timer.Stop(); cache.Clear() }()

for {
select {
case <-timer.C:
cache.ClearExpired()
timer.Reset(cacheTtl)
case <-stopCh:
return
}
}
}()

return func(ctx *fasthttp.RequestCtx) {
var (
reqHeaders = &ctx.Request.Header
Expand Down Expand Up @@ -106,57 +132,82 @@ func New(cfg *config.Config, log *logger.Logger) fasthttp.RequestHandler { //nol

switch {
case format == jsonFormat && cfg.Formats.JSON != "":
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
j, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, j)
} else {
write(ctx, log, content)
if cached, ok := cache.Get(cfg.Formats.JSON, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, errAsJson) // error during rendering
} else {
cache.Put(cfg.Formats.JSON, tplProps, []byte(content))

write(ctx, log, content) // rendered successfully
}
}

case format == xmlFormat && cfg.Formats.XML != "":
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>", err.Error(),
))
} else {
write(ctx, log, content)
if cached, ok := cache.Get(cfg.Formats.XML, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
))
} else {
cache.Put(cfg.Formats.XML, tplProps, []byte(content))

write(ctx, log, content)
}
}

case format == htmlFormat:
var templateName = templateToUse(cfg)

if tpl, found := cfg.Templates.Get(templateName); found {
if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>",
templateName,
err.Error(),
))
} else {
write(ctx, log, content)
if tpl, found := cfg.Templates.Get(templateName); found { //nolint:nestif
if cached, ok := cache.Get(tpl, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
templateName,
err.Error(),
))
} else {
cache.Put(tpl, tplProps, []byte(content))

write(ctx, log, content)
}
}
} else {
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>", templateName,
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>\n", templateName,
))
}

default: // plainTextFormat as default
if cfg.Formats.PlainText != "" {
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else {
write(ctx, log, content)
if cfg.Formats.PlainText != "" { //nolint:nestif
if cached, ok := cache.Get(cfg.Formats.PlainText, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else {
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))

write(ctx, log, content)
}
}
} else {
write(ctx, log, `The requested content format is not supported.
Please create an issue on the project's GitHub page to request support for this format.
Supported formats: JSON, XML, HTML, Plain Text`)
Supported formats: JSON, XML, HTML, Plain Text
`)
}
}
}
}, func() { stopOnce.Do(func() { close(stopCh) }) }
}

var (
Expand Down
Loading

0 comments on commit 5698daa

Please sign in to comment.