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
Mohammed Al Sahaf 2026-10-25 01:32:06 +03:00 committed by GitHub
parent 62bc606433
commit 4e8de214bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 201 additions and 1 deletions

View File

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

View File

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