From 5698daa36c4665218711c518bbba7296589ed12f Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Tue, 2 Jul 2024 23:42:09 +0400 Subject: [PATCH] feat: templates caching --- internal/config/config.go | 6 +- internal/http/handlers/error_page/cache.go | 111 ++++++++++++++++++ .../http/handlers/error_page/cache_test.go | 86 ++++++++++++++ internal/http/handlers/error_page/handler.go | 111 +++++++++++++----- .../http/handlers/error_page/handler_test.go | 7 +- internal/http/server.go | 20 +++- internal/http/server_test.go | 2 +- internal/template/template.go | 7 +- internal/template/template_test.go | 2 +- templates/app-down.html | 2 +- templates/cats.html | 2 +- templates/connection.html | 2 +- templates/ghost.html | 2 +- templates/hacker-terminal.html | 2 +- templates/l7.html | 2 +- templates/lost-in-space.html | 2 +- templates/noise.html | 2 +- templates/orient.html | 2 +- templates/shuffle.html | 2 +- 19 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 internal/http/handlers/error_page/cache.go create mode 100644 internal/http/handlers/error_page/cache_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 353b2cf3..032cc4f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -91,7 +91,7 @@ const defaultXMLFormat string = ` {{ service_name }} {{ service_port }} {{ request_id }} - {{ now.Unix }} + {{ nowUnix }} {{ end }} ` // an empty line at the end is important for better UX @@ -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 diff --git a/internal/http/handlers/error_page/cache.go b/internal/http/handlers/error_page/cache.go new file mode 100644 index 00000000..04f42b29 --- /dev/null +++ b/internal/http/handlers/error_page/cache.go @@ -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 +} diff --git a/internal/http/handlers/error_page/cache_test.go b/internal/http/handlers/error_page/cache_test.go new file mode 100644 index 00000000..0ebbfc9f --- /dev/null +++ b/internal/http/handlers/error_page/cache_test.go @@ -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 +} diff --git a/internal/http/handlers/error_page/handler.go b/internal/http/handlers/error_page/handler.go index 0e6de6bd..d698bfb9 100644 --- a/internal/http/handlers/error_page/handler.go +++ b/internal/http/handlers/error_page/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "sync" "sync/atomic" "time" @@ -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 @@ -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( - "\nFailed to render the XML template: %s", 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( + "\nFailed to render the XML template: %s\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( - "\nFailed to render the HTML template %s: %s", - 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( + "\nFailed to render the HTML template %s: %s\n", + templateName, + err.Error(), + )) + } else { + cache.Put(tpl, tplProps, []byte(content)) + + write(ctx, log, content) + } } } else { write(ctx, log, fmt.Sprintf( - "\nTemplate %s not found and cannot be used", templateName, + "\nTemplate %s not found and cannot be used\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 ( diff --git a/internal/http/handlers/error_page/handler_test.go b/internal/http/handlers/error_page/handler_test.go index fa1faf33..c2747232 100644 --- a/internal/http/handlers/error_page/handler_test.go +++ b/internal/http/handlers/error_page/handler_test.go @@ -159,7 +159,8 @@ func TestHandler(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - var handler = error_page.New(tt.giveConfig(), logger.NewNop()) + var handler, closeCache = error_page.New(tt.giveConfig(), logger.NewNop()) + defer closeCache() req, reqErr := http.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody) require.NoError(t, reqErr) @@ -202,9 +203,11 @@ func TestRotationModeOnEachRequest(t *testing.T) { lastResponseBody string changedTimes int - handler = error_page.New(&cfg, logger.NewNop()) + handler, closeCache = error_page.New(&cfg, logger.NewNop()) ) + defer func() { closeCache(); closeCache(); closeCache() }() // multiple calls should not panic + for range 300 { req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody) require.NoError(t, reqErr) diff --git a/internal/http/server.go b/internal/http/server.go index faf591a8..444aaf4f 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -23,8 +23,9 @@ import ( // Server is an HTTP server for serving error pages. type Server struct { - log *logger.Logger - server *fasthttp.Server + log *logger.Logger + server *fasthttp.Server + beforeStop func() } // NewServer creates a new HTTP server. @@ -45,21 +46,26 @@ func NewServer(log *logger.Logger, readBufferSize uint) Server { CloseOnShutdown: true, Logger: logger.NewStdLog(log), }, + beforeStop: func() {}, // noop } } // Register server handlers, middlewares, etc. func (s *Server) Register(cfg *config.Config) error { var ( - liveHandler = live.New() - versionHandler = version.New(appmeta.Version()) - errorPagesHandler = ep.New(cfg, s.log) - faviconHandler = static.New(static.Favicon) + liveHandler = live.New() + versionHandler = version.New(appmeta.Version()) + faviconHandler = static.New(static.Favicon) + + errorPagesHandler, closeCache = ep.New(cfg, s.log) notFound = http.StatusText(http.StatusNotFound) + "\n" notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n" ) + // wrap the before shutdown function to close the cache + s.beforeStop = closeCache + s.server.Handler = func(ctx *fasthttp.RequestCtx) { var url, method = string(ctx.Path()), string(ctx.Method()) @@ -134,5 +140,7 @@ func (s *Server) Stop(timeout time.Duration) error { var ctx, cancel = context.WithTimeout(context.Background(), timeout) defer cancel() + s.beforeStop() + return s.server.ShutdownWithContext(ctx) } diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 96981b9f..bfba105d 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -38,7 +38,7 @@ func TestRouting(t *testing.T) { Service Name: {{ service_name }} Service Port: {{ service_port }} Request ID: {{ request_id }} - Timestamp: {{ now.Unix }} + Timestamp: {{ nowUnix }} {{ end }} `)) diff --git a/internal/template/template.go b/internal/template/template.go index b763dd4d..694ea9d8 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -16,10 +16,9 @@ import ( ) var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals - // current time: - // `{{ now.Unix }}` // `1631610000` - // `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}` // `15:4:5` - "now": time.Now, + // the current time in unix format (seconds since 1970 UTC): + // `{{ nowUnix }}` // `1631610000` + "nowUnix": func() int64 { return time.Now().Unix() }, // current hostname: // `{{ hostname }}` // `localhost` diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 073552f0..a11ae15d 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -27,7 +27,7 @@ func TestRender_BuiltInFunction(t *testing.T) { wantErrMsg string }{ "now (unix)": { - giveTemplate: `{{ now.Unix }}`, + giveTemplate: `{{ nowUnix }}`, wantResult: strconv.Itoa(int(time.Now().Unix())), }, "hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname}, diff --git a/templates/app-down.html b/templates/app-down.html index cbd731a4..4252a70d 100644 --- a/templates/app-down.html +++ b/templates/app-down.html @@ -341,7 +341,7 @@

