@marcel91200 / caddy Mirror · read-only / commits / 4e8de214
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 03:30 UTC

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>

Mohammed Al Sahaf committed Oct 25, 2026 at 01:32 UTC 4e8de214bc77bce405a5355a9d17d4845823c684
2 files changed +201 -1
replacer.go
+81 -1
@@ -16,6 +16,7 @@ package caddy
16
17 import (
18 "bytes"
19 + "container/list"
20 "fmt"
21 "io"
22 "net/http"
@@ -192,6 +193,9 @@ func (r *Replacer) replace(input, empty string,
193 // fail fast if too many placeholders are unclosed
194 var unclosedCount int
195
196 + // {file.*} lookups hit the disk, so repeats within one call are memoized
197 + var files *fileCache
198 +
199 scan:
200 for i := 0; i < len(input); i++ {
201 // check for escaped braces
@@ -236,7 +240,17 @@ scan:
240 key := input[i+1 : end]
241
242 // try to get a value for this key, handle empty values accordingly
239 - val, found := r.Get(key)
243 + var val any
244 + var found bool
245 + isFileKey := strings.HasPrefix(key, filePrefix)
246 + if files == nil && isFileKey && r.hasFileProvider() {
247 + files = new(fileCache)
248 + }
249 + if files != nil && isFileKey {
250 + val, found = files.get(key, r.Get)
251 + } else {
252 + val, found = r.Get(key)
253 + }
254 if !found {
255 // placeholder is unknown (unrecognized); handle accordingly
256 if errOnUnknown {
@@ -372,6 +386,69 @@ func (f fileReplacementProvider) replace(key string) (any, bool) {
386 return string(body), true
387 }
388
389 +func (r *Replacer) hasFileProvider() bool {
390 + for _, p := range r.providers {
391 + if _, ok := p.(fileReplacementProvider); ok {
392 + return true
393 + }
394 + }
395 + return false
396 +}
397 +
398 +// fileCache memoizes {file.*} lookups for one replace call. Contents live in a
399 +// byte-bounded LRU; misses and empty results cost no more than their key.
400 +type fileCache struct {
401 + lru *list.List
402 + entries map[string]*list.Element
403 + size int
404 + emptyResults map[string]any
405 +}
406 +
407 +type fileCacheEntry struct {
408 + key string
409 + val any
410 + size int
411 +}
412 +
413 +func (c *fileCache) get(key string, load func(string) (any, bool)) (any, bool) {
414 + if el, ok := c.entries[key]; ok {
415 + c.lru.MoveToFront(el)
416 + return el.Value.(*fileCacheEntry).val, true
417 + }
418 + if val, ok := c.emptyResults[key]; ok {
419 + return val, true
420 + }
421 +
422 + val, found := load(key)
423 + if !found {
424 + return val, found
425 + }
426 + switch s, isStr := val.(string); {
427 + case val == nil || (isStr && s == ""):
428 + if c.emptyResults == nil {
429 + c.emptyResults = make(map[string]any)
430 + }
431 + c.emptyResults[key] = val
432 + case isStr && len(s) <= maxFileCacheBytes:
433 + c.add(key, val, len(s))
434 + }
435 + return val, found
436 +}
437 +
438 +func (c *fileCache) add(key string, val any, size int) {
439 + if c.lru == nil {
440 + c.lru = list.New()
441 + c.entries = make(map[string]*list.Element)
442 + }
443 + for c.size+size > maxFileCacheBytes {
444 + evicted := c.lru.Remove(c.lru.Back()).(*fileCacheEntry)
445 + delete(c.entries, evicted.key)
446 + c.size -= evicted.size
447 + }
448 + c.entries[key] = c.lru.PushFront(&fileCacheEntry{key: key, val: val, size: size})
449 + c.size += size
450 +}
451 +
452 // globalDefaultReplacementProvider handles replacements
453 // that can be used in any context, such as system variables,
454 // time, or environment variables.
@@ -453,3 +530,6 @@ const ReplacerCtxKey CtxKey = "replacer"
530 const phOpen, phClose, phEscape = '{', '}', '\\'
531
532 const filePrefix = "file."
533 +
534 +// maxFileCacheBytes bounds the memoized file contents to 8 MiB (8,388,608 bytes) per replace call.
535 +const maxFileCacheBytes = 8 << 20
replacer_test.go
+120
@@ -19,6 +19,7 @@ import (
19 "os"
20 "path/filepath"
21 "runtime"
22 + "strings"
23 "sync"
24 "testing"
25 "time"
@@ -518,6 +519,125 @@ func TestReplacerTimeNowRFC3339(t *testing.T) {
519 }
520 }
521
522 +func TestReplacerRepeatedFilePlaceholders(t *testing.T) {
523 + dir := t.TempDir()
524 + fileA := filepath.Join(dir, "a.txt")
525 + fileB := filepath.Join(dir, "b.txt")
526 + missing := filepath.Join(dir, "missing.txt")
527 + if err := os.WriteFile(fileA, []byte("alpha\n"), 0o600); err != nil {
528 + t.Fatal(err)
529 + }
530 + if err := os.WriteFile(fileB, []byte("beta"), 0o600); err != nil {
531 + t.Fatal(err)
532 + }
533 + phA, phB, phMissing := "{file."+fileA+"}", "{file."+fileB+"}", "{file."+missing+"}"
534 +
535 + for i, tc := range []struct {
536 + repl func() *Replacer
537 + known bool
538 + input string
539 + expected string
540 + }{
541 + {
542 + repl: NewReplacer,
543 + input: phA + "|" + phA + "|" + phB + "|" + phA,
544 + expected: "alpha|alpha|beta|alpha",
545 + },
546 + {
547 + repl: NewReplacer,
548 + input: strings.Repeat(phA, 20000),
549 + expected: strings.Repeat("alpha", 20000),
550 + },
551 + {
552 + repl: NewReplacer,
553 + known: true,
554 + input: phMissing + "|" + phMissing,
555 + expected: "EMPTY|EMPTY",
556 + },
557 + {
558 + repl: NewReplacer,
559 + known: false,
560 + input: phMissing + "|" + phMissing,
561 + expected: "EMPTY|EMPTY",
562 + },
563 + {
564 + repl: NewEmptyReplacer,
565 + known: true,
566 + input: phMissing + "|" + phMissing,
567 + expected: phMissing + "|" + phMissing,
568 + },
569 + {
570 + repl: func() *Replacer { return NewReplacer().WithoutFile() },
571 + known: true,
572 + input: phA + "|" + phA,
573 + expected: phA + "|" + phA,
574 + },
575 + } {
576 + var actual string
577 + if tc.known {
578 + actual = tc.repl().ReplaceKnown(tc.input, "EMPTY")
579 + } else {
580 + actual = tc.repl().ReplaceAll(tc.input, "EMPTY")
581 + }
582 + if actual != tc.expected {
583 + t.Errorf("Test %d: expected %.80q, got %.80q", i, tc.expected, actual)
584 + }
585 + }
586 +}
587 +
588 +func TestFileCacheEviction(t *testing.T) {
589 + big := strings.Repeat("x", maxFileCacheBytes/2+1)
590 + var loads int
591 + load := func(key string) (any, bool) {
592 + loads++
593 + if key == "file.unknown" {
594 + return nil, false
595 + }
596 + return key + big, true
597 + }
598 +
599 + var c fileCache
600 + for i, tc := range []struct {
601 + key string
602 + found bool
603 + wantLoads int
604 + }{
605 + {key: "file.a", found: true, wantLoads: 1},
606 + {key: "file.a", found: true, wantLoads: 1},
607 + {key: "file.b", found: true, wantLoads: 2}, // evicts file.a
608 + {key: "file.b", found: true, wantLoads: 2},
609 + {key: "file.a", found: true, wantLoads: 3},
610 + {key: "file.unknown", found: false, wantLoads: 4},
611 + {key: "file.unknown", found: false, wantLoads: 5},
612 + } {
613 + val, found := c.get(tc.key, load)
614 + if found != tc.found {
615 + t.Errorf("Test %d: expected found=%t, got %t", i, tc.found, found)
616 + }
617 + if tc.found && val != tc.key+big {
618 + t.Errorf("Test %d: wrong value for %s", i, tc.key)
619 + }
620 + if loads != tc.wantLoads {
621 + t.Errorf("Test %d: expected %d loads, got %d", i, tc.wantLoads, loads)
622 + }
623 + if c.size > maxFileCacheBytes {
624 + t.Errorf("Test %d: cache holds %d bytes, over the %d limit", i, c.size, maxFileCacheBytes)
625 + }
626 + }
627 +}
628 +
629 +func BenchmarkReplacerRepeatedFile(b *testing.B) {
630 + file := filepath.Join(b.TempDir(), "a.txt")
631 + if err := os.WriteFile(file, []byte("alpha"), 0o600); err != nil {
632 + b.Fatal(err)
633 + }
634 + input := strings.Repeat("{file."+file+"}", 1000)
635 + rep := NewReplacer()
636 + for b.Loop() {
637 + rep.ReplaceAll(input, "")
638 + }
639 +}
640 +
641 func BenchmarkReplacer(b *testing.B) {
642 type testCase struct {
643 name, input, empty string