Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*
!caddy-r2alias
caddy-r2alias/**/.*
13 changes: 9 additions & 4 deletions .github/workflows/docker--caddy-s3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ jobs:
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26"
cache-dependency-path: docker/images/caddy-s3/modules/r2alias/go.sum
cache-dependency-path: caddy-r2alias/go.sum

- name: Run module tests
working-directory: docker/images/caddy-s3/modules/r2alias
run: go test -race -count=1 ./...
working-directory: caddy-r2alias
shell: bash
run: |
go test -race -count=1 -v ./... 2>&1 | tee /tmp/module-tests.log
# caddytest skips the end-to-end tests when a prerequisite fails, and
# an all-skipped run exits 0 — assert nothing was skipped.
! grep -q -- "--- SKIP" /tmp/module-tests.log

build-and-push:
name: Build + push image
Expand Down Expand Up @@ -67,7 +72,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: ./docker/images/caddy-s3
context: .
file: ./docker/images/caddy-s3/Dockerfile
platforms: linux/amd64
push: true
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ loadtest/results/

# dossier: internal scratchpad theater tooling
.scratchpad/

# per-directory agent state (also covered by a personal global ignore)
.claude/
99 changes: 99 additions & 0 deletions caddy-r2alias/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package r2alias

import (
"context"
"fmt"
"time"

"github.com/hashicorp/golang-lru/v2/expirable"
"golang.org/x/sync/singleflight"
)

// aliasCache is a bounded LRU with per-entry TTL and singleflight stampede
// control. It holds both hit and missing-alias sentinel entries so scan
// traffic against dead sites is absorbed by the cache rather than amplified
// to R2.
type aliasCache struct {
lru *expirable.LRU[string, aliasEntry]
sf singleflight.Group
fetchTimeout time.Duration
}

// newAliasCache clamps a non-positive bound to its default. expirable.NewLRU
// reads size 0 as unbounded and ttl 0 as never-expiring, and the cache key
// carries the Host header, so an unclamped zero is a memory sink and a stuck
// alias flip.
func newAliasCache(size int, ttl, fetchTimeout time.Duration) *aliasCache {
if size <= 0 {
size = defaultCacheMaxEntries
}
if ttl <= 0 {
ttl = defaultCacheTTL
}
if fetchTimeout <= 0 {
fetchTimeout = defaultFetchTimeout
}
return &aliasCache{
lru: expirable.NewLRU[string, aliasEntry](size, nil, ttl),
fetchTimeout: fetchTimeout,
}
}

func cacheKey(bucket, site, aliasName string) string {
return bucket + "/" + site + "/" + aliasName
}

// Resolve returns the cached entry or invokes fetchFn (coalesced via
// singleflight). Errors are never cached — sticky errors would amplify
// upstream outages across the TTL window.
//
// The shared flight runs on a context detached from every caller and bounded
// by fetchTimeout, so whichever caller happened to start it cannot cancel the
// result the others are waiting on. Each caller still honours its own context.
func (c *aliasCache) Resolve(
ctx context.Context,
bucket, site, aliasName string,
fetchFn func(context.Context, string) (aliasEntry, error),
) (aliasEntry, error) {
key := cacheKey(bucket, site, aliasName)

if entry, ok := c.lru.Get(key); ok {
return entry, nil
}

ch := c.sf.DoChan(key, func() (val any, err error) {
// A DoChan flight re-panics on a detached goroutine that no caller
// frame can recover, so ServeHTTP's own recover cannot see it and the
// process dies. Convert it here to keep that guarantee.
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("r2_alias: alias fetch panic: %v", rec)
}
}()

// Re-check inside the flight: a concurrent winner may have populated
// the cache between our miss and acquiring the singleflight slot.
if entry, ok := c.lru.Get(key); ok {
return entry, nil
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.fetchTimeout)
defer cancel()

entry, ferr := fetchFn(fetchCtx, key)
if ferr != nil {
return aliasEntry{}, ferr
}
c.lru.Add(key, entry)
return entry, nil
})

