feat: templates caching

This commit is contained in:
Paramtamtam 2024-07-02 23:42:09 +04:00
parent 075bc8f57c
commit 5698daa36c
No known key found for this signature in database
GPG Key ID: 366371698FAD0A2B
19 changed files with 315 additions and 57 deletions

View File

@ -72,7 +72,7 @@ const defaultJSONFormat string = `{
"service_name": {{ service_name | json }}, "service_name": {{ service_name | json }},
"service_port": {{ service_port | json }}, "service_port": {{ service_port | json }},
"request_id": {{ request_id | json }}, "request_id": {{ request_id | json }},
"timestamp": {{ now.Unix }} "timestamp": {{ nowUnix }}
}{{ end }} }{{ end }}
} }
` // an empty line at the end is important for better UX ` // an empty line at the end is important for better UX
@ -91,7 +91,7 @@ const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
<serviceName>{{ service_name }}</serviceName> <serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort> <servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID> <requestID>{{ request_id }}</requestID>
<timestamp>{{ now.Unix }}</timestamp> <timestamp>{{ nowUnix }}</timestamp>
</details>{{ end }} </details>{{ end }}
</error> </error>
` // an empty line at the end is important for better UX ` // an empty line at the end is important for better UX
@ -107,7 +107,7 @@ Ingress Name: {{ ingress_name }}
Service Name: {{ service_name }} Service Name: {{ service_name }}
Service Port: {{ service_port }} Service Port: {{ service_port }}
Request ID: {{ request_id }} Request ID: {{ request_id }}
Timestamp: {{ now.Unix }}{{ end }} Timestamp: {{ nowUnix }}{{ end }}
` // an empty line at the end is important for better UX ` // an empty line at the end is important for better UX
//nolint:lll //nolint:lll

View File

@ -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
}

View File

