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