Template rendering performance issue has been fixed (#60)

This commit is contained in:
Paramtamtam 2022-01-31 13:43:40 +05:00 committed by GitHub
parent 690a405994
commit cc6cbc7d47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 337 additions and 31 deletions

View File

@ -42,7 +42,9 @@ linters: # All available linters list: <https://golangci-lint.run/usage/linters/
disable-all: true disable-all: true
enable: enable:
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
- bidichk # Checks for dangerous unicode character sequences
- bodyclose # Checks whether HTTP response body is closed successfully - bodyclose # Checks whether HTTP response body is closed successfully
- contextcheck # check the function whether use a non-inherited context
- deadcode # Finds unused code - deadcode # Finds unused code
- depguard # Go linter that checks if package imports are in a list of acceptable packages - depguard # Go linter that checks if package imports are in a list of acceptable packages
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())

View File

@ -14,6 +14,11 @@ The format is based on [Keep a Changelog][keepachangelog] and this project adher
- `Host` and `X-Forwarded-For` Header to error pages [#61] - `Host` and `X-Forwarded-For` Header to error pages [#61]
### Fixed
- Performance issue, that affects template rendering. Now templates are cached in memory (for 2 seconds), and it has improved performance by more than 200% [#60]
[#60]:https://github.com/tarampampam/error-pages/pull/60
[#61]:https://github.com/tarampampam/error-pages/pull/61 [#61]:https://github.com/tarampampam/error-pages/pull/61
## v2.4.0 ## v2.4.0

View File

@ -80,25 +80,24 @@ $ ulimit -aH | grep file
-n: file descriptors 1048576 -n: file descriptors 1048576
-x: file locks unlimited -x: file locks unlimited
$ wrk --version | head -n 1 $ docker run --rm -p "8080:8080/tcp" -e "SHOW_DETAILS=true" error-pages:local # in separate terminal
wrk 4.2.0 [epoll] Copyright (C) 2012 Will Glozer
$ wrk -t12 -c400 -d30s http://127.0.0.1:8080/500.html $ wrk --timeout 1s -t12 -c400 -d30s -s ./test/wrk/request.lua http://127.0.0.1:8080/
Running 30s test @ http://127.0.0.1:8080/500.html Running 30s test @ http://127.0.0.1:8080/
12 threads and 400 connections 12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev Thread Stats Avg Stdev Max +/- Stdev
Latency 50.06ms 61.15ms 655.79ms 85.54% Latency 10.84ms 7.89ms 135.91ms 79.36%
Req/Sec 1.07k 363.14 2.40k 69.24% Req/Sec 3.23k 785.11 6.30k 70.04%
383014 requests in 30.08s, 2.14GB read 1160567 requests in 30.10s, 4.12GB read
Requests/sec: 12731.07 Requests/sec: 38552.04
Transfer/sec: 72.79MB Transfer/sec: 140.23MB
``` ```
<details> <details>
<summary>FS & memory usage stats during the test</summary> <summary>FS & memory usage stats during the test</summary>
<p align="center"> <p align="center">
<img src="https://hsto.org/webt/dy/2e/_8/dy2e_8xkefxre7z5w7xcorjldmm.png" alt="" /> <img src="https://hsto.org/webt/ts/w-/lz/tsw-lznvru0ngjneiimkwq7ysyc.png" alt="" />
</p> </p>
</details> </details>

View File

@ -71,7 +71,8 @@ func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateI
return errors.Wrap(err, "cannot prepare output directory") return errors.Wrap(err, "cannot prepare output directory")
} }
history := newBuildingHistory() history, renderer := newBuildingHistory(), tpl.NewTemplateRenderer()
defer func() { _ = renderer.Close() }()
for _, template := range cfg.Templates { for _, template := range cfg.Templates {
log.Debug("template processing", zap.String("name", template.Name())) log.Debug("template processing", zap.String("name", template.Name()))
@ -86,7 +87,7 @@ func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateI
filePath = path.Join(outDirectoryPath, template.Name(), fileName) filePath = path.Join(outDirectoryPath, template.Name(), fileName)
) )
content, renderingErr := tpl.Render(template.Content(), tpl.Properties{ content, renderingErr := renderer.Render(template.Content(), tpl.Properties{
Code: page.Code(), Code: page.Code(),
Message: page.Message(), Message: page.Message(),
Description: page.Description(), Description: page.Description(),

View File

@ -13,10 +13,15 @@ type templatePicker interface {
Pick() string Pick() string
} }
type renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
func RespondWithErrorPage( //nolint:funlen func RespondWithErrorPage( //nolint:funlen
ctx *fasthttp.RequestCtx, ctx *fasthttp.RequestCtx,
cfg *config.Config, cfg *config.Config,
p templatePicker, p templatePicker,
rdr renderer,
pageCode string, pageCode string,
httpCode int, httpCode int,
showRequestDetails bool, showRequestDetails bool,
@ -64,7 +69,7 @@ func RespondWithErrorPage( //nolint:funlen
{ {
SetClientFormat(ctx, JSONContentType) SetClientFormat(ctx, JSONContentType)
if content, err := tpl.Render(json.Content(), props); err == nil { if content, err := rdr.Render(json.Content(), props); err == nil {
ctx.SetStatusCode(httpCode) ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content) _, _ = ctx.Write(content)
} else { } else {
@ -77,7 +82,7 @@ func RespondWithErrorPage( //nolint:funlen
{ {
SetClientFormat(ctx, XMLContentType) SetClientFormat(ctx, XMLContentType)
if content, err := tpl.Render(xml.Content(), props); err == nil { if content, err := rdr.Render(xml.Content(), props); err == nil {
ctx.SetStatusCode(httpCode) ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content) _, _ = ctx.Write(content)
} else { } else {
@ -93,7 +98,7 @@ func RespondWithErrorPage( //nolint:funlen
var templateName = p.Pick() var templateName = p.Pick()
if template, exists := cfg.Template(templateName); exists { if template, exists := cfg.Template(templateName); exists {
if content, err := tpl.Render(template.Content(), props); err == nil { if content, err := rdr.Render(template.Content(), props); err == nil {
ctx.SetStatusCode(httpCode) ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content) _, _ = ctx.Write(content)
} else { } else {

View File

@ -3,21 +3,28 @@ package errorpage
import ( import (
"github.com/tarampampam/error-pages/internal/config" "github.com/tarampampam/error-pages/internal/config"
"github.com/tarampampam/error-pages/internal/http/core" "github.com/tarampampam/error-pages/internal/http/core"
"github.com/tarampampam/error-pages/internal/tpl"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
type templatePicker interface { type (
// Pick the template name for responding. templatePicker interface {
Pick() string // Pick the template name for responding.
} Pick() string
}
renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
)
// NewHandler creates handler for error pages serving. // NewHandler creates handler for error pages serving.
func NewHandler(cfg *config.Config, p templatePicker, showRequestDetails bool) fasthttp.RequestHandler { func NewHandler(cfg *config.Config, p templatePicker, rdr renderer, showRequestDetails bool) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) { return func(ctx *fasthttp.RequestCtx) {
core.SetClientFormat(ctx, core.PlainTextContentType) // default content type core.SetClientFormat(ctx, core.PlainTextContentType) // default content type
if code, ok := ctx.UserValue("code").(string); ok { if code, ok := ctx.UserValue("code").(string); ok {
core.RespondWithErrorPage(ctx, cfg, p, code, fasthttp.StatusOK, showRequestDetails) core.RespondWithErrorPage(ctx, cfg, p, rdr, code, fasthttp.StatusOK, showRequestDetails)
} else { // will never occur } else { // will never occur
ctx.SetStatusCode(fasthttp.StatusInternalServerError) ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot extract requested code from the request") _, _ = ctx.WriteString("cannot extract requested code from the request")

View File

@ -5,6 +5,7 @@ import (
"github.com/tarampampam/error-pages/internal/config" "github.com/tarampampam/error-pages/internal/config"
"github.com/tarampampam/error-pages/internal/http/core" "github.com/tarampampam/error-pages/internal/http/core"
"github.com/tarampampam/error-pages/internal/tpl"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
@ -13,12 +14,17 @@ type (
// Pick the template name for responding. // Pick the template name for responding.
Pick() string Pick() string
} }
renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
) )
// NewHandler creates handler for the index page serving. // NewHandler creates handler for the index page serving.
func NewHandler( func NewHandler(
cfg *config.Config, cfg *config.Config,
p templatePicker, p templatePicker,
rdr renderer,
defaultPageCode string, defaultPageCode string,
defaultHTTPCode uint16, defaultHTTPCode uint16,
showRequestDetails bool, showRequestDetails bool,
@ -30,7 +36,7 @@ func NewHandler(
pageCode, httpCode = strconv.Itoa(returnCode), returnCode pageCode, httpCode = strconv.Itoa(returnCode), returnCode
} }
core.RespondWithErrorPage(ctx, cfg, p, pageCode, httpCode, showRequestDetails) core.RespondWithErrorPage(ctx, cfg, p, rdr, pageCode, httpCode, showRequestDetails)
} }
} }

View File

@ -4,6 +4,8 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/tarampampam/error-pages/internal/tpl"
"github.com/fasthttp/router" "github.com/fasthttp/router"
"github.com/tarampampam/error-pages/internal/checkers" "github.com/tarampampam/error-pages/internal/checkers"
"github.com/tarampampam/error-pages/internal/config" "github.com/tarampampam/error-pages/internal/config"
@ -24,6 +26,7 @@ type Server struct {
log *zap.Logger log *zap.Logger
fast *fasthttp.Server fast *fasthttp.Server
router *router.Router router *router.Router
rdr *tpl.TemplateRenderer
} }
const ( const (
@ -33,6 +36,8 @@ const (
) )
func NewServer(log *zap.Logger) Server { func NewServer(log *zap.Logger) Server {
rdr := tpl.NewTemplateRenderer()
return Server{ return Server{
// fasthttp docs: <https://github.com/valyala/fasthttp> // fasthttp docs: <https://github.com/valyala/fasthttp>
fast: &fasthttp.Server{ fast: &fasthttp.Server{
@ -46,6 +51,7 @@ func NewServer(log *zap.Logger) Server {
}, },
router: router.New(), router: router.New(),
log: log, log: log,
rdr: rdr,
} }
} }
@ -76,8 +82,8 @@ func (s *Server) Register(
s.fast.Handler = common.DurationMetrics(common.LogRequest(s.router.Handler, s.log), &m) s.fast.Handler = common.DurationMetrics(common.LogRequest(s.router.Handler, s.log), &m)
s.router.GET("/", indexHandler.NewHandler(cfg, templatePicker, defaultPageCode, defaultHTTPCode, showDetails)) s.router.GET("/", indexHandler.NewHandler(cfg, templatePicker, s.rdr, defaultPageCode, defaultHTTPCode, showDetails)) //nolint:lll
s.router.GET("/{code}.html", errorpageHandler.NewHandler(cfg, templatePicker, showDetails)) s.router.GET("/{code}.html", errorpageHandler.NewHandler(cfg, templatePicker, s.rdr, showDetails))
s.router.GET("/version", versionHandler.NewHandler(version.Version())) s.router.GET("/version", versionHandler.NewHandler(version.Version()))
liveHandler := healthzHandler.NewHandler(checkers.NewLiveChecker()) liveHandler := healthzHandler.NewHandler(checkers.NewLiveChecker())
@ -92,4 +98,12 @@ func (s *Server) Register(
} }
// Stop server. // Stop server.
func (s *Server) Stop() error { return s.fast.Shutdown() } func (s *Server) Stop() error {
if err := s.rdr.Close(); err != nil {
defer func() { _ = s.fast.Shutdown() }()
return err
}
return s.fast.Shutdown()
}

25
internal/tpl/hasher.go Normal file
View File

@ -0,0 +1,25 @@
package tpl
import (
"bytes"
"crypto/md5" //nolint:gosec
"encoding/gob"
)
const hashLength = 16 // md5 hash length
type Hash [hashLength]byte
func HashStruct(s interface{}) (Hash, error) {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(s); err != nil {
return Hash{}, err
}
return md5.Sum(b.Bytes()), nil //nolint:gosec
}
func HashBytes(b []byte) Hash {
return md5.Sum(b) //nolint:gosec
}

View File

@ -0,0 +1,35 @@
package tpl_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/tpl"
)
func TestHashBytes(t *testing.T) {
assert.NotEqual(t, tpl.HashBytes([]byte{1}), tpl.HashBytes([]byte{2}))
}
func TestHashStruct(t *testing.T) {
type s struct {
S string
I int
B bool
}
h1, err1 := tpl.HashStruct(s{S: "foo", I: 1, B: false})
assert.NoError(t, err1)
h2, err2 := tpl.HashStruct(s{S: "bar", I: 2, B: true})
assert.NoError(t, err2)
assert.NotEqual(t, h1, h2)
type p struct { // no exported fields
any string
}
_, err := tpl.HashStruct(p{any: "foo"})
assert.Error(t, err)
}

View File

@ -1,6 +1,8 @@
package tpl package tpl
import "reflect" import (
"reflect"
)
type Properties struct { // only string properties with a "token" tag, please type Properties struct { // only string properties with a "token" tag, please
Code string `token:"code"` Code string `token:"code"`
@ -33,3 +35,5 @@ func (p *Properties) Replaces() map[string]string {
return replaces return replaces
} }
func (p *Properties) Hash() (Hash, error) { return HashStruct(p) }

View File

@ -5,13 +5,17 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"strconv" "strconv"
"sync"
"text/template" "text/template"
"time" "time"
"github.com/pkg/errors"
"github.com/tarampampam/error-pages/internal/version" "github.com/tarampampam/error-pages/internal/version"
) )
var tplFnMap = template.FuncMap{ //nolint:gochecknoglobals // these functions can be used in templates // These functions are always allowed in the templates.
var tplFnMap = template.FuncMap{ //nolint:gochecknoglobals
"now": time.Now, "now": time.Now,
"hostname": os.Hostname, "hostname": os.Hostname,
"json": func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn "json": func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn
@ -29,11 +33,113 @@ var tplFnMap = template.FuncMap{ //nolint:gochecknoglobals // these functions ca
}, },
} }
func Render(content []byte, props Properties) ([]byte, error) { var ErrClosed = errors.New("closed")
type TemplateRenderer struct {
cacheMu sync.RWMutex
cache map[cacheEntryHash]cacheItem // map key is a unique hash
cacheCleanupInterval time.Duration
cacheItemLifetime time.Duration
close chan struct{}
closedMu sync.RWMutex
closed bool
}
type (
cacheEntryHash = [hashLength * 2]byte // two md5 hashes
cacheItem struct {
data []byte
expiresAtNano int64
}
)
const (
cacheCleanupInterval = time.Second
cacheItemLifetime = time.Second * 2
)
// NewTemplateRenderer returns new template renderer. Don't forget to call Close() function!
func NewTemplateRenderer() *TemplateRenderer {
tr := &TemplateRenderer{
cache: make(map[cacheEntryHash]cacheItem),
cacheCleanupInterval: cacheCleanupInterval,
cacheItemLifetime: cacheItemLifetime,
close: make(chan struct{}, 1),
}
go tr.cleanup()
return tr
}
func (tr *TemplateRenderer) cleanup() {
defer close(tr.close)
timer := time.NewTimer(tr.cacheCleanupInterval)
defer timer.Stop()
for {
select {
case <-tr.close:
tr.cacheMu.Lock()
for hash := range tr.cache {
delete(tr.cache, hash)
}
tr.cacheMu.Unlock()
return
case <-timer.C:
tr.cacheMu.Lock()
var now = time.Now().UnixNano()
for hash, item := range tr.cache {
if now > item.expiresAtNano {
delete(tr.cache, hash)
}
}
tr.cacheMu.Unlock()
timer.Reset(tr.cacheCleanupInterval)
}
}
}
func (tr *TemplateRenderer) Render(content []byte, props Properties) ([]byte, error) { //nolint:funlen
if tr.isClosed() {
return nil, ErrClosed
}
if len(content) == 0 { if len(content) == 0 {
return content, nil return content, nil
} }
var (
cacheKey cacheEntryHash
cacheKeyInit bool
)
if propsHash, err := props.Hash(); err == nil {
cacheKeyInit, cacheKey = true, tr.mixHashes(propsHash, HashBytes(content))
tr.cacheMu.RLock()
item, hit := tr.cache[cacheKey]
tr.cacheMu.RUnlock()
if hit {
// cache item has been expired?
if time.Now().UnixNano() > item.expiresAtNano {
tr.cacheMu.Lock()
delete(tr.cache, cacheKey)
tr.cacheMu.Unlock()
} else {
return item.data, nil
}
}
}
var funcMap = template.FuncMap{ var funcMap = template.FuncMap{
"show_details": func() bool { return props.ShowRequestDetails }, "show_details": func() bool { return props.ShowRequestDetails },
"hide_details": func() bool { return !props.ShowRequestDetails }, "hide_details": func() bool { return !props.ShowRequestDetails },
@ -62,5 +168,50 @@ func Render(content []byte, props Properties) ([]byte, error) {
return nil, err return nil, err
} }
return buf.Bytes(), nil b := buf.Bytes()
if cacheKeyInit {
tr.cacheMu.Lock()
tr.cache[cacheKey] = cacheItem{
data: b,
expiresAtNano: time.Now().UnixNano() + tr.cacheItemLifetime.Nanoseconds(),
}
tr.cacheMu.Unlock()
}
return b, nil
}
func (tr *TemplateRenderer) isClosed() (closed bool) {
tr.closedMu.RLock()
closed = tr.closed
tr.closedMu.RUnlock()
return
}
func (tr *TemplateRenderer) Close() error {
if tr.isClosed() {
return ErrClosed
}
tr.closedMu.Lock()
tr.closed = true
tr.closedMu.Unlock()
tr.close <- struct{}{}
return nil
}
func (tr *TemplateRenderer) mixHashes(a, b Hash) (result cacheEntryHash) {
for i := 0; i < len(a); i++ {
result[i] = a[i]
}
for i := 0; i < len(b); i++ {
result[i+len(a)] = b[i]
}
return
} }

View File

@ -1,6 +1,9 @@
package tpl_test package tpl_test
import ( import (
"math/rand"
"strconv"
"sync"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -8,6 +11,9 @@ import (
) )
func Test_Render(t *testing.T) { func Test_Render(t *testing.T) {
renderer := tpl.NewTemplateRenderer()
defer func() { _ = renderer.Close() }()
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
giveContent string giveContent string
giveProps tpl.Properties giveProps tpl.Properties
@ -52,7 +58,7 @@ func Test_Render(t *testing.T) {
}, },
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
content, err := tpl.Render([]byte(tt.giveContent), tt.giveProps) content, err := renderer.Render([]byte(tt.giveContent), tt.giveProps)
if tt.wantError == true { if tt.wantError == true {
assert.Error(t, err) assert.Error(t, err)
@ -64,11 +70,48 @@ func Test_Render(t *testing.T) {
} }
} }
func TestTemplateRenderer_Render_Concurrent(t *testing.T) {
renderer := tpl.NewTemplateRenderer()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
props := tpl.Properties{
Code: strconv.Itoa(rand.Intn(599-300+1) + 300), //nolint:gosec
Message: "Not found",
Description: "Blah",
}
content, err := renderer.Render([]byte("{{code}}: {{ message }} {{description}}"), props)
assert.NoError(t, err)
assert.NotEmpty(t, content)
}()
}
wg.Wait()
assert.NoError(t, renderer.Close())
assert.EqualError(t, renderer.Close(), tpl.ErrClosed.Error())
content, err := renderer.Render([]byte{}, tpl.Properties{})
assert.Nil(t, content)
assert.EqualError(t, err, tpl.ErrClosed.Error())
}
func BenchmarkRenderHTML(b *testing.B) { func BenchmarkRenderHTML(b *testing.B) {
b.ReportAllocs() b.ReportAllocs()
renderer := tpl.NewTemplateRenderer()
defer func() { _ = renderer.Close() }()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
_, _ = tpl.Render( _, _ = renderer.Render(
[]byte("{{code}}: {{ message }} {{description}}"), []byte("{{code}}: {{ message }} {{description}}"),
tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"}, tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"},
) )

9
test/wrk/request.lua Normal file
View File

@ -0,0 +1,9 @@
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
request = function()
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
return wrk.format("GET", "/500.html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
end