@ -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
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -15,7 +16,32 @@ import (
) )
// New creates a new handler that returns an error page with the specified status code and format. // 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) { return func(ctx *fasthttp.RequestCtx) {
var ( var (
reqHeaders = &ctx.Request.Header reqHeaders = &ctx.Request.Header
@ -106,57 +132,82 @@ func New(cfg *config.Config, log *logger.Logger) fasthttp.RequestHandler { //nol
switch { switch {
case format == jsonFormat && cfg.Formats.JSON != "": case format == jsonFormat && cfg.Formats.JSON != "":
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 { 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())) errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, j) write(ctx, log, errAsJson) // error during rendering
} else { } else {
write(ctx, log, content) cache.Put(cfg.Formats.JSON, tplProps, []byte(content))
write(ctx, log, content) // rendered successfully
}
} }
case format == xmlFormat && cfg.Formats.XML != "": case format == xmlFormat && cfg.Formats.XML != "":
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 { if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf( write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>", err.Error(), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
)) ))
} else { } else {
cache.Put(cfg.Formats.XML, tplProps, []byte(content))
write(ctx, log, content) write(ctx, log, content)
} }
}
case format == htmlFormat: case format == htmlFormat:
var templateName = templateToUse(cfg) var templateName = templateToUse(cfg)
if tpl, found := cfg.Templates.Get(templateName); found { 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 { if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support // TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf( write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>", "<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
templateName, templateName,
err.Error(), err.Error(),
)) ))
} else { } else {
cache.Put(tpl, tplProps, []byte(content))
write(ctx, log, content) write(ctx, log, content)
} }
}
} else { } else {
write(ctx, log, fmt.Sprintf( 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 default: // plainTextFormat as default
if cfg.Formats.PlainText != "" { 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 { 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())) write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else { } else {
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))
write(ctx, log, content) write(ctx, log, content)
} }
}
} else { } else {
write(ctx, log, `The requested content format is not supported. 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. 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 ( var (

View File

@ -159,7 +159,8 @@ func TestHandler(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() 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) req, reqErr := http.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody)
require.NoError(t, reqErr) require.NoError(t, reqErr)
@ -202,9 +203,11 @@ func TestRotationModeOnEachRequest(t *testing.T) {
lastResponseBody string lastResponseBody string
changedTimes int 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 { for range 300 {
req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody) req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody)
require.NoError(t, reqErr) require.NoError(t, reqErr)

View File

@ -25,6 +25,7 @@ import (
type Server struct { type Server struct {
log *logger.Logger log *logger.Logger
server *fasthttp.Server server *fasthttp.Server
beforeStop func()
} }
// NewServer creates a new HTTP server. // NewServer creates a new HTTP server.
@ -45,6 +46,7 @@ func NewServer(log *logger.Logger, readBufferSize uint) Server {
CloseOnShutdown: true, CloseOnShutdown: true,
Logger: logger.NewStdLog(log), Logger: logger.NewStdLog(log),
}, },
beforeStop: func() {}, // noop
} }
} }
@ -53,13 +55,17 @@ func (s *Server) Register(cfg *config.Config) error {
var ( var (
liveHandler = live.New() liveHandler = live.New()
versionHandler = version.New(appmeta.Version()) versionHandler = version.New(appmeta.Version())
errorPagesHandler = ep.New(cfg, s.log)
faviconHandler = static.New(static.Favicon) faviconHandler = static.New(static.Favicon)
errorPagesHandler, closeCache = ep.New(cfg, s.log)
notFound = http.StatusText(http.StatusNotFound) + "\n" notFound = http.StatusText(http.StatusNotFound) + "\n"
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\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) { s.server.Handler = func(ctx *fasthttp.RequestCtx) {
var url, method = string(ctx.Path()), string(ctx.Method()) 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) var ctx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
s.beforeStop()
return s.server.ShutdownWithContext(ctx) return s.server.ShutdownWithContext(ctx)
} }

View File

@ -38,7 +38,7 @@ func TestRouting(t *testing.T) {
Service Name: {{ service_name }} Service Name: {{ service_name }}
Service Port: {{ service_port }} Service Port: {{ service_port }}
Request ID: {{ request_id }} Request ID: {{ request_id }}
Timestamp: {{ now.Unix }} Timestamp: {{ nowUnix }}
</pre>{{ end }} </pre>{{ end }}
</html>`)) </html>`))

View File

@ -16,10 +16,9 @@ import (
) )
var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals
// current time: // the current time in unix format (seconds since 1970 UTC):
// `{{ now.Unix }}` // `1631610000` // `{{ nowUnix }}` // `1631610000`
// `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}` // `15:4:5` "nowUnix": func() int64 { return time.Now().Unix() },
"now": time.Now,
// current hostname: // current hostname:
// `{{ hostname }}` // `localhost` // `{{ hostname }}` // `localhost`

View File

@ -27,7 +27,7 @@ func TestRender_BuiltInFunction(t *testing.T) {
wantErrMsg string wantErrMsg string
}{ }{
"now (unix)": { "now (unix)": {
giveTemplate: `{{ now.Unix }}`, giveTemplate: `{{ nowUnix }}`,
wantResult: strconv.Itoa(int(time.Now().Unix())), wantResult: strconv.Itoa(int(time.Now().Unix())),
}, },
"hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname}, "hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname},

View File

@ -341,7 +341,7 @@
<!-- {{- end }}{{ if request_id -}} --> <!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li> <li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} --> <!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li> <li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
</ul> </ul>
</div> </div>
<!-- {{- end -}} --> <!-- {{- end -}} -->

View File

@ -150,7 +150,7 @@
<!-- {{- end -}} --> <!-- {{- end -}} -->
<tr> <tr>
<td class="name" data-l10n>Timestamp</td> <td class="name" data-l10n>Timestamp</td>
<td class="value">{{ now.Unix }}</td> <td class="value">{{ nowUnix }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -327,7 +327,7 @@
<!-- {{- end }}{{ if request_id -}} --> <!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li> <li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} --> <!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li> <li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
</ul> </ul>
</div> </div>
<!-- {{- end -}} --> <!-- {{- end -}} -->

View File

@ -235,7 +235,7 @@
<!-- {{- end -}} --> <!-- {{- end -}} -->
<tr> <tr>
<td class="name" data-l10n>Timestamp</td> <td class="name" data-l10n>Timestamp</td>
<td class="value">{{ now.Unix }}</td> <td class="value">{{ nowUnix }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -174,7 +174,7 @@
<!-- {{- end }}{{ if request_id -}} --> <!-- {{- end }}{{ if request_id -}} -->
<p class="output small"><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></p> <p class="output small"><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></p>
<!-- {{- end -}} --> <!-- {{- end -}} -->
<p class="output small"><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></p> <p class="output small"><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></p>
</div> </div>
<!-- {{- end -}} --> <!-- {{- end -}} -->
</main> </main>

View File

@ -157,7 +157,7 @@
<!-- {{- end }}{{ if request_id -}} --> <!-- {{- end }}{{ if request_id -}} -->
<li class="value">{{ request_id }}</li> <li class="value">{{ request_id }}</li>
<!-- {{- end -}} --> <!-- {{- end -}} -->
<li class="value">{{ now.Unix }}</li> <li class="value">{{ nowUnix }}</li>
</ul> </ul>
<!-- {{- end -}} --> <!-- {{- end -}} -->
</div> </div>

View File

@ -442,7 +442,7 @@
<!-- {{- end }}{{ if request_id -}} --> <!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li> <li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} --> <!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li> <li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
</ul> </ul>
<!-- {{- end -}} --> <!-- {{- end -}} -->
</div> </div>

View File

@ -9,7 +9,7 @@
{{ if service_name }}Service name: {{ service_name }}{{ end }} {{ if service_name }}Service name: {{ service_name }}{{ end }}
{{ if service_port }}Service port: {{ service_port }}{{ end }} {{ if service_port }}Service port: {{ service_port }}{{ end }}
{{ if request_id }}Request ID: {{ request_id }}{{ end }} {{ if request_id }}Request ID: {{ request_id }}{{ end }}
Timestamp: {{ now.Unix }} Timestamp: {{ nowUnix }}
{{ end }} {{ end }}
--> -->
<html lang="en"> <html lang="en">

View File

@ -253,7 +253,7 @@
<!-- {{- end -}} --> <!-- {{- end -}} -->
<tr> <tr>
<td class="name" data-l10n>Timestamp</td> <td class="name" data-l10n>Timestamp</td>
<td class="value">{{ now.Unix }}</td> <td class="value">{{ nowUnix }}</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -167,7 +167,7 @@
<!-- {{- end -}} --> <!-- {{- end -}} -->
<tr> <tr>
<td class="name"><span data-l10n>Timestamp</span>:</td> <td class="name"><span data-l10n>Timestamp</span>:</td>
<td class="value">{{ now.Unix }}</td> <td class="value">{{ nowUnix }}</td>
</tr> </tr>
</table> </table>
<!-- {{- end -}} --> <!-- {{- end -}} -->