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
239 changes: 239 additions & 0 deletions server/middleware/caching/caching_fuzzy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package caching

import (
"testing"
"time"

"github.com/omalloc/tavern/api/defined/v1/storage/object"
)

func TestCalculateSoftTTL(t *testing.T) {
tests := []struct {
name string
respUnix int64
expiresAt int64
fuzzyRate float64
wantSoftTTL int64
}{
{
name: "standard case - 80% of 600s",
respUnix: 1000,
expiresAt: 1600,
fuzzyRate: 0.8,
wantSoftTTL: 1480, // 1000 + (600 * 0.8) = 1480
},
{
name: "standard case - 90% of 3600s",
respUnix: 1000,
expiresAt: 4600,
fuzzyRate: 0.9,
wantSoftTTL: 4240, // 1000 + (3600 * 0.9) = 4240
},
{
name: "100% rate - immediate fuzzy refresh",
respUnix: 1000,
expiresAt: 1600,
fuzzyRate: 1.0,
wantSoftTTL: 1600, // 1000 + (600 * 1.0) = 1600
},
{
name: "invalid rate - should default to 0.8",
respUnix: 1000,
expiresAt: 1600,
fuzzyRate: 1.5,
wantSoftTTL: 1480, // 1000 + (600 * 0.8) = 1480
},
{
name: "zero rate - should default to 0.8",
respUnix: 1000,
expiresAt: 1600,
fuzzyRate: 0,
wantSoftTTL: 1480, // 1000 + (600 * 0.8) = 1480
},
{
name: "already expired",
respUnix: 1000,
expiresAt: 900,
fuzzyRate: 0.8,
wantSoftTTL: 900, // Should return expiresAt when already expired
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calculateSoftTTL(tt.respUnix, tt.expiresAt, tt.fuzzyRate)
if got != tt.wantSoftTTL {
t.Errorf("calculateSoftTTL() = %v, want %v", got, tt.wantSoftTTL)
}
})
}
}

func TestShouldTriggerFuzzyRefresh(t *testing.T) {
softTTL := int64(1000)
hardTTL := int64(1100)

tests := []struct {
name string
now int64
description string
}{
{
name: "before soft TTL",
now: 900,
description: "should never trigger before soft TTL",
},
{
name: "at soft TTL",
now: 1000,
description: "should have 0% probability at soft TTL",
},
{
name: "midpoint",
now: 1050,
description: "should have 50% probability at midpoint",
},
{
name: "near hard TTL",
now: 1099,
description: "should have ~99% probability near hard TTL",
},
{
name: "at hard TTL",
now: 1100,
description: "should never trigger at hard TTL (handled by hasExpired)",
},
{
name: "after hard TTL",
now: 1200,
description: "should never trigger after hard TTL",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Run multiple times to check probability behavior
iterations := 100
triggered := 0

for i := 0; i < iterations; i++ {
if shouldTriggerFuzzyRefresh(tt.now, softTTL, hardTTL) {
triggered++
}
}

// Check boundary conditions
if tt.now < softTTL || tt.now >= hardTTL {
if triggered > 0 {
t.Errorf("shouldTriggerFuzzyRefresh() triggered %d times out of %d, should never trigger %s",
triggered, iterations, tt.description)
}
} else {
// In the fuzzy zone, we expect some triggers based on probability
t.Logf("Triggered %d times out of %d at now=%d (soft=%d, hard=%d) - %s",
triggered, iterations, tt.now, softTTL, hardTTL, tt.description)
}
})
}
}

func TestShouldTriggerFuzzyRefreshProbability(t *testing.T) {
softTTL := int64(1000)
hardTTL := int64(2000)
iterations := 1000

tests := []struct {
name string
now int64
expectedProbRange [2]float64 // min and max expected probability
}{
{
name: "at soft TTL (0% probability)",
now: 1000,
expectedProbRange: [2]float64{0, 0.05}, // Allow small margin
},
{
name: "25% into fuzzy zone",
now: 1250,
expectedProbRange: [2]float64{0.15, 0.35},
},
{
name: "50% into fuzzy zone",
now: 1500,
expectedProbRange: [2]float64{0.40, 0.60},
},
{
name: "75% into fuzzy zone",
now: 1750,
expectedProbRange: [2]float64{0.65, 0.85},
},
{
name: "95% into fuzzy zone",
now: 1950,
expectedProbRange: [2]float64{0.90, 1.0},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
triggered := 0
for i := 0; i < iterations; i++ {
if shouldTriggerFuzzyRefresh(tt.now, softTTL, hardTTL) {
triggered++
}
}

probability := float64(triggered) / float64(iterations)
t.Logf("Probability at now=%d: %.2f (triggered %d/%d)",
tt.now, probability, triggered, iterations)

if probability < tt.expectedProbRange[0] || probability > tt.expectedProbRange[1] {
t.Errorf("Probability %.2f outside expected range [%.2f, %.2f]",
probability, tt.expectedProbRange[0], tt.expectedProbRange[1])
}
})
}
}

