diff --git a/README.md b/README.md index 8f2d51f..04d4758 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,29 @@ func main() { } ``` +All unexpired items can be iterated over with the `Range` and +`RangeBackwards` methods, or, on Go 1.23 and above, with the +`KeysSeq` and `ItemsSeq` range-over-func iterators. The latter are the +lazy counterparts of `Keys` and `Items`: they visit items in the same +order as `Range` (from the most to the least recently added or updated) +without allocating an intermediate slice or map, and support stopping +early with `break`: +```go +func main() { + cache := ttlcache.New[string, string]() + cache.Set("first", "value1", ttlcache.DefaultTTL) + cache.Set("second", "value2", ttlcache.DefaultTTL) + + for key := range cache.KeysSeq() { + fmt.Println(key) + } + + for key, item := range cache.ItemsSeq() { + fmt.Println(key, item.Value()) + } +} +``` + ## Examples See the [examples](https://github.com/jellydator/ttlcache/tree/v3/examples) directory for complete applications demonstrating how to use `ttlcache`. diff --git a/cache.go b/cache.go index 946bdf5..f599f03 100644 --- a/cache.go +++ b/cache.go @@ -4,6 +4,7 @@ import ( "container/list" "context" "fmt" + "iter" "sync" "time" @@ -641,6 +642,38 @@ func (c *Cache[K, V]) RangeBackwards(fn func(item *Item[K, V]) bool) { c.items.mu.RUnlock() } +// KeysSeq returns an iterator that yields the key of each unexpired item +// in the cache. It is the lazy, range-over-func counterpart of Keys and +// visits items in the same order as Range (from the most to the least +// recently added or updated). Stopping the iteration early is supported. +// +// As with Range, the cache lock is not held while a key is yielded, so it +// is safe to call other cache methods from within the loop. +func (c *Cache[K, V]) KeysSeq() iter.Seq[K] { + return func(yield func(K) bool) { + c.Range(func(item *Item[K, V]) bool { + return yield(item.Key()) + }) + } +} + +// ItemsSeq returns an iterator that yields the key and the item of each +// unexpired item in the cache. It is the lazy, range-over-func counterpart +// of Items and visits items in the same order as Range (from the most to +// the least recently added or updated). Stopping the iteration early is +// supported. +// +// As with Range, the cache lock is not held while an item is yielded, so it +// is safe to call other cache methods from within the loop. Unlike Items, it +// does not allocate an intermediate map. +func (c *Cache[K, V]) ItemsSeq() iter.Seq2[K, *Item[K, V]] { + return func(yield func(K, *Item[K, V]) bool) { + c.Range(func(item *Item[K, V]) bool { + return yield(item.Key(), item) + }) + } +} + // Metrics returns the metrics of the cache. func (c *Cache[K, V]) Metrics() Metrics { c.metricsMu.RLock() diff --git a/cache_test.go b/cache_test.go index b4dcf27..437f703 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1083,6 +1083,87 @@ func Test_Cache_RangeBackwards(t *testing.T) { }) } +func Test_Cache_KeysSeq(t *testing.T) { + c := prepCache(0, DefaultTTL, "1", "2", "3", "4", "5") + addExpiredCacheItems(c, "6") + + // full iteration, expired items excluded, order matches Range + var results []string + for key := range c.KeysSeq() { + results = append(results, key) + } + assert.Equal(t, []string{"5", "4", "3", "2", "1"}, results) + + // early stop via break + results = nil + for key := range c.KeysSeq() { + results = append(results, key) + if key == "4" { + break + } + } + assert.Equal(t, []string{"5", "4"}, results) + + // empty cache does not panic and yields nothing + emptyCache := New[string, string]() + assert.NotPanics(t, func() { + for range emptyCache.KeysSeq() { + t.Fatal("empty cache must not yield any keys") + } + }) + + // calling other cache methods during iteration is safe + deletedCache := New[string, string]() + addTTLCacheItems(deletedCache, time.Minute, "6", "3", "4") + assert.NotPanics(t, func() { + for range deletedCache.KeysSeq() { + deletedCache.DeleteAll() + } + }) +} + +func Test_Cache_ItemsSeq(t *testing.T) { + c := prepCache(0, DefaultTTL, "1", "2", "3", "4", "5") + addExpiredCacheItems(c, "6") + + // full iteration, expired items excluded, order matches Range, + // and the yielded key matches the item's own key + var results []string + for key, item := range c.ItemsSeq() { + require.NotNil(t, item) + assert.Equal(t, key, item.Key()) + results = append(results, key) + } + assert.Equal(t, []string{"5", "4", "3", "2", "1"}, results) + + // early stop via break + results = nil + for key := range c.ItemsSeq() { + results = append(results, key) + if key == "4" { + break + } + } + assert.Equal(t, []string{"5", "4"}, results) + + // empty cache does not panic and yields nothing + emptyCache := New[string, string]() + assert.NotPanics(t, func() { + for range emptyCache.ItemsSeq() { + t.Fatal("empty cache must not yield any items") + } + }) + + // calling other cache methods during iteration is safe + deletedCache := New[string, string]() + addTTLCacheItems(deletedCache, time.Minute, "6", "3", "4") + assert.NotPanics(t, func() { + for range deletedCache.ItemsSeq() { + deletedCache.DeleteAll() + } + }) +} + func Test_Cache_Metrics(t *testing.T) { cache := Cache[string, string]{ metrics: Metrics{Evictions: 10},