Repository: maypok86/otter (go)

Issue:
Cleaner api contracts
# Clarification and motivation

Now otter uses an api very similar to other go cache libraries, but unfortunately it is not particularly beautiful and extensible.

For example, now we have `Set(key, value)` and `SetWithTTL(key, value, ttl)` functions, but if we want to add `SetIfAbsent(key, value)` function, we also need `SetIfAbsentWithTTL(key, value, ttl)` function, which doesn't look nice anymore. Especially since when we create the cache in the builder we already know its further behavior and whether it will need ttl or not.

We would like to create a cache with a clean api and with the functions it needs using parameters in the builder. This will allow to provide such api as `Set(key, value, ttl)` and `SetIfAbsent(key, value, ttl)`. Also, since in most cases ttl is the same for all entries in the cache, we don't need to pass it every time at all, further simplifying the api. This will also allow us to use optimizations for the expiration policy, since with the same ttl we only need to maintain a simple fifo queue of items and check their expiration one by one.

# Acceptance criteria

1. A more advanced version of the builder has been implemented
2. Otter provides simpler api contracts
3. Implementation tests are written


Pull request description:
[#40] Provide cleaner contracts
## Problem

Now otter uses an api very similar to other go cache libraries, but unfortunately it is not particularly beautiful and extensible.

For example, now we have `Set(key, value)` and `SetWithTTL(key, value, ttl)` functions, but if we want to add `SetIfAbsent(key, value)` function, we also need `SetIfAbsentWithTTL(key, value, ttl)` function, which doesn't look nice anymore.

## Solution

Implement a more advanced version of the builder and a set of cache wrappers.

## Related issue(s)
- Resolves #40

## :white_check_mark: Checklist for your Pull Request

<!--
Ideally a PR has all of the checkmarks set.

If something in this list is irrelevant to your PR, you should still set this
checkmark indicating that you are sure it is dealt with (be that by irrelevance).

If you don't set a checkmark (e. g. don't add a test for new functionality),
you must be able to justify that.
-->

#### Related changes (conditional)

- Tests
    - [x] If I added new functionality, I added tests covering it.
    - [x] If I fixed a bug, I added a regression test to prevent the bug from
      silently reappearing again.

- Documentation
    - [x] I checked whether I should update the docs and did so if necessary:
        - [README](../README.md)

- Public contracts
    - [x] My changes doesn't break project license.

#### Stylistic guide (mandatory)