select {
case res := <-ch:
if res.Err != nil {
return aliasEntry{}, res.Err
}
return res.Val.(aliasEntry), nil
case <-ctx.Done():
return aliasEntry{}, ctx.Err()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

func TestCache_HitAfterMiss(t *testing.T) {
c := newAliasCache(10, 1*time.Second)
c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
Expand Down Expand Up @@ -39,7 +39,7 @@ func TestCache_HitAfterMiss(t *testing.T) {
}

func TestCache_TTLExpiry(t *testing.T) {
c := newAliasCache(10, 50*time.Millisecond)
c := newAliasCache(10, 50*time.Millisecond, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
Expand All @@ -59,7 +59,7 @@ func TestCache_TTLExpiry(t *testing.T) {
}

func TestCache_LRUEvictionAtCapacity(t *testing.T) {
c := newAliasCache(3, 10*time.Second)
c := newAliasCache(3, 10*time.Second, time.Minute)
var mu sync.Mutex
fetchLog := []string{}

Expand Down Expand Up @@ -99,7 +99,7 @@ func TestCache_LRUEvictionAtCapacity(t *testing.T) {
}

func TestCache_MissingSentinelCached(t *testing.T) {
c := newAliasCache(10, 1*time.Second)
c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
Expand Down Expand Up @@ -128,7 +128,7 @@ func TestCache_MissingSentinelCached(t *testing.T) {

func TestCache_Singleflight(t *testing.T) {
const concurrency = 1000
c := newAliasCache(10, 1*time.Second)
c := newAliasCache(10, 1*time.Second, time.Minute)

var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
Expand Down Expand Up @@ -157,7 +157,7 @@ func TestCache_Singleflight(t *testing.T) {

// Sticky errors would amplify outages, so fetchFn errors are never cached.
func TestCache_ErrorNotCached(t *testing.T) {
c := newAliasCache(10, 1*time.Second)
c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
testErr := errors.New("transient r2 failure")

Expand Down Expand Up @@ -185,7 +185,7 @@ func TestCache_ErrorNotCached(t *testing.T) {
}

func TestCache_KeyComposition(t *testing.T) {
c := newAliasCache(10, 1*time.Second)
c := newAliasCache(10, 1*time.Second, time.Minute)
var mu sync.Mutex
seen := map[string]struct{}{}

Expand Down Expand Up @@ -219,3 +219,76 @@ func TestCache_KeyComposition(t *testing.T) {
}
}
}

func TestNewAliasCache_ClampsNonPositiveBounds(t *testing.T) {
c := newAliasCache(0, 0, time.Minute)

for i := 0; i < defaultCacheMaxEntries+1; i++ {
c.lru.Add(fmt.Sprintf("k%d", i), aliasEntry{DeployID: "d", Present: true})
}

if got := c.lru.Len(); got > defaultCacheMaxEntries {
t.Fatalf("a size-0 cache must clamp to %d entries, held %d", defaultCacheMaxEntries, got)
}
}

func TestCache_LeaderExitDoesNotFailWaiters(t *testing.T) {
c := newAliasCache(10, time.Minute, time.Minute)

release := make(chan struct{})
var started sync.WaitGroup
started.Add(1)
var once sync.Once
fetch := func(ctx context.Context, _ string) (aliasEntry, error) {
once.Do(started.Done)
select {
case <-release:
return aliasEntry{DeployID: "d1", Present: true}, nil
case <-ctx.Done():
return aliasEntry{}, ctx.Err()
}
}

leaderCtx, cancelLeader := context.WithCancel(context.Background())
leaderDone := make(chan error, 1)
go func() {
_, err := c.Resolve(leaderCtx, "b", "site-a", "production", fetch)
leaderDone <- err
}()

started.Wait()

const waiters = 5
results := make(chan error, waiters)
for i := 0; i < waiters; i++ {
go func() {
_, err := c.Resolve(context.Background(), "b", "site-a", "production", fetch)
results <- err
}()
}
time.Sleep(50 * time.Millisecond)

cancelLeader()
<-leaderDone
close(release)

for i := 0; i < waiters; i++ {
select {
case err := <-results:
if err != nil {
t.Fatalf("waiter %d: the leader leaving must not fail it: %v", i, err)
}
case <-time.After(3 * time.Second):
t.Fatalf("waiter %d: never returned", i)
}
}
}

func TestCache_FetchTimeoutBoundsTheFlight(t *testing.T) {
c := newAliasCache(10, time.Minute, 30*time.Millisecond)

_, err := c.Resolve(context.Background(), "b", "site-a", "production", blockingFetcher)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("want context.DeadlineExceeded, got %v", err)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,74 +27,84 @@
// secret_access_key <str>
// cache_ttl <duration>
// cache_max_entries <int>
// fetch_timeout <duration>
// preview_subdomain <str>
// root_domain <str>
// deploy_id_regex <str>
// }
//
// Unknown tokens are rejected so typos surface at config-parse time.
func (r *R2Alias) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
if d.NextArg() {
return d.ArgErr()
}
for d.NextBlock(0) {
switch d.Val() {
case "bucket":
if !d.NextArg() {
return d.ArgErr()
}
r.Bucket = d.Val()
case "endpoint":
if !d.NextArg() {
return d.ArgErr()
}
r.Endpoint = d.Val()
case "region":
if !d.NextArg() {
return d.ArgErr()
}
r.Region = d.Val()
case "access_key_id":
if !d.NextArg() {
return d.ArgErr()
}
r.AccessKeyID = d.Val()
case "secret_access_key":
if !d.NextArg() {
return d.ArgErr()
}
r.SecretAccessKey = d.Val()
case "cache_ttl":
if !d.NextArg() {
return d.ArgErr()
}
dur, err := time.ParseDuration(d.Val())
if err != nil {
return d.Errf("cache_ttl: %v", err)
}
r.CacheTTL = dur
case "fetch_timeout":
if !d.NextArg() {
return d.ArgErr()
}
dur, err := time.ParseDuration(d.Val())
if err != nil {
return d.Errf("fetch_timeout: %v", err)
}
r.FetchTimeout = dur
case "cache_max_entries":
if !d.NextArg() {
return d.ArgErr()
}
n, err := strconv.Atoi(d.Val())
if err != nil {
return d.Errf("cache_max_entries: %v", err)
}
r.CacheMaxEntries = n
case "preview_subdomain":
if !d.NextArg() {
return d.ArgErr()
}
r.PreviewSubdomain = d.Val()
case "root_domain":
if !d.NextArg() {
return d.ArgErr()
}
r.RootDomain = d.Val()
case "deploy_id_regex":
if !d.NextArg() {

Check notice on line 107 in caddy-r2alias/caddyfile.go

View check run for this annotation

codefactor.io / CodeFactor

caddy-r2alias/caddyfile.go#L36-L107

Complex Method
return d.ArgErr()
}
r.DeployIDRegex = d.Val()
Expand Down
Loading