replacer: cache file lookups and cap them (#8248)
* replacer: cache file lookups and cap them Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * replacer: cache repeated file placeholder lookups Memoize file placeholder results per replacement call, using an 8 MiB LRU for contents. Preserve empty and failed-read results without changing the output for unknown placeholders. Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> --------- Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>pull/8276/head
parent
62bc606433
commit
4e8de214bc
82
replacer.go
82
replacer.go
|
|
@ -16,6 +16,7 @@ package caddy
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -192,6 +193,9 @@ func (r *Replacer) replace(input, empty string,
|
|||
// fail fast if too many placeholders are unclosed
|
||||
var unclosedCount int
|
||||
|
||||
// {file.*} lookups hit the disk, so repeats within one call are memoized
|
||||
var files *fileCache
|
||||
|
||||
scan:
|
||||
for i := 0; i < len(input); i++ {
|
||||
// check for escaped braces
|
||||
|
|
@ -236,7 +240,17 @@ scan:
|
|||
key := input[i+1 : end]
|
||||
|
||||
// try to get a value for this key, handle empty values accordingly
|
||||
val, found := r.Get(key)
|
||||
var val any
|
||||
var found bool
|
||||
isFileKey := strings.HasPrefix(key, filePrefix)
|
||||
if files == nil && isFileKey && r.hasFileProvider() {
|
||||
files = new(fileCache)
|
||||
}
|
||||
if files != nil && isFileKey {
|
||||
val, found = files.get(key, r.Get)
|
||||
} else {
|
||||
val, found = r.Get(key)
|
||||
}
|
||||
if !found {
|
||||
// placeholder is unknown (unrecognized); handle accordingly
|
||||
if errOnUnknown {
|
||||
|
|
@ -372,6 +386,69 @@ func (f fileReplacementProvider) replace(key string) (any, bool) {
|
|||
return string(body), true
|
||||
}
|
||||
|
||||
func (r *Replacer) hasFileProvider() bool {
|
||||
for _, p := range r.providers {
|
||||
if _, ok := p.(fileReplacementProvider); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fileCache memoizes {file.*} lookups for one replace call. Contents live in a
|
||||
// byte-bounded LRU; misses and empty results cost no more than their key.
|
||||
type fileCache struct {
|
||||
lru *list.List
|
||||
entries map[string]*list.Element
|
||||
size int
|
||||
emptyResults map[string]any
|
||||
}
|
||||
|
||||
type fileCacheEntry struct {
|
||||
key string
|
||||
val any
|
||||
size int
|
||||
}
|
||||
|
||||
func (c *fileCache) get(key string, load func(string) (any, bool)) (any, bool) {
|
||||
if el, ok := c.entries[key]; ok {
|
||||
c.lru.MoveToFront(el)
|
||||
return el.Value.(*fileCacheEntry).val, true
|
||||
}
|
||||
if val, ok := c.emptyResults[key]; ok {
|
||||
return val, true
|
||||
}
|
||||
|
||||
val, found := load(key)
|
||||
if !found {
|
||||
return val, found
|
||||
}
|
||||
switch s, isStr := val.(string); {
|
||||
case val == nil || (isStr && s == ""):
|
||||
if c.emptyResults == nil {
|
||||
c.emptyResults = make(map[string]any)
|
||||
}
|
||||
c.emptyResults[key] = val
|
||||
case isStr && len(s) <= maxFileCacheBytes:
|
||||
c.add(key, val, len(s))
|
||||
}
|
||||
return val, found
|
||||
}
|
||||
|
||||
func (c *fileCache) add(key string, val any, size int) {
|
||||
if c.lru == nil {
|
||||
c.lru = list.New()
|
||||
c.entries = make(map[string]*list.Element)
|
||||
}
|
||||
for c.size+size > maxFileCacheBytes {
|
||||
evicted := c.lru.Remove(c.lru.Back()).(*fileCacheEntry)
|
||||
delete(c.entries, evicted.key)
|
||||
c.size -= evicted.size
|
||||
}
|
||||
c.entries[key] = c.lru.PushFront(&fileCacheEntry{key: key, val: val, size: size})
|
||||
c.size += size
|
||||
}
|
||||
|
||||
// globalDefaultReplacementProvider handles replacements
|
||||
// that can be used in any context, such as system variables,
|
||||
// time, or environment variables.
|
||||
|
|
@ -453,3 +530,6 @@ const ReplacerCtxKey CtxKey = "replacer"
|
|||
const phOpen, phClose, phEscape = '{', '}', '\\'
|
||||
|
||||
const filePrefix = "file."
|
||||
|
||||
// maxFileCacheBytes bounds the memoized file contents to 8 MiB (8,388,608 bytes) per replace call.
|
||||
const maxFileCacheBytes = 8 << 20
|
||||
|
|
|
|||
120
replacer_test.go
120
replacer_test.go
|
|
@ -19,6 +19,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -518,6 +519,125 @@ func TestReplacerTimeNowRFC3339(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReplacerRepeatedFilePlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fileA := filepath.Join(dir, "a.txt")
|
||||
fileB := filepath.Join(dir, "b.txt")
|
||||
missing := filepath.Join(dir, "missing.txt")
|
||||
if err := os.WriteFile(fileA, []byte("alpha\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fileB, []byte("beta"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phA, phB, phMissing := "{file."+fileA+"}", "{file."+fileB+"}", "{file."+missing+"}"
|
||||
|
||||
for i, tc := range []struct {
|
||||
repl func() *Replacer
|
||||
known bool
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
repl: NewReplacer,
|
||||
input: phA + "|" + phA + "|" + phB + "|" + phA,
|
||||
expected: "alpha|alpha|beta|alpha",
|
||||
},
|
||||
{
|
||||
repl: NewReplacer,
|
||||
input: strings.Repeat(phA, 20000),
|
||||
expected: strings.Repeat("alpha", 20000),
|
||||
},
|
||||
{
|
||||
repl: NewReplacer,
|
||||
known: true,
|
||||
input: phMissing + "|" + phMissing,
|
||||
expected: "EMPTY|EMPTY",
|
||||
},
|
||||
{
|
||||
repl: NewReplacer,
|
||||
known: false,
|
||||
input: phMissing + "|" + phMissing,
|
||||
expected: "EMPTY|EMPTY",
|
||||
},
|
||||
{
|
||||
repl: NewEmptyReplacer,
|
||||
known: true,
|
||||
input: phMissing + "|" + phMissing,
|
||||
expected: phMissing + "|" + phMissing,
|
||||
},
|
||||
{
|
||||
repl: func() *Replacer { return NewReplacer().WithoutFile() },
|
||||
known: true,
|
||||
input: phA + "|" + phA,
|
||||
expected: phA + "|" + phA,
|
||||
},
|
||||
} {
|
||||
var actual string
|
||||
if tc.known {
|
||||
actual = tc.repl().ReplaceKnown(tc.input, "EMPTY")
|
||||
} else {
|
||||
actual = tc.repl().ReplaceAll(tc.input, "EMPTY")
|
||||
}
|
||||
if actual != tc.expected {
|
||||
t.Errorf("Test %d: expected %.80q, got %.80q", i, tc.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCacheEviction(t *testing.T) {
|
||||
big := strings.Repeat("x", maxFileCacheBytes/2+1)
|
||||
var loads int
|
||||
load := func(key string) (any, bool) {
|
||||
loads++
|
||||
if key == "file.unknown" {
|
||||
return nil, false
|
||||
}
|
||||
return key + big, true
|
||||
}
|
||||
|
||||
var c fileCache
|
||||
for i, tc := range []struct {
|
||||
key string
|
||||
found bool
|
||||
wantLoads int
|
||||
}{
|
||||
{key: "file.a", found: true, wantLoads: 1},
|
||||
{key: "file.a", found: true, wantLoads: 1},
|
||||
{key: "file.b", found: true, wantLoads: 2}, // evicts file.a
|
||||
{key: "file.b", found: true, wantLoads: 2},
|
||||
{key: "file.a", found: true, wantLoads: 3},
|
||||
{key: "file.unknown", found: false, wantLoads: 4},
|
||||
{key: "file.unknown", found: false, wantLoads: 5},
|
||||
} {
|
||||
val, found := c.get(tc.key, load)
|
||||
if found != tc.found {
|
||||
t.Errorf("Test %d: expected found=%t, got %t", i, tc.found, found)
|
||||
}
|
||||
if tc.found && val != tc.key+big {
|
||||
t.Errorf("Test %d: wrong value for %s", i, tc.key)
|
||||
}
|
||||
if loads != tc.wantLoads {
|
||||
t.Errorf("Test %d: expected %d loads, got %d", i, tc.wantLoads, loads)
|
||||
}
|
||||
if c.size > maxFileCacheBytes {
|
||||
t.Errorf("Test %d: cache holds %d bytes, over the %d limit", i, c.size, maxFileCacheBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReplacerRepeatedFile(b *testing.B) {
|
||||
file := filepath.Join(b.TempDir(), "a.txt")
|
||||
if err := os.WriteFile(file, []byte("alpha"), 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
input := strings.Repeat("{file."+file+"}", 1000)
|
||||
rep := NewReplacer()
|
||||
for b.Loop() {
|
||||
rep.ReplaceAll(input, "")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReplacer(b *testing.B) {
|
||||
type testCase struct {
|
||||
name, input, empty string
|
||||
|
|
|
|||
Loading…
Reference in New Issue