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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion lastcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
34 changes: 34 additions & 0 deletions lastcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down