diff --git a/README.md b/README.md index 0ebad92..d23de3f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ type Result[V any] struct { | `NegativeTTL` | How long an authoritative miss is served from cache before re-fetching. `0` disables negative caching (misses still evict). | | `OnError` | Optional `func(key any, err error)` called when a background refresh fails. | | `Context` | Base context for background refreshes (they outlive the request). Defaults to `context.Background()`. | +| `Clock` | Optional `func() time.Time` used for all expiry checks. Defaults to `time.Now`. Override in tests to control TTLs deterministically; must be safe for concurrent use. | ## Migrating from v1 diff --git a/lastcache.go b/lastcache.go index b3516d5..a13e4df 100644 --- a/lastcache.go +++ b/lastcache.go @@ -80,6 +80,12 @@ type Config struct { // Context is the base context used for background refreshes, which outlive // the request that triggered them. Defaults to context.Background(). Context context.Context + + // Clock returns the current time and defaults to time.Now. Override it in + // tests to control TTL expiry deterministically instead of sleeping. The + // cache reads it from background goroutines too, so a test clock must be + // safe for concurrent use. + Clock func() time.Time } // Result carries a value plus whether it was served stale. @@ -140,9 +146,14 @@ func New[K comparable, V any](config Config) *Cache[K, V] { base = context.Background() } + clock := config.Clock + if clock == nil { + clock = time.Now + } + return &Cache[K, V]{ config: config, - clock: time.Now, + clock: clock, baseCtx: base, entries: make(map[K]item[V]), semaphore: make(chan struct{}, sem), diff --git a/lastcache_test.go b/lastcache_test.go index 93c25a3..4591633 100644 --- a/lastcache_test.go +++ b/lastcache_test.go @@ -34,6 +34,40 @@ func TestNew_Defaults(t *testing.T) { } } +// Config.Clock lets tests control expiry without sleeping or touching +// internals. +func TestConfig_Clock(t *testing.T) { + var mu sync.Mutex + now := fixedTime + c := New[string, string](Config{ + TTL: time.Minute, + Clock: func() time.Time { + mu.Lock() + defer mu.Unlock() + return now + }, + }) + c.Set("k", "v") + + var calls int32 + fetch := func(context.Context, string) (string, error) { + atomic.AddInt32(&calls, 1) + return "v2", nil + } + + if v, _ := c.Get(context.Background(), "k", fetch); v != "v" || atomic.LoadInt32(&calls) != 0 { + t.Fatalf("fresh hit: got %q with %d fetches, want \"v\" with 0", v, atomic.LoadInt32(&calls)) + } + + mu.Lock() + now = now.Add(2 * time.Minute) // expire + mu.Unlock() + + if v, _ := c.Get(context.Background(), "k", fetch); v != "v2" || atomic.LoadInt32(&calls) != 1 { + t.Fatalf("after expiry: got %q with %d fetches, want \"v2\" with 1", v, atomic.LoadInt32(&calls)) + } +} + func TestGet_FreshHit_NoFetch(t *testing.T) { now := fixedTime c := New[string, int](Config{TTL: time.Minute})