- [x] My code complies with the [styles guide](https://github.com/uber-go/guide/blob/master/style.md).
- [x] My commit history is clean (only contains changes relating to my
  issue/pull request and no reverted-my-earlier-commit changes) and commit
  messages start with identifiers of related issues in square brackets.

  **Example:** `[#42] Short commit description`

  If necessary both of these can be achieved even after the commits have been
  made/pushed using [rebase and squash](https://git-scm.com/docs/git-rebase).

#### Before merging (mandatory)
- [x] Check __target__  branch of PR is set correctly


Patch:
diff --git a/README.md b/README.md
index a7df1f3..d0c982e 100644
--- a/README.md
+++ b/README.md
@@ -66,79 +66,91 @@ go get -u github.com/maypok86/otter
 
 ### ✏️ Examples <a id="examples" />
 
-**Builder**
-
 Otter uses a builder pattern that allows you to conveniently create a cache object with different parameters
 
+**Cache with const TTL**
 ```go
 package main
 
 import (
-  "github.com/maypok86/otter"
+    "fmt"
+    "time"
+
+    "github.com/maypok86/otter"
 )
 
 func main() {
-  // NewBuilder creates a builder and sets the future cache capacity to 1000 elements.
-  // Returns an error if capacity <= 0.
-  builder, err := otter.NewBuilder[string, string](1000)
-  if err != nil {
-    panic(err)
-  }
-
-  // StatsEnabled determines whether statistics should be calculated when the cache is running.
-  // By default, statistics calculating is disabled.
-  builder.StatsEnabled(true)
-
-  // Cost sets a function to dynamically calculate the weight of a key-value pair.
-  // By default this function always returns 1.
-  builder.Cost(func(key string, value string) uint32 {
-    return uint32(len(value))
-  })
-
-  // Build creates a new cache object or
-  // returns an error if invalid parameters were passed to the builder.
-  cache, err := builder.Build()
-  if err != nil {
-    panic(err)
-  }
-
-  cache.Close()
+    // create a cache with capacity equal to 10000 elements
+    cache, err := otter.MustBuilder[string, string](10_000).
+        CollectStats().
+        Cost(func(key string, value string) uint32 {
+            return 1
+        }).
+        WithTTL(time.Hour).
+        Build()
+    if err != nil {
+        panic(err)
+    }
+
+    // set item with ttl (1 hour) 
+    cache.Set("key", "value")
+
+    // get value from cache
+    value, ok := cache.Get("key")
+    if !ok {
+        panic("not found key")
+    }
+    fmt.Println(value)
+
+    // delete item from cache
+    cache.Delete("key")
+
+    // delete data and stop goroutines
+    cache.Close()
 }
 ```
 
-**Cache**
+**Cache with variable TTL**
 ```go
 package main
 
 import (
-  "fmt"
-  "time"
+    "fmt"
+    "time"
 
-  "github.com/maypok86/otter"
+    "github.com/maypok86/otter"
 )
 
 func main() {
-  // create a cache with capacity equal to 10000 elements
-  cache, err := otter.MustBuilder[string, string](10_000).Build()
-  if err != nil {
-    panic(err)
-  }
-
-  // set key-value pair with ttl (1 hour) 
-  cache.SetWithTTL("key", "value", time.Hour)
-
-  // get value from cache
-  value, ok := cache.Get("key")
-  if !ok {
-    panic("not found key")
-  }
-  fmt.Println(value)
-
-  // delete key-value pair from cache
-  cache.Delete("key")
-
-  // delete data and stop goroutines
-  cache.Close()
+    // create a cache with capacity equal to 10000 elements
+    cache, err := otter.MustBuilder[string, string](10_000).
+        CollectStats().
+        Cost(func(key string, value string) uint32 {
+            return 1
+        }).
+        WithVariableTTL().
+        Build()
+    if err != nil {
+        panic(err)
+    }
+
+    // set item with ttl (1 hour)
+    cache.Set("key1", "value1", time.Hour)
+    // set item with ttl (1 minute)
+    cache.Set("key2", "value2", time.Minute)
+
+    // get value from cache
+    value, ok := cache.Get("key1")
+    if !ok {
+        panic("not found key")
+    }
+    fmt.Println(value)
+
+    // delete item from cache
+    cache.Delete("key1")
+
+    // delete data and stop goroutines
+    cache.Close()
 }
 ```
 
diff --git a/builder.go b/builder.go
index f770adc..d8542bd 100644
--- a/builder.go
+++ b/builder.go
@@ -14,36 +14,77 @@
 
 package otter
 
-import "errors"
+import (
+	"errors"
+	"time"
 
-const (
-	defaultStatsEnabled = false
+	"github.com/maypok86/otter/internal/core"
 )
 
-// ErrIllegalCapacity means that a non-positive capacity has been passed to the NewBuilder.
-var ErrIllegalCapacity = errors.New("capacity should be positive")
+var (
+	// ErrIllegalCapacity means that a non-positive capacity has been passed to the NewBuilder.
+	ErrIllegalCapacity = errors.New("capacity should be positive")
+	// ErrIllegalTTL means that a non-positive ttl has been passed to the Builder.WithTTL.
+	ErrIllegalTTL = errors.New("ttl should be positive")
+)
 
-type options[K comparable, V any] struct {
+type baseOptions[K comparable, V any] struct {
 	capacity     int
 	statsEnabled bool
 	costFunc     func(key K, value V) uint32
 }
 
-func (o *options[K, V]) validate() error {
+func (o *baseOptions[K, V]) collectStats() {
+	o.statsEnabled = true
+}
+
+func (o *baseOptions[K, V]) cost(costFunc func(key K, value V) uint32) {
+	o.costFunc = costFunc
+}
+
+func (o *baseOptions[K, V]) validate() error {
 	return nil
 }
 
-func (o *options[K, V]) toConfig() Config[K, V] {
-	return Config[K, V]{
+func (o *baseOptions[K, V]) toConfig() core.Config[K, V] {
+	return core.Config[K, V]{
 		Capacity:     o.capacity,
 		StatsEnabled: o.statsEnabled,
 		CostFunc:     o.costFunc,
 	}
 }
 
+type constTTLOptions[K comparable, V any] struct {
+	baseOptions[K, V]
+	ttl time.Duration
+}
+
+func (o *constTTLOptions[K, V]) validate() error {
+	if o.ttl <= 0 {
+		return ErrIllegalTTL
+	}
+	return o.baseOptions.validate()
+}
+
+func (o *constTTLOptions[K, V]) toConfig() core.Config[K, V] {
+	c := o.baseOptions.toConfig()
+	c.TTL = &o.ttl
+	return c
+}
+
+type variableTTLOptions[K comparable, V any] struct {
+	baseOptions[K, V]
+}
+
+func (o *variableTTLOptions[K, V]) toConfig() core.Config[K, V] {
+	c := o.baseOptions.toConfig()
+	c.WithVariableTTL = true
+	return c
+}
+
 // Builder is a one-shot builder for creating a cache instance.
 type Builder[K comparable, V any] struct {
-	options[K, V]
+	baseOptions[K, V]
 }
 
 // MustBuilder creates a builder and sets the future cache capacity.
@@ -66,9 +107,9 @@ func NewBuilder[K comparable, V any](capacity int) (*Builder[K, V], error) {
 	}
 
 	return &Builder[K, V]{
-		options: options[K, V]{
+		baseOptions: baseOptions[K, V]{
 			capacity:     capacity,
-			statsEnabled: defaultStatsEnabled,
+			statsEnabled: false,
 			costFunc: func(key K, value V) uint32 {
 				return 1
 			},
@@ -76,28 +117,113 @@ func NewBuilder[K comparable, V any](capacity int) (*Builder[K, V], error) {
 	}, nil
 }
 
-// StatsEnabled determines whether statistics should be calculated when the cache is running.
+// CollectStats determines whether statistics should be calculated when the cache is running.
 //
 // By default, statistics calculating is disabled.
-func (b *Builder[K, V]) StatsEnabled(statsEnabled bool) *Builder[K, V] {
-	b.statsEnabled = statsEnabled
+func (b *Builder[K, V]) CollectStats() *Builder[K, V] {
+	b.collectStats()
 	return b
 }
 
-// Cost sets a function to dynamically calculate the cost of a key-value pair.
+// Cost sets a function to dynamically calculate the cost of an item.
 //
 // By default, this function always returns 1.
 func (b *Builder[K, V]) Cost(costFunc func(key K, value V) uint32) *Builder[K, V] {
-	b.costFunc = costFunc
+	b.cost(costFunc)
+	return b
+}
+
+// WithTTL specifies that each item should be automatically removed from the cache once a fixed duration
+// has elapsed after the item's creation.
+func (b *Builder[K, V]) WithTTL(ttl time.Duration) *ConstTTLBuilder[K, V] {
+	return &ConstTTLBuilder[K, V]{
+		constTTLOptions[K, V]{
+			baseOptions: b.baseOptions,
+			ttl:         ttl,
+		},
+	}
+}
+
+// WithVariableTTL specifies that each item should be automatically removed from the cache once a duration has
+// elapsed after the item's creation. Items are expired based on the custom ttl specified for each item separately.
+//
+// You should prefer WithTTL to this option whenever possible.
+func (b *Builder[K, V]) WithVariableTTL() *VariableTTLBuilder[K, V] {
+	return &VariableTTLBuilder[K, V]{
+		variableTTLOptions[K, V]{
+			baseOptions: b.baseOptions,
+		},
+	}
+}
+
+// Build creates a configured cache or
+// returns an error if invalid parameters were passed to the builder.
+func (b *Builder[K, V]) Build() (Cache[K, V], error) {
+	if err := b.validate(); err != nil {
+		return Cache[K, V]{}, err
+	}
+
+	return newCache(b.toConfig()), nil
+}
+
+// ConstTTLBuilder is a one-shot builder for creating a cache instance.
+type ConstTTLBuilder[K comparable, V any] struct {
+	constTTLOptions[K, V]
+}
+
+// CollectStats determines whether statistics should be calculated when the cache is running.
+//
+// By default, statistics calculating is disabled.
+func (b *ConstTTLBuilder[K, V]) CollectStats() *ConstTTLBuilder[K, V] {
+	b.collectStats()
+	return b
+}
+
+// Cost sets a function to dynamically calculate the cost of an item.
+//
+// By default, this function always returns 1.
+func (b *ConstTTLBuilder[K, V]) Cost(costFunc func(key K, value V) uint32) *ConstTTLBuilder[K, V] {
+	b.cost(costFunc)
+	return b
+}
+
+// Build creates a configured cache or
+// returns an error if invalid parameters were passed to the builder.
+func (b *ConstTTLBuilder[K, V]) Build() (Cache[K, V], error) {
+	if err := b.validate(); err != nil {
+		return Cache[K, V]{}, err
+	}
+
+	return newCache(b.toConfig()), nil
+}
+
+// VariableTTLBuilder is a one-shot builder for creating a cache instance.
+type VariableTTLBuilder[K comparable, V any] struct {
+	variableTTLOptions[K, V]
+}
+
+// CollectStats determines whether statistics should be calculated when the cache is running.
+//
+// By default, statistics calculating is disabled.
+func (b *VariableTTLBuilder[K, V]) CollectStats() *VariableTTLBuilder[K, V] {
+	b.collectStats()
+	return b
+}
+
+// Cost sets a function to dynamically calculate the cost of an item.
+//
+// By default, this function always returns 1.
+func (b *VariableTTLBuilder[K, V]) Cost(costFunc func(key K, value V) uint32) *VariableTTLBuilder[K, V] {
+	b.cost(costFunc)
 	return b
 }
 
 // Build creates a configured cache or
 // returns an error if invalid parameters were passed to the builder.
-func (b *Builder[K, V]) Build() (*Cache[K, V], error) {
+func (b *VariableTTLBuilder[K, V]) Build() (CacheWithVariableTTL[K, V], error) {
 	if err := b.validate(); err != nil {
-		return nil, err
+		return CacheWithVariableTTL[K, V]{}, err
 	}
 
-	return NewCache(b.toConfig()), nil
+	return newCacheWithVariableTTL(b.toConfig()), nil
 }
diff --git a/cache.go b/cache.go
index cd087d2..0d87306 100644
--- a/cache.go
+++ b/cache.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2023 Alexey Mayshev. All rights reserved.
+// Copyright (c) 2024 Alexey Mayshev. All rights reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -15,330 +15,131 @@
 package otter
 
 import (
-	"sync"
 	"time"
 
-	"github.com/maypok86/otter/internal/expire"
-	"github.com/maypok86/otter/internal/hashtable"
-	"github.com/maypok86/otter/internal/lossy"
-	"github.com/maypok86/otter/internal/node"
-	"github.com/maypok86/otter/internal/queue"
-	"github.com/maypok86/otter/internal/s3fifo"
+	"github.com/maypok86/otter/internal/core"
 	"github.com/maypok86/otter/internal/stats"
-	"github.com/maypok86/otter/internal/unixtime"
-	"github.com/maypok86/otter/internal/xmath"
-	"github.com/maypok86/otter/internal/xruntime"
 )
 
-func zeroValue[V any]() V {
-	var zero V
-	return zero
+// Stats is a thread-safe statistics collector.
+type Stats struct {
+	s *stats.Stats
 }
 
-// Config is a set of cache settings.
-type Config[K comparable, V any] struct {
-	Capacity     int
-	StatsEnabled bool
-	CostFunc     func(key K, value V) uint32
+func newStats(s *stats.Stats) Stats {
+	return Stats{s: s}
 }
 
-// Cache is a structure performs a best-effort bounding of a hash table using eviction algorithm
-// to determine which entries to evict when the capacity is exceeded.
-type Cache[K comparable, V any] struct {
-	hashmap       *hashtable.Map[K, V]
-	policy        *s3fifo.Policy[K, V]
-	expirePolicy  *expire.Policy[K, V]
-	stats         *stats.Stats
-	readBuffers   []*lossy.Buffer[node.Node[K, V]]
-	writeBuffer   *queue.MPSC[node.WriteTask[K, V]]
-	evictionMutex sync.Mutex
-	closeOnce     sync.Once
-	isClosed      bool
-	doneClear     chan struct{}
-	costFunc      func(key K, value V) uint32
-	capacity      int
-	mask          uint32
-}
-
-// NewCache returns a new cache instance based on the settings from Config.
-//
-// You should always prefer to create a cache using Builder instead of this method.
-func NewCache[K comparable, V any](c Config[K, V]) *Cache[K, V] {
-	parallelism := xruntime.Parallelism()
-	roundedParallelism := int(xmath.RoundUpPowerOf2(parallelism))
-	writeBufferCapacity := 128 * roundedParallelism
-	readBuffersCount := 4 * roundedParallelism
-
-	readBuffers := make([]*lossy.Buffer[node.Node[K, V]], 0, readBuffersCount)
-	for i := 0; i < readBuffersCount; i++ {
-		readBuffers = append(readBuffers, lossy.New[node.Node[K, V]]())
-	}
-
-	cache := &Cache[K, V]{
-		hashmap:     hashtable.New[K, V](),
-		policy:      s3fifo.NewPolicy[K, V](uint32(c.Capacity)),
-		readBuffers: readBuffers,
-		writeBuffer: queue.NewMPSC[node.WriteTask[K, V]](writeBufferCapacity),
-		doneClear:   make(chan struct{}),
-		mask:        uint32(readBuffersCount - 1),
-		costFunc:    c.CostFunc,
-		capacity:    c.Capacity,
-	}
-
-	cache.expirePolicy = expire.NewPolicy[K, V]()
-	if c.StatsEnabled {
-		cache.stats = stats.New()
-	}
-
-	unixtime.Start()
-	go cache.process()
-	go cache.cleanup()
-
-	return cache
+// Hits returns the number of cache hits.
+func (s Stats) Hits() int64 {
+	return s.s.Hits()
 }
 
-func (c *Cache[K, V]) getReadBufferIdx() int {
-	return int(xruntime.Fastrand() & c.mask)
+// Misses returns the number of cache misses.
+func (s Stats) Misses() int64 {
+	return s.s.Misses()
 }
 
-// Has checks if there is an item with the given key in the cache.
-func (c *Cache[K, V]) Has(key K) bool {
-	_, ok := c.Get(key)
-	return ok
+// Ratio returns the cache hit ratio.
+func (s Stats) Ratio() float64 {
+	return s.s.Ratio()
 }
 
-// Get returns the value associated with the key in this cache.
-func (c *Cache[K, V]) Get(key K) (V, bool) {
-	got, ok := c.hashmap.Get(key)
-	if !ok {
-		c.stats.IncMisses()
-		return zeroValue[V](), false
-	}
-
-	if got.IsExpired() {
-		c.writeBuffer.Insert(node.NewDeleteTask(got))
-		c.stats.IncMisses()
-		return zeroValue[V](), false
-	}
-
-	c.afterGet(got)
-	c.stats.IncHits()
-
-	return got.Value(), ok
+type baseCache[K comparable, V any] struct {
+	cache *core.Cache[K, V]
 }
 
-func (c *Cache[K, V]) afterGet(got *node.Node[K, V]) {
-	idx := c.getReadBufferIdx()
-	pb := c.readBuffers[idx].Add(got)
-	if pb != nil {
-		c.evictionMutex.Lock()
-		c.policy.Read(pb.Returned)
-		c.evictionMutex.Unlock()
-
-		c.readBuffers[idx].Free()
+func newBaseCache[K comparable, V any](c core.Config[K, V]) baseCache[K, V] {
+	return baseCache[K, V]{
+		cache: core.NewCache(c),
 	}
 }
 
-// Set associates the value with the key in this cache.
-//
-// If it returns false, then the key-value item had too much cost and the Set was dropped.
-func (c *Cache[K, V]) Set(key K, value V) bool {
-	return c.set(key, value, 0)
-}
-
-// SetWithTTL associates the value with the key in this cache and sets the custom ttl for this key-value item.
-//
-// If it returns false, then the key-value item had too much cost and the SetWithTTL was dropped.
-func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) bool {
-	ttl = (ttl + time.Second - 1) / time.Second
-	expiration := unixtime.Now() + uint32(ttl)
-	return c.set(key, value, expiration)
+// Has checks if there is an item with the given key in the cache.
+func (bs baseCache[K, V]) Has(key K) bool {
+	return bs.cache.Has(key)
 }
 
-func (c *Cache[K, V]) set(key K, value V, expiration uint32) bool {
-	cost := c.costFunc(key, value)
-	if cost > c.policy.MaxAvailableCost() {
-		return false
-	}
-
-	n := node.New(key, value, expiration, cost)
-	evicted := c.hashmap.Set(n)
-	if evicted != nil {
-		// update
-		c.writeBuffer.Insert(node.NewUpdateTask(n, evicted))
-	} else {
-		// insert
-		c.writeBuffer.Insert(node.NewAddTask(n))
-	}
-
-	return true
+// Get returns the value associated with the key in this cache.
+func (bs baseCache[K, V]) Get(key K) (V, bool) {
+	return bs.cache.Get(key)
 }
 
 // Delete removes the association for this key from the cache.
-func (c *Cache[K, V]) Delete(key K) {
-	deleted := c.hashmap.Delete(key)
-	if deleted != nil {
-		c.writeBuffer.Insert(node.NewDeleteTask(deleted))
-	}
-}
-
-func (c *Cache[K, V]) cleanup() {
-	expired := make([]*node.Node[K, V], 0, 128)
-	for {
-		time.Sleep(time.Second)
-
-		c.evictionMutex.Lock()
-		if c.isClosed {
-			return
-		}
-
-		e := c.expirePolicy.RemoveExpired(expired)
-		c.policy.Delete(e)
-
-		c.evictionMutex.Unlock()
-
-		for _, n := range e {
-			c.hashmap.EvictNode(n)
-		}
-
-		expired = clearBuffer(expired)
-	}
-}
-
-func (c *Cache[K, V]) process() {
-	bufferCapacity := 64
-	buffer := make([]node.WriteTask[K, V], 0, bufferCapacity)
-	deleted := make([]*node.Node[K, V], 0, bufferCapacity)
-	i := 0
-	for {
-		task := c.writeBuffer.Remove()
-
-		if task.IsClear() || task.IsClose() {
-			buffer = clearBuffer(buffer)
-			c.writeBuffer.Clear()
-
-			c.evictionMutex.Lock()
-			c.policy.Clear()
-			c.expirePolicy.Clear()
-			if task.IsClose() {
-				c.isClosed = true
-			}
-			c.evictionMutex.Unlock()
-
-			c.doneClear <- struct{}{}
-			if task.IsClose() {
-				break
-			}
-			continue
-		}
-
-		buffer = append(buffer, task)
-		i++
-		if i >= bufferCapacity {
-			i -= bufferCapacity
-
-			c.evictionMutex.Lock()
-
-			for _, t := range buffer {
-				switch {
-				case t.IsDelete():
-					c.expirePolicy.Delete(t.Node())
-				case t.IsAdd():
-					c.expirePolicy.Add(t.Node())
-				case t.IsUpdate():
-					c.expirePolicy.Delete(t.OldNode())
-					c.expirePolicy.Add(t.Node())
-				}
-			}
-
-			d := c.policy.Write(deleted, buffer)
-			for _, n := range d {
-				c.expirePolicy.Delete(n)
-			}
-
-			c.evictionMutex.Unlock()
-
-			for _, n := range d {
-				c.hashmap.EvictNode(n)
-			}
-
-			buffer = clearBuffer(buffer)
-			deleted = clearBuffer(deleted)
-		}
-	}
+func (bs baseCache[K, V]) Delete(key K) {
+	bs.cache.Delete(key)
 }
 
 // Range iterates over all items in the cache.
 //
 // Iteration stops early when the given function returns false.
-func (c *Cache[K, V]) Range(f func(key K, value V) bool) {
-	c.hashmap.Range(func(n *node.Node[K, V]) bool {
-		if n.IsExpired() {
-			return true
-		}
-
-		return f(n.Key(), n.Value())
-	})
+func (bs baseCache[K, V]) Range(f func(key K, value V) bool) {
+	bs.cache.Range(f)
 }
 
 // Clear clears the hash table, all policies, buffers, etc.
 //
 // NOTE: this operation must be performed when no requests are made to the cache otherwise the behavior is undefined.
-func (c *Cache[K, V]) Clear() {
-	c.clear(node.NewClearTask[K, V]())
-}
-
-func (c *Cache[K, V]) clear(task node.WriteTask[K, V]) {
-	c.hashmap.Clear()
-	for i := 0; i < len(c.readBuffers); i++ {
-		c.readBuffers[i].Clear()
-	}
-
-	c.writeBuffer.Insert(task)
-	<-c.doneClear
-
-	c.stats.Clear()
+func (bs baseCache[K, V]) Clear() {
+	bs.cache.Clear()
 }
 
 // Close clears the hash table, all policies, buffers, etc and stop all goroutines.
 //
 // NOTE: this operation must be performed when no requests are made to the cache otherwise the behavior is undefined.
-func (c *Cache[K, V]) Close() {
-	c.closeOnce.Do(func() {
-		c.clear(node.NewCloseTask[K, V]())
-		unixtime.Stop()
-	})
+func (bs baseCache[K, V]) Close() {
+	bs.cache.Close()
 }
 
 // Size returns the current number of items in the cache.
-func (c *Cache[K, V]) Size() int {
-	return c.hashmap.Size()
+func (bs baseCache[K, V]) Size() int {
+	return bs.cache.Size()
 }
 
 // Capacity returns the cache capacity.
-func (c *Cache[K, V]) Capacity() int {
-	return c.capacity
+func (bs baseCache[K, V]) Capacity() int {
+	return bs.cache.Capacity()
 }
 
-// Hits returns the number of cache hits.
-func (c *Cache[K, V]) Hits() int64 {
-	return c.stats.Hits()
+// Stats returns a current snapshot of this cache's cumulative statistics.
+func (bs baseCache[K, V]) Stats() Stats {
+	return newStats(bs.cache.Stats())
 }
 
-// Misses returns the number of cache misses.
-func (c *Cache[K, V]) Misses() int64 {
-	return c.stats.Misses()
+// Cache is a structure performs a best-effort bounding of a hash table using eviction algorithm
+// to determine which entries to evict when the capacity is exceeded.
+type Cache[K comparable, V any] struct {
+	baseCache[K, V]
 }
 
-// Ratio returns the cache hit ratio.
-func (c *Cache[K, V]) Ratio() float64 {
-	return c.stats.Ratio()
+func newCache[K comparable, V any](c core.Config[K, V]) Cache[K, V] {
+	return Cache[K, V]{
+		baseCache: newBaseCache(c),
+	}
 }
 
-func clearBuffer[T any](buffer []T) []T {
-	var zero T
-	for i := 0; i < len(buffer); i++ {
-		buffer[i] = zero
+// Set associates the value with the key in this cache.
+//
+// If it returns false, then the key-value item had too much cost and the Set was dropped.
+func (c Cache[K, V]) Set(key K, value V) bool {
+	return c.cache.Set(key, value)
+}
+
+// CacheWithVariableTTL is a structure performs a best-effort bounding of a hash table using eviction algorithm
+// to determine which entries to evict when the capacity is exceeded.
+type CacheWithVariableTTL[K comparable, V any] struct {
+	baseCache[K, V]
+}
+
+func newCacheWithVariableTTL[K comparable, V any](c core.Config[K, V]) CacheWithVariableTTL[K, V] {
+	return CacheWithVariableTTL[K, V]{
+		baseCache: newBaseCache(c),
 	}
-	return buffer[:0]
+}
+
+// Set associates the value with the key in this cache and sets the custom ttl for this key-value item.
+//
+// If it returns false, then the key-value item had too much cost and the Set was dropped.
+func (c CacheWithVariableTTL[K, V]) Set(key K, value V, ttl time.Duration) bool {
+	return c.cache.SetWithTTL(key, value, ttl)
 }
diff --git a/internal/core/cache.go b/internal/core/cache.go
new file mode 100644
index 0000000..1e6384f
--- /dev/null
+++ b/internal/core/cache.go
@@ -0,0 +1,346 @@
+// Copyright (c) 2023 Alexey Mayshev. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package core
+
+import (
+	"sync"
+	"time"
+
+	"github.com/maypok86/otter/internal/expire"
+	"github.com/maypok86/otter/internal/hashtable"
+	"github.com/maypok86/otter/internal/lossy"
+	"github.com/maypok86/otter/internal/node"
+	"github.com/maypok86/otter/internal/queue"
+	"github.com/maypok86/otter/internal/s3fifo"
+	"github.com/maypok86/otter/internal/stats"
+	"github.com/maypok86/otter/internal/unixtime"
+	"github.com/maypok86/otter/internal/xmath"
+	"github.com/maypok86/otter/internal/xruntime"
+)
+
+func zeroValue[V any]() V {
+	var zero V
+	return zero
+}
+
+// Config is a set of cache settings.
+type Config[K comparable, V any] struct {
+	Capacity        int
+	StatsEnabled    bool
+	TTL             *time.Duration
+	WithVariableTTL bool
+	CostFunc        func(key K, value V) uint32
+}
+
+// Cache is a structure performs a best-effort bounding of a hash table using eviction algorithm
+// to determine which entries to evict when the capacity is exceeded.
+type Cache[K comparable, V any] struct {
+	hashmap       *hashtable.Map[K, V]
+	policy        *s3fifo.Policy[K, V]
+	expirePolicy  *expire.Policy[K, V]
+	stats         *stats.Stats
+	readBuffers   []*lossy.Buffer[node.Node[K, V]]
+	writeBuffer   *queue.MPSC[node.WriteTask[K, V]]
+	evictionMutex sync.Mutex
+	closeOnce     sync.Once
+	isClosed      bool
+	doneClear     chan struct{}
+	costFunc      func(key K, value V) uint32
+	ttl           uint32
+	capacity      int
+	mask          uint32
+}
+
+// NewCache returns a new cache instance based on the settings from Config.
+func NewCache[K comparable, V any](c Config[K, V]) *Cache[K, V] {
+	parallelism := xruntime.Parallelism()
+	roundedParallelism := int(xmath.RoundUpPowerOf2(parallelism))
+	writeBufferCapacity := 128 * roundedParallelism
+	readBuffersCount := 4 * roundedParallelism
+
+	readBuffers := make([]*lossy.Buffer[node.Node[K, V]], 0, readBuffersCount)
+	for i := 0; i < readBuffersCount; i++ {
+		readBuffers = append(readBuffers, lossy.New[node.Node[K, V]]())
+	}
+
+	cache := &Cache[K, V]{
+		hashmap:     hashtable.New[K, V](),
+		policy:      s3fifo.NewPolicy[K, V](uint32(c.Capacity)),
+		readBuffers: readBuffers,
+		writeBuffer: queue.NewMPSC[node.WriteTask[K, V]](writeBufferCapacity),
+		doneClear:   make(chan struct{}),
+		mask:        uint32(readBuffersCount - 1),
+		costFunc:    c.CostFunc,
+		capacity:    c.Capacity,
+	}
+
+	cache.expirePolicy = expire.NewPolicy[K, V]()
+	if c.StatsEnabled {
+		cache.stats = stats.New()
+	}
+	if c.TTL != nil {
+		cache.ttl = uint32((*c.TTL + time.Second - 1) / time.Second)
+	}
+
+	unixtime.Start()
+	go cache.process()
+	go cache.cleanup()
+
+	return cache
+}
+
+func (c *Cache[K, V]) getReadBufferIdx() int {
+	return int(xruntime.Fastrand() & c.mask)
+}
+
+// Has checks if there is an item with the given key in the cache.
+func (c *Cache[K, V]) Has(key K) bool {
+	_, ok := c.Get(key)
+	return ok
+}
+
+// Get returns the value associated with the key in this cache.
+func (c *Cache[K, V]) Get(key K) (V, bool) {
+	got, ok := c.hashmap.Get(key)
+	if !ok {
+		c.stats.IncMisses()
+		return zeroValue[V](), false
+	}
+
+	if got.IsExpired() {
+		c.writeBuffer.Insert(node.NewDeleteTask(got))
+		c.stats.IncMisses()
+		return zeroValue[V](), false
+	}
+
+	c.afterGet(got)
+	c.stats.IncHits()
+
+	return got.Value(), ok
+}
+
+func (c *Cache[K, V]) afterGet(got *node.Node[K, V]) {
+	idx := c.getReadBufferIdx()
+	pb := c.readBuffers[idx].Add(got)
+	if pb != nil {
+		c.evictionMutex.Lock()
+		c.policy.Read(pb.Returned)
+		c.evictionMutex.Unlock()
+
+		c.readBuffers[idx].Free()
+	}
+}
+
+// Set associates the value with the key in this cache.
+//
+// If it returns false, then the key-value item had too much cost and the Set was dropped.
+func (c *Cache[K, V]) Set(key K, value V) bool {
+	return c.set(key, value, c.defaultExpiration())
+}
+
+func (c *Cache[K, V]) defaultExpiration() uint32 {
+	if c.ttl == 0 {
+		return 0
+	}
+
+	return unixtime.Now() + c.ttl
+}
+
+// SetWithTTL associates the value with the key in this cache and sets the custom ttl for this key-value item.
+//
+// If it returns false, then the key-value item had too much cost and the SetWithTTL was dropped.
+func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) bool {
+	ttl = (ttl + time.Second - 1) / time.Second
+	expiration := unixtime.Now() + uint32(ttl)
+	return c.set(key, value, expiration)
+}
+
+func (c *Cache[K, V]) set(key K, value V, expiration uint32) bool {
+	cost := c.costFunc(key, value)
+	if cost > c.policy.MaxAvailableCost() {
+		return false
+	}
+
+	n := node.New(key, value, expiration, cost)
+	evicted := c.hashmap.Set(n)
+	if evicted != nil {
+		// update
+		c.writeBuffer.Insert(node.NewUpdateTask(n, evicted))
+	} else {
+		// insert
+		c.writeBuffer.Insert(node.NewAddTask(n))
+	}
+
+	return true
+}
+
+// Delete removes the association for this key from the cache.
+func (c *Cache[K, V]) Delete(key K) {
+	deleted := c.hashmap.Delete(key)
+	if deleted != nil {
+		c.writeBuffer.Insert(node.NewDeleteTask(deleted))
+	}
+}
+
+func (c *Cache[K, V]) cleanup() {
+	expired := make([]*node.Node[K, V], 0, 128)
+	for {
+		time.Sleep(time.Second)
+
+		c.evictionMutex.Lock()
+		if c.isClosed {
+			return
+		}
+
+		e := c.expirePolicy.RemoveExpired(expired)
+		c.policy.Delete(e)
+
+		c.evictionMutex.Unlock()
+
+		for _, n := range e {
+			c.hashmap.EvictNode(n)
+		}
+
+		expired = clearBuffer(expired)
+	}
+}
+
+func (c *Cache[K, V]) process() {
+	bufferCapacity := 64
+	buffer := make([]node.WriteTask[K, V], 0, bufferCapacity)
+	deleted := make([]*node.Node[K, V], 0, bufferCapacity)
+	i := 0
+	for {
+		task := c.writeBuffer.Remove()
+
+		if task.IsClear() || task.IsClose() {
+			buffer = clearBuffer(buffer)
+			c.writeBuffer.Clear()
+
+			c.evictionMutex.Lock()
+			c.policy.Clear()
+			c.expirePolicy.Clear()
+			if task.IsClose() {
+				c.isClosed = true
+			}
+			c.evictionMutex.Unlock()
+
+			c.doneClear <- struct{}{}
+			if task.IsClose() {
+				break
+			}
+			continue
+		}
+
+		buffer = append(buffer, task)
+		i++
+		if i >= bufferCapacity {
+			i -= bufferCapacity
+
+			c.evictionMutex.Lock()
+
+			for _, t := range buffer {
+				switch {
+				case t.IsDelete():
+					c.expirePolicy.Delete(t.Node())
+				case t.IsAdd():
+					c.expirePolicy.Add(t.Node())
+				case t.IsUpdate():
+					c.expirePolicy.Delete(t.OldNode())
+					c.expirePolicy.Add(t.Node())
+				}
+			}
+
+			d := c.policy.Write(deleted, buffer)
+			for _, n := range d {
+				c.expirePolicy.Delete(n)
+			}
+
+			c.evictionMutex.Unlock()
+
+			for _, n := range d {
+				c.hashmap.EvictNode(n)
+			}
+
+			buffer = clearBuffer(buffer)
+			deleted = clearBuffer(deleted)
+		}
+	}
+}
+
+// Range iterates over all items in the cache.
+//
+// Iteration stops early when the given function returns false.
+func (c *Cache[K, V]) Range(f func(key K, value V) bool) {
+	c.hashmap.Range(func(n *node.Node[K, V]) bool {
+		if n.IsExpired() {
+			return true
+		}
+
+		return f(n.Key(), n.Value())
+	})
+}
+
+// Clear clears the hash table, all policies, buffers, etc.
+//
+// NOTE: this operation must be performed when no requests are made to the cache otherwise the behavior is undefined.
+func (c *Cache[K, V]) Clear() {
+	c.clear(node.NewClearTask[K, V]())
+}
+
+func (c *Cache[K, V]) clear(task node.WriteTask[K, V]) {
+	c.hashmap.Clear()
+	for i := 0; i < len(c.readBuffers); i++ {
+		c.readBuffers[i].Clear()
+	}
+
+	c.writeBuffer.Insert(task)
+	<-c.doneClear
+
+	c.stats.Clear()
+}
+
+// Close clears the hash table, all policies, buffers, etc and stop all goroutines.
+//
+// NOTE: this operation must be performed when no requests are made to the cache otherwise the behavior is undefined.
+func (c *Cache[K, V]) Close() {
+	c.closeOnce.Do(func() {
+		c.clear(node.NewCloseTask[K, V]())
+		unixtime.Stop()
+	})
+}
+
+// Size returns the current number of items in the cache.
+func (c *Cache[K, V]) Size() int {
+	return c.hashmap.Size()
+}
+
+// Capacity returns the cache capacity.
+func (c *Cache[K, V]) Capacity() int {
+	return c.capacity
+}
+
+// Stats returns a current snapshot of this cache's cumulative statistics.
+func (c *Cache[K, V]) Stats() *stats.Stats {
+	return c.stats
+}
+
+func clearBuffer[T any](buffer []T) []T {
+	var zero T
+	for i := 0; i < len(buffer); i++ {
+		buffer[i] = zero
+	}
+	return buffer[:0]
+}


Test patch:
diff --git a/builder_test.go b/builder_test.go
index ed75c29..063928e 100644
--- a/builder_test.go
+++ b/builder_test.go
@@ -19,6 +19,7 @@ import (
 	"fmt"
 	"reflect"
 	"testing"
+	"time"
 )
 
 func TestBuilder_MustFailed(t *testing.T) {
@@ -36,13 +37,18 @@ func TestBuilder_NewFailed(t *testing.T) {
 	if err == nil || !errors.Is(err, ErrIllegalCapacity) {
 		t.Fatalf("should fail with an error %v, but got %v", ErrIllegalCapacity, err)
 	}
+
+	_, err = MustBuilder[int, int](100).WithTTL(-1).Build()
+	if err == nil || !errors.Is(err, ErrIllegalTTL) {
+		t.Fatalf("should fail with an error %v, but got %v", ErrIllegalTTL, err)
+	}
 }
 
 func TestBuilder_BuildSuccess(t *testing.T) {
 	b := MustBuilder[int, int](10)
 
 	c, err := b.
-		StatsEnabled(true).
+		CollectStats().
 		Cost(func(key int, value int) uint32 {
 			return 2
 		}).Build()
@@ -50,7 +56,29 @@ func TestBuilder_BuildSuccess(t *testing.T) {
 		t.Fatalf("builded cache with error: %v", err)
 	}
 
-	if !reflect.DeepEqual(reflect.TypeOf(&Cache[int, int]{}), reflect.TypeOf(c)) {
+	if !reflect.DeepEqual(reflect.TypeOf(Cache[int, int]{}), reflect.TypeOf(c)) {
+		t.Fatalf("builder returned a different type of cache: %v", err)
+	}
+
+	cc, err := b.WithTTL(time.Minute).CollectStats().Cost(func(key int, value int) uint32 {
+		return 2
+	}).Build()
+	if err != nil {
+		t.Fatalf("builded cache with error: %v", err)
+	}
+
+	if !reflect.DeepEqual(reflect.TypeOf(Cache[int, int]{}), reflect.TypeOf(cc)) {
+		t.Fatalf("builder returned a different type of cache: %v", err)
+	}
+
+	cv, err := b.WithVariableTTL().CollectStats().Cost(func(key int, value int) uint32 {
+		return 2
+	}).Build()
+	if err != nil {
+		t.Fatalf("builded cache with error: %v", err)
+	}
+
+	if !reflect.DeepEqual(reflect.TypeOf(CacheWithVariableTTL[int, int]{}), reflect.TypeOf(cv)) {
 		t.Fatalf("builder returned a different type of cache: %v", err)
 	}
 }
diff --git a/cache_test.go b/cache_test.go
index 0c13da6..e81c47e 100644
--- a/cache_test.go
+++ b/cache_test.go
@@ -22,23 +22,17 @@ import (
 	"testing"
 	"time"
 
-	"github.com/maypok86/otter/internal/node"
 	"github.com/maypok86/otter/internal/xruntime"
 )
 
 func TestCache_Set(t *testing.T) {
-	b, err := NewBuilder[int, int](100)
-	if err != nil {
-		t.Fatalf("can not create builder: %v", err)
-	}
-
-	c, err := b.StatsEnabled(true).Build()
+	c, err := MustBuilder[int, int](100).WithTTL(time.Minute).CollectStats().Build()
 	if err != nil {
 		t.Fatalf("can not create cache: %v", err)
 	}
 
 	for i := 0; i < 100; i++ {
-		c.SetWithTTL(i, i, time.Minute)
+		c.Set(i, i)
 	}
 
 	parallelism := xruntime.Parallelism()
@@ -68,7 +62,7 @@ func TestCache_Set(t *testing.T) {
 	if err != nil {
 		t.Fatalf("not found key: %v", err)
 	}
-	ratio := c.Ratio()
+	ratio := c.Stats().Ratio()
 	if ratio != 1.0 {
 		t.Fatalf("cache hit ratio should be 1.0, but got %v", ratio)
 	}
@@ -76,13 +70,13 @@ func TestCache_Set(t *testing.T) {
 
 func TestCache_SetWithTTL(t *testing.T) {
 	size := 256
-	c, err := MustBuilder[int, int](size).Build()
+	c, err := MustBuilder[int, int](size).WithTTL(time.Second).Build()
 	if err != nil {
 		t.Fatalf("can not create builder: %v", err)
 	}
 
 	for i := 0; i < size; i++ {
-		c.SetWithTTL(i, i, time.Second)
+		c.Set(i, i)
 	}
 
 	time.Sleep(3 * time.Second)
@@ -98,65 +92,35 @@ func TestCache_SetWithTTL(t *testing.T) {
 		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
 	}
 
-	c, err = MustBuilder[int, int](size).Build()
+	cc, err := MustBuilder[int, int](size).WithVariableTTL().CollectStats().Build()
 	if err != nil {
 		t.Fatalf("can not create builder: %v", err)
 	}
 
 	for i := 0; i < size; i++ {
-		c.SetWithTTL(i, i, 5*time.Second)
+		cc.Set(i, i, 5*time.Second)
 	}
 
 	time.Sleep(7 * time.Second)
 
 	for i := 0; i < size; i++ {
-		if c.Has(i) {
+		if cc.Has(i) {
 			t.Fatalf("key should be expired: %d", i)
 		}
 	}
 
 	time.Sleep(10 * time.Millisecond)
 
-	if cacheSize := c.Size(); cacheSize != 0 {
+	if cacheSize := cc.Size(); cacheSize != 0 {
 		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
 	}
-}
-
-func TestCache_SetWithCost(t *testing.T) {
-	size := 10
-	c, err := MustBuilder[int, int](size).Cost(func(key int, value int) uint32 {
-		return uint32(key)
-	}).Build()
-	if err != nil {
-		t.Fatalf("can not create cache: %v", err)
-	}
-
-	goodCost := int(c.policy.MaxAvailableCost())
-	badCost := goodCost + 1
-
-	added := c.Set(goodCost, 1)
-	if !added {
-		t.Fatalf("Set was dropped, even though it shouldn't have been. Max available cost: %d, actual cost: %d",
-			c.policy.MaxAvailableCost(),
-			c.costFunc(goodCost, 1),
-		)
-	}
-	added = c.Set(badCost, 1)
-	if added {
-		t.Fatalf("Set wasn't dropped, though it should have been. Max available cost: %d, actual cost: %d",
-			c.policy.MaxAvailableCost(),
-			c.costFunc(badCost, 1),
-		)
+	if misses := cc.Stats().Misses(); misses != int64(size) {
+		t.Fatalf("c.Stats().Misses() = %d, want = %d", misses, size)
 	}
 }
 
 func TestCache_Ratio(t *testing.T) {
-	b, err := NewBuilder[uint64, uint64](100)
-	if err != nil {
-		t.Fatalf("can not create builder: %v", err)
-	}
-
-	c, err := b.StatsEnabled(true).Build()
+	c, err := MustBuilder[uint64, uint64](100).CollectStats().Build()
 	if err != nil {
 		t.Fatalf("can not create cache: %v", err)
 	}
@@ -174,97 +138,7 @@ func TestCache_Ratio(t *testing.T) {
 	}
 
 	t.Logf("actual size: %d, capacity: %d", c.Size(), c.Capacity())
-	t.Logf("actual: %.2f, optimal: %.2f", c.Ratio(), o.Ratio())
-}
-
-func TestCache_Range(t *testing.T) {
-	size := 10
-	c, err := MustBuilder[int, int](size).Build()
-	if err != nil {
-		t.Fatalf("can not create cache: %v", err)
-	}
-
-	time.Sleep(3 * time.Second)
-
-	c.Set(1, 1)
-	c.hashmap.Set(node.New(2, 2, 1, 1))
-	c.Set(3, 3)
-	aliveNodes := 2
-	iters := 0
-	c.Range(func(key, value int) bool {
-		if key != value {
-			t.Fatalf("got unexpected key/value for iteration %d: %d/%d", iters, key, value)
-			return false
-		}
-		iters++
-		return true
-	})
-	if iters != aliveNodes {
-		t.Fatalf("got unexpected number of iterations: %d", iters)
-	}
-}
-
-func TestCache_Close(t *testing.T) {
-	size := 10
-	c, err := MustBuilder[int, int](size).Build()
-	if err != nil {
-		t.Fatalf("can not create cache: %v", err)
-	}
-
-	for i := 0; i < size; i++ {
-		c.Set(i, i)
-	}
-
-	if cacheSize := c.Size(); cacheSize != size {
-		t.Fatalf("c.Size() = %d, want = %d", cacheSize, size)
-	}
-
-	c.Close()
-
-	time.Sleep(10 * time.Millisecond)
-
-	if cacheSize := c.Size(); cacheSize != 0 {
-		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
-	}
-	if !c.isClosed {
-		t.Fatalf("cache should be closed")
-	}
-
-	c.Close()
-
-	if cacheSize := c.Size(); cacheSize != 0 {
-		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
-	}
-	if !c.isClosed {
-		t.Fatalf("cache should be closed")
-	}
-}
-
-func TestCache_Clear(t *testing.T) {
-	size := 10
-	c, err := MustBuilder[int, int](size).Build()
-	if err != nil {
-		t.Fatalf("can not create cache: %v", err)
-	}
-
-	for i := 0; i < size; i++ {
-		c.Set(i, i)
-	}
-
-	if cacheSize := c.Size(); cacheSize != size {
-		t.Fatalf("c.Size() = %d, want = %d", cacheSize, size)
-	}
-
-	c.Clear()
-
-	time.Sleep(10 * time.Millisecond)
-
-	if cacheSize := c.Size(); cacheSize != 0 {
-		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
-	}
-	if c.isClosed {
-		t.Fatalf("cache shouldn't be closed")
-	}
+	t.Logf("actual: %.2f, optimal: %.2f", c.Stats().Ratio(), o.Ratio())
 }
 
 type optimal struct {
diff --git a/internal/core/cache_test.go b/internal/core/cache_test.go
new file mode 100644
index 0000000..3e57f7c
--- /dev/null
+++ b/internal/core/cache_test.go
@@ -0,0 +1,132 @@
+package core
+
+import (
+	"testing"
+	"time"
+
+	"github.com/maypok86/otter/internal/node"
+)
+
+func TestCache_SetWithCost(t *testing.T) {
+	size := 10
+	c := NewCache[int, int](Config[int, int]{
+		Capacity: size,
+		CostFunc: func(key int, value int) uint32 {
+			return uint32(key)
+		},
+	})
+
+	goodCost := int(c.policy.MaxAvailableCost())
+	badCost := goodCost + 1
+
+	added := c.Set(goodCost, 1)
+	if !added {
+		t.Fatalf("Set was dropped, even though it shouldn't have been. Max available cost: %d, actual cost: %d",
+			c.policy.MaxAvailableCost(),
+			c.costFunc(goodCost, 1),
+		)
+	}
+	added = c.Set(badCost, 1)
+	if added {
+		t.Fatalf("Set wasn't dropped, though it should have been. Max available cost: %d, actual cost: %d",
+			c.policy.MaxAvailableCost(),
+			c.costFunc(badCost, 1),
+		)
+	}
+}
+
+func TestCache_Range(t *testing.T) {
+	size := 10
+	c := NewCache[int, int](Config[int, int]{
+		Capacity: size,
+		CostFunc: func(key int, value int) uint32 {
+			return 1
+		},
+	})
+
+	time.Sleep(3 * time.Second)
+
+	c.Set(1, 1)
+	c.hashmap.Set(node.New(2, 2, 1, 1))
+	c.Set(3, 3)
+	aliveNodes := 2
+	iters := 0
+	c.Range(func(key, value int) bool {
+		if key != value {
+			t.Fatalf("got unexpected key/value for iteration %d: %d/%d", iters, key, value)
+			return false
+		}
+		iters++
+		return true
+	})
+	if iters != aliveNodes {
+		t.Fatalf("got unexpected number of iterations: %d", iters)
+	}
+}
+
+func TestCache_Close(t *testing.T) {
+	size := 10
+	c := NewCache[int, int](Config[int, int]{
+		Capacity: size,
+		CostFunc: func(key int, value int) uint32 {
+			return 1
+		},
+	})
+
+	for i := 0; i < size; i++ {
+		c.Set(i, i)
+	}
+
+	if cacheSize := c.Size(); cacheSize != size {
+		t.Fatalf("c.Size() = %d, want = %d", cacheSize, size)
+	}
+
+	c.Close()
+
+	time.Sleep(10 * time.Millisecond)
+
+	if cacheSize := c.Size(); cacheSize != 0 {
+		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
+	}
+	if !c.isClosed {
+		t.Fatalf("cache should be closed")
+	}
+
+	c.Close()
+
+	if cacheSize := c.Size(); cacheSize != 0 {
+		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
+	}
+	if !c.isClosed {
+		t.Fatalf("cache should be closed")
+	}
+}
+
+func TestCache_Clear(t *testing.T) {
+	size := 10
+	c := NewCache[int, int](Config[int, int]{
+		Capacity: size,
+		CostFunc: func(key int, value int) uint32 {
+			return 1
+		},
+	})
+
+	for i := 0; i < size; i++ {
+		c.Set(i, i)
+	}
+
+	if cacheSize := c.Size(); cacheSize != size {
+		t.Fatalf("c.Size() = %d, want = %d", cacheSize, size)
+	}
+
+	c.Clear()
+
+	time.Sleep(10 * time.Millisecond)
+
+	if cacheSize := c.Size(); cacheSize != 0 {
+		t.Fatalf("c.Size() = %d, want = %d", cacheSize, 0)
+	}
+	if c.isClosed {
+		t.Fatalf("cache shouldn't be closed")
+	}
+}