func TestHasExpired(t *testing.T) {
now := time.Now()

tests := []struct {
name string
expiresAt int64
want bool
}{
{
name: "not expired - 1 hour in future",
expiresAt: now.Add(1 * time.Hour).Unix(),
want: false,
},
{
name: "expired - 1 hour in past",
expiresAt: now.Add(-1 * time.Hour).Unix(),
want: true,
},
{
name: "just expired",
expiresAt: now.Add(-1 * time.Second).Unix(),
want: true,
},
{
name: "not expired - just created",
expiresAt: now.Add(1 * time.Second).Unix(),
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := &object.Metadata{
ExpiresAt: tt.expiresAt,
}
got := hasExpired(md)
if got != tt.want {
t.Errorf("hasExpired() = %v, want %v", got, tt.want)
}
})
}
}
135 changes: 134 additions & 1 deletion server/middleware/caching/caching_revalidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package caching

import (
"context"
"io"
"math/rand/v2"
"net/http"
"time"

Expand All @@ -19,11 +21,87 @@ type RefreshOption func(r *RevalidateProcessor)

type RevalidateProcessor struct{}

// calculateSoftTTL calculates the soft expiration time based on fuzzy_refresh_rate.
// soft_ttl = hard_ttl * fuzzy_ratio
// For example, if hard_ttl is 600s and fuzzy_ratio is 0.8, soft_ttl = 480s
// Valid fuzzy_rate range is (0, 1.0], where 1.0 means fuzzy refresh starts immediately
func calculateSoftTTL(respUnix, expiresAt int64, fuzzyRate float64) int64 {
hardTTL := expiresAt - respUnix
if hardTTL <= 0 {
return expiresAt
}

// Ensure fuzzy rate is in valid range (0, 1.0]
if fuzzyRate <= 0 || fuzzyRate > 1 {
fuzzyRate = 0.8 // default to 0.8 if invalid
}
Comment on lines +35 to +37
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent fallback to default value masks configuration errors. Consider logging a warning when invalid fuzzy_rate values are detected to help with debugging and configuration validation.

Copilot uses AI. Check for mistakes.

softTTL := int64(float64(hardTTL) * fuzzyRate)
return respUnix + softTTL
}

// shouldTriggerFuzzyRefresh determines if we should trigger an async refresh
// based on the current position in the [soft_ttl, hard_ttl) interval.
// The probability increases linearly as we approach hard_ttl.
func shouldTriggerFuzzyRefresh(now, softTTL, hardTTL int64) bool {
if now < softTTL {
// Before soft TTL, no refresh needed
return false
}

if now >= hardTTL {
// After hard TTL, force refresh (handled by hasExpired)
return false
}

// In the fuzzy refresh zone [soft_ttl, hard_ttl)
// Calculate linear probability: P = (now - soft_ttl) / (hard_ttl - soft_ttl)
totalWindow := float64(hardTTL - softTTL)
if totalWindow <= 0 {
return false
}

elapsed := float64(now - softTTL)
probability := elapsed / totalWindow

// Random trigger based on probability using math/rand/v2 which is thread-safe
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While math/rand/v2 is thread-safe for concurrent use, consider documenting this thread-safety assumption in a comment since shouldTriggerFuzzyRefresh is called from multiple goroutines via the async refresh mechanism.

Suggested change
// Random trigger based on probability using math/rand/v2 which is thread-safe
// Random trigger based on probability.
// Note: This relies on math/rand/v2's documented guarantee of being safe for concurrent use,
// because shouldTriggerFuzzyRefresh may be called from multiple goroutines via async refresh.

Copilot uses AI. Check for mistakes.
return rand.Float64() < probability
}

func (r *RevalidateProcessor) Lookup(c *Caching, req *http.Request) (bool, error) {
if c.md == nil {
return false, nil
}
// check if metadata is expired.

now := time.Now().Unix()
hardTTL := c.md.ExpiresAt

// Fuzzy Refresh Logic
if c.opt.FuzzyRefresh && c.opt.FuzzyRefreshRate > 0 {
softTTL := calculateSoftTTL(c.md.RespUnix, c.md.ExpiresAt, c.opt.FuzzyRefreshRate)

// Check if we're in the fuzzy refresh zone [soft_ttl, hard_ttl)
if now >= softTTL && now < hardTTL {
// We're in the fuzzy refresh zone
if shouldTriggerFuzzyRefresh(now, softTTL, hardTTL) {
// Trigger async background refresh
if c.md.HasComplete() && hasConditionHeader(c.md.Headers) {
c.log.Debugf("fuzzy refresh triggered for object: %s (soft_ttl: %s, hard_ttl: %s)",
c.id.Key(),
time.Unix(softTTL, 0).Format(time.DateTime),
time.Unix(hardTTL, 0).Format(time.DateTime))

// Trigger async revalidation in background
go r.asyncRevalidate(c, req)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded goroutine spawning could lead to resource exhaustion under high load. Consider using a worker pool or rate limiting mechanism to cap concurrent background refresh operations per cache key.

Copilot uses AI. Check for mistakes.
}
}

// Still return cache hit - serve stale content while refreshing
return true, nil
}
}

// check if metadata is expired (hard expiration).
if !hasExpired(c.md) {
return true, nil
}
Expand Down Expand Up @@ -153,6 +231,61 @@ func (r *RevalidateProcessor) freshness(c *Caching, resp *http.Response) bool {
return true
}

// asyncRevalidate performs background revalidation for fuzzy refresh
func (r *RevalidateProcessor) asyncRevalidate(c *Caching, req *http.Request) {
// Create a background context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

Comment on lines +234 to +239
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 30-second timeout is hardcoded. Consider making this configurable through CachingOptions to allow tuning based on upstream latency characteristics.

Suggested change
// asyncRevalidate performs background revalidation for fuzzy refresh
func (r *RevalidateProcessor) asyncRevalidate(c *Caching, req *http.Request) {
// Create a background context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const defaultAsyncRevalidateTimeout = 30 * time.Second
// asyncRevalidate performs background revalidation for fuzzy refresh
func (r *RevalidateProcessor) asyncRevalidate(c *Caching, req *http.Request) {
// Create a background context with timeout or inherit existing deadline
baseCtx := context.Background()
var (
ctx context.Context
cancel context.CancelFunc
)
if deadline, ok := req.Context().Deadline(); ok {
// Honor any existing deadline from the incoming request
ctx, cancel = context.WithDeadline(baseCtx, deadline)
} else {
// Fall back to the default async revalidation timeout
ctx, cancel = context.WithTimeout(baseCtx, defaultAsyncRevalidateTimeout)
}
defer cancel()

Copilot uses AI. Check for mistakes.
// Clone the request for background processing
bgReq := req.Clone(ctx)

// Set conditional headers for revalidation
if c.md.Headers.Get("ETag") != "" {
bgReq.Header.Set("If-None-Match", c.md.Headers.Get("ETag"))
}
if c.md.Headers.Get("Last-Modified") != "" {
bgReq.Header.Set("If-Modified-Since", c.md.Headers.Get("Last-Modified"))
}

// Remove Range header for full object revalidation
bgReq.Header.Del("Range")

c.log.Debugf("async fuzzy refresh started for object: %s", c.id.Key())

// Perform the upstream request
resp, err := c.doProxy(bgReq, false)
if err != nil {
c.log.Warnf("async fuzzy refresh failed for object %s: %v", c.id.Key(), err)
return
}
defer closeBody(resp)

// Handle 304 Not Modified - just update freshness metadata
if resp.StatusCode == http.StatusNotModified {
r.freshness(c, resp)
c.log.Debugf("async fuzzy refresh completed (304) for object: %s", c.id.Key())
return
}

// For non-304 responses, the content has changed
// The doProxy method has already wrapped the response body with cache writing logic
// We need to consume the body to trigger the cache update
if resp.StatusCode == http.StatusOK && resp.Body != nil {
// Read the entire response body to trigger cache storage through the wrapper
// This ensures the new content is written to cache
_, err := io.Copy(io.Discard, resp.Body)
if err != nil {
c.log.Warnf("async fuzzy refresh failed to read body for object %s: %v", c.id.Key(), err)
return
}
c.log.Debugf("async fuzzy refresh completed (%d) for object: %s - content updated", resp.StatusCode, c.id.Key())
return
}

c.log.Debugf("async fuzzy refresh completed (%d) for object: %s", resp.StatusCode, c.id.Key())
}

func NewRevalidateProcessor(opts ...RefreshOption) Processor {
return &RevalidateProcessor{}
}
Expand Down