-
Notifications
You must be signed in to change notification settings - Fork 3
Implement fuzzy refresh middleware for caching #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7208eac
ffecf64
a9a86a6
d587068
33bfe1e
3055c28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,8 @@ package caching | |||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "io" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "math/rand/v2" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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
AI
Jan 14, 2026
There was a problem hiding this comment.
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
AI
Jan 14, 2026
There was a problem hiding this comment.
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.
| // 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() |
There was a problem hiding this comment.
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.