{{ message }}

  • Request ID: {{ request_id }}
  • -
  • Timestamp: {{ now.Unix }}
  • +
  • Timestamp: {{ nowUnix }}
  • diff --git a/templates/cats.html b/templates/cats.html index 2f9d9960..8c3d0733 100644 --- a/templates/cats.html +++ b/templates/cats.html @@ -150,7 +150,7 @@ Timestamp - {{ now.Unix }} + {{ nowUnix }} diff --git a/templates/connection.html b/templates/connection.html index 0111dc79..48b3bc3f 100644 --- a/templates/connection.html +++ b/templates/connection.html @@ -327,7 +327,7 @@

    What can I do?

  • Request ID: {{ request_id }}
  • -
  • Timestamp: {{ now.Unix }}
  • +
  • Timestamp: {{ nowUnix }}
  • diff --git a/templates/ghost.html b/templates/ghost.html index ca77551d..94d22377 100644 --- a/templates/ghost.html +++ b/templates/ghost.html @@ -235,7 +235,7 @@

    Error {{ code }}

    Timestamp - {{ now.Unix }} + {{ nowUnix }} diff --git a/templates/hacker-terminal.html b/templates/hacker-terminal.html index 9982ad75..e29c0865 100644 --- a/templates/hacker-terminal.html +++ b/templates/hacker-terminal.html @@ -174,7 +174,7 @@

    Error {{ code }}

    Request ID: {{ request_id }}

    -

    Timestamp: {{ now.Unix }}

    +

    Timestamp: {{ nowUnix }}

    diff --git a/templates/l7.html b/templates/l7.html index 2f2d7d5e..c435f660 100644 --- a/templates/l7.html +++ b/templates/l7.html @@ -157,7 +157,7 @@

    {{ code }}

  • {{ request_id }}
  • -
  • {{ now.Unix }}
  • +
  • {{ nowUnix }}
  • diff --git a/templates/lost-in-space.html b/templates/lost-in-space.html index 9a66ad6c..bd70c799 100644 --- a/templates/lost-in-space.html +++ b/templates/lost-in-space.html @@ -442,7 +442,7 @@

    UH OH! {{ message }}

  • Request ID: {{ request_id }}
  • -
  • Timestamp: {{ now.Unix }}
  • +
  • Timestamp: {{ nowUnix }}
  • diff --git a/templates/noise.html b/templates/noise.html index 1aed0f75..e540da43 100644 --- a/templates/noise.html +++ b/templates/noise.html @@ -9,7 +9,7 @@ {{ if service_name }}Service name: {{ service_name }}{{ end }} {{ if service_port }}Service port: {{ service_port }}{{ end }} {{ if request_id }}Request ID: {{ request_id }}{{ end }} - Timestamp: {{ now.Unix }} + Timestamp: {{ nowUnix }} {{ end }} --> diff --git a/templates/orient.html b/templates/orient.html index cb9dbb49..2fd0b658 100644 --- a/templates/orient.html +++ b/templates/orient.html @@ -253,7 +253,7 @@ Timestamp - {{ now.Unix }} + {{ nowUnix }} diff --git a/templates/shuffle.html b/templates/shuffle.html index 71858b16..0b19010a 100644 --- a/templates/shuffle.html +++ b/templates/shuffle.html @@ -167,7 +167,7 @@

    Timestamp: - {{ now.Unix }} + {{ nowUnix }}