Repository: maypok86/otter (go)

Issue:
Request Get Expiration With Get
# Clarification and motivation
I have a script that updating some variables and users get them from cache. But I want to get expiration for showing them last update time.

# Acceptance criteria
Get expiration time with get function or new function with expiration time


Pull request description:
Add extension
## Description

Added `Extension` feature.

## Related issue(s)

- Resolves #56 

## :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/cache.go b/cache.go
index b2704d8..bedbb0e 100644
--- a/cache.go
+++ b/cache.go
@@ -44,7 +44,7 @@ func newBaseCache[K comparable, V any](c core.Config[K, V]) baseCache[K, V] {
 	}
 }
 
-// Has checks if there is an item with the given key in the cache.
+// Has checks if there is an entry with the given key in the cache.
 func (bs baseCache[K, V]) Has(key K) bool {
 	return bs.cache.Has(key)
 }
@@ -64,7 +64,7 @@ func (bs baseCache[K, V]) DeleteByFunc(f func(key K, value V) bool) {
 	bs.cache.DeleteByFunc(f)
 }
 
-// Range iterates over all items in the cache.
+// Range iterates over all entries in the cache.
 //
 // Iteration stops early when the given function returns false.
 func (bs baseCache[K, V]) Range(f func(key K, value V) bool) {
@@ -85,7 +85,7 @@ func (bs baseCache[K, V]) Close() {
 	bs.cache.Close()
 }
 
-// Size returns the current number of items in the cache.
+// Size returns the current number of entries in the cache.
 func (bs baseCache[K, V]) Size() int {
 	return bs.cache.Size()
 }
@@ -100,6 +100,13 @@ func (bs baseCache[K, V]) Stats() Stats {
 	return newStats(bs.cache.Stats())
 }
 
+// Extension returns access to inspect and perform low-level operations on this cache based on its runtime
+// characteristics. These operations are optional and dependent on how the cache was constructed
+// and what abilities the implementation exposes.
+func (bs baseCache[K, V]) Extension() Extension[K, V] {
+	return newExtension(bs.cache)
+}
+
 // 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 {
@@ -114,7 +121,7 @@ func newCache[K comparable, V any](c core.Config[K, V]) Cache[K, V] {
 
 // Set associates the value with the key in this cache.
 //
-// If it returns false, then the key-value item had too much setCostFunc and the Set was dropped.
+// If it returns false, then the key-value pair 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)
 }
@@ -123,7 +130,7 @@ func (c Cache[K, V]) Set(key K, value V) bool {
 //
 // If the specified key is not already associated with a value, then it returns false.
 //
-// Also, it returns false if the key-value item had too much setCostFunc and the SetIfAbsent was dropped.
+// Also, it returns false if the key-value pair had too much cost and the SetIfAbsent was dropped.
 func (c Cache[K, V]) SetIfAbsent(key K, value V) bool {
 	return c.cache.SetIfAbsent(key, value)
 }
@@ -140,19 +147,19 @@ func newCacheWithVariableTTL[K comparable, V any](c core.Config[K, V]) CacheWith
 	}
 }
 
-// Set associates the value with the key in this cache and sets the custom ttl for this key-value item.
+// Set associates the value with the key in this cache and sets the custom ttl for this key-value pair.
 //
-// If it returns false, then the key-value item had too much setCostFunc and the Set was dropped.
+// If it returns false, then the key-value pair 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)
 }
 
 // SetIfAbsent if the specified key is not already associated with a value associates it with the given value
-// and sets the custom ttl for this key-value item.
+// and sets the custom ttl for this key-value pair.
 //
 // If the specified key is not already associated with a value, then it returns false.
 //
-// Also, it returns false if the key-value item had too much setCostFunc and the SetIfAbsent was dropped.
+// Also, it returns false if the key-value pair had too much cost and the SetIfAbsent was dropped.
 func (c CacheWithVariableTTL[K, V]) SetIfAbsent(key K, value V, ttl time.Duration) bool {
 	return c.cache.SetIfAbsentWithTTL(key, value, ttl)
 }
diff --git a/cmd/generator/main.go b/cmd/generator/main.go
index dd7bb2a..757e18f 100644
--- a/cmd/generator/main.go
+++ b/cmd/generator/main.go
@@ -336,10 +336,10 @@ func (g *generator) printFunctions() {
 	g.p("}")
 	g.p("")
 
-	g.p("func (n *%s[K, V]) IsExpired() bool {", g.structName)
+	g.p("func (n *%s[K, V]) HasExpired() bool {", g.structName)
 	g.in()
 	if g.features[expiration] {
-		g.p("return n.expiration > 0 && n.expiration < unixtime.Now()")
+		g.p("return n.expiration <= unixtime.Now()")
 	} else {
 		g.p("return false")
 	}
@@ -501,8 +501,8 @@ type Node[K comparable, V any] interface {
 	NextExp() Node[K, V]
 	// SetNextExp sets the next node in the expiration policy.
 	SetNextExp(v Node[K, V])
-	// IsExpired returns true if node is expired.
-	IsExpired() bool
+	// HasExpired returns true if node has expired.
+	HasExpired() bool
 	// Expiration returns the expiration time.
 	Expiration() uint32
 	// Cost returns the cost of the node.
diff --git a/entry.go b/entry.go
new file mode 100644
index 0000000..5dcfaf0
--- /dev/null
+++ b/entry.go
@@ -0,0 +1,82 @@
+// 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.
+// 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 otter
+
+import "time"
+
+// Entry is a key-value pair that may include policy metadata for the cached entry.
+//
+// It is an immutable snapshot of the cached data at the time of this entry's creation, and it will not
+// reflect changes afterward.
+type Entry[K comparable, V any] struct {
+	key        K
+	value      V
+	expiration int64
+	cost       uint32
+}
+
+// Key returns the entry's key.
+func (e Entry[K, V]) Key() K {
+	return e.key
+}
+
+// Value returns the entry's value.
+func (e Entry[K, V]) Value() V {
+	return e.value
+}
+
+// Expiration returns the entry's expiration time as a unix time,
+// the number of seconds elapsed since January 1, 1970 UTC.
+//
+// If the cache was not configured with an expiration policy then this value is always 0.
+func (e Entry[K, V]) Expiration() int64 {
+	return e.expiration
+}
+
+// TTL returns the entry's ttl.
+//
+// If the cache was not configured with an expiration policy then this value is always -1.
+//
+// If the entry is expired then this value is always 0.
+func (e Entry[K, V]) TTL() time.Duration {
+	expiration := e.Expiration()
+	if expiration == 0 {
+		return -1
+	}
+
+	now := time.Now().Unix()
+	if expiration <= now {
+		return 0
+	}
+
+	return time.Duration(expiration-now) * time.Second
+}
+
+// HasExpired returns true if the entry has expired.
+func (e Entry[K, V]) HasExpired() bool {
+	expiration := e.Expiration()
+	if expiration == 0 {
+		return false
+	}
+
+	return expiration <= time.Now().Unix()
+}
+
+// Cost returns the entry's cost.
+//
+// If the cache was not configured with a cost then this value is always 1.
+func (e Entry[K, V]) Cost() uint32 {
+	return e.cost
+}
diff --git a/extension.go b/extension.go
new file mode 100644
index 0000000..83a511b
--- /dev/null
+++ b/extension.go
@@ -0,0 +1,89 @@
+// 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.
+// 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 otter
+
+import (
+	"github.com/maypok86/otter/internal/core"
+	"github.com/maypok86/otter/internal/generated/node"
+	"github.com/maypok86/otter/internal/unixtime"
+)
+
+func zeroValue[V any]() V {
+	var zero V
+	return zero
+}
+
+// Extension is an access point for inspecting and performing low-level operations based on the cache's runtime
+// characteristics. These operations are optional and dependent on how the cache was constructed
+// and what abilities the implementation exposes.
+type Extension[K comparable, V any] struct {
+	cache *core.Cache[K, V]
+}
+
+func newExtension[K comparable, V any](cache *core.Cache[K, V]) Extension[K, V] {
+	return Extension[K, V]{
+		cache: cache,
+	}
+}
+
+func (e Extension[K, V]) createEntry(n node.Node[K, V]) Entry[K, V] {
+	var expiration int64
+	if e.cache.WithExpiration() {
+		expiration = unixtime.StartTime() + int64(n.Expiration())
+	}
+
+	return Entry[K, V]{
+		key:        n.Key(),
+		value:      n.Value(),
+		expiration: expiration,
+		cost:       n.Cost(),
+	}
+}
+
+// GetQuietly returns the value associated with the key in this cache.
+//
+// Unlike Get in the cache, this function does not produce any side effects
+// such as updating statistics or the eviction policy.
+func (e Extension[K, V]) GetQuietly(key K) (V, bool) {
+	n, ok := e.cache.GetNodeQuietly(key)
+	if !ok {
+		return zeroValue[V](), false
+	}
+
+	return n.Value(), true
+}
+
+// GetEntry returns the cache entry associated with the key in this cache.
+func (e Extension[K, V]) GetEntry(key K) (Entry[K, V], bool) {
+	n, ok := e.cache.GetNode(key)
+	if !ok {
+		return Entry[K, V]{}, false
+	}
+
+	return e.createEntry(n), true
+}
+
+// GetEntryQuietly returns the cache entry associated with the key in this cache.
+//
+// Unlike GetEntry, this function does not produce any side effects
+// such as updating statistics or the eviction policy.
+func (e Extension[K, V]) GetEntryQuietly(key K) (Entry[K, V], bool) {
+	n, ok := e.cache.GetNodeQuietly(key)
+	if !ok {
+		return Entry[K, V]{}, false
+	}
+
+	return e.createEntry(n), true
+}
diff --git a/internal/core/cache.go b/internal/core/cache.go
index d05b0b6..3bcee0d 100644
--- a/internal/core/cache.go
+++ b/internal/core/cache.go
@@ -18,7 +18,7 @@ import (
 	"sync"
 	"time"
 
-	"github.com/maypok86/otter/internal/expire"
+	"github.com/maypok86/otter/internal/expiry"
 	"github.com/maypok86/otter/internal/generated/node"
 	"github.com/maypok86/otter/internal/hashtable"
 	"github.com/maypok86/otter/internal/lossy"
@@ -53,9 +53,12 @@ func zeroValue[V any]() V {
 	return zero
 }
 
+func getTTL(ttl time.Duration) uint32 {
+	return uint32((ttl + time.Second - 1) / time.Second)
+}
+
 func getExpiration(ttl time.Duration) uint32 {
-	ttlSecond := (ttl + time.Second - 1) / time.Second
-	return unixtime.Now() + uint32(ttlSecond)
+	return unixtime.Now() + getTTL(ttl)
 }
 
 // Config is a set of cache settings.
@@ -70,7 +73,7 @@ type Config[K comparable, V any] struct {
 	DeletionListener func(key K, value V, cause DeletionCause)
 }
 
-type expirePolicy[K comparable, V any] interface {
+type expiryPolicy[K comparable, V any] interface {
 	Add(n node.Node[K, V])
 	Delete(n node.Node[K, V])
 	RemoveExpired(expired []node.Node[K, V]) []node.Node[K, V]
@@ -83,7 +86,7 @@ type Cache[K comparable, V any] struct {
 	nodeManager      *node.Manager[K, V]
 	hashmap          *hashtable.Map[K, V]
 	policy           *s3fifo.Policy[K, V]
-	expirePolicy     expirePolicy[K, V]
+	expiryPolicy     expiryPolicy[K, V]
 	stats            *stats.Stats
 	readBuffers      []*lossy.Buffer[K, V]
 	writeBuffer      *queue.Growable[task[K, V]]
@@ -123,21 +126,21 @@ func NewCache[K comparable, V any](c Config[K, V]) *Cache[K, V] {
 		hashmap = hashtable.NewWithSize[K, V](nodeManager, *c.InitialCapacity)
 	}
 
-	var expPolicy expirePolicy[K, V]
+	var expPolicy expiryPolicy[K, V]
 	switch {
 	case c.TTL != nil:
-		expPolicy = expire.NewFixed[K, V]()
+		expPolicy = expiry.NewFixed[K, V]()
 	case c.WithVariableTTL:
-		expPolicy = expire.NewVariable[K, V](nodeManager)
+		expPolicy = expiry.NewVariable[K, V](nodeManager)
 	default:
-		expPolicy = expire.NewDisabled[K, V]()
+		expPolicy = expiry.NewDisabled[K, V]()
 	}
 
 	cache := &Cache[K, V]{
 		nodeManager:      nodeManager,
 		hashmap:          hashmap,
 		policy:           s3fifo.NewPolicy[K, V](uint32(c.Capacity)),
-		expirePolicy:     expPolicy,
+		expiryPolicy:     expPolicy,
 		readBuffers:      readBuffers,
 		writeBuffer:      queue.NewGrowable[task[K, V]](minWriteBufferCapacity, maxWriteBufferCapacity),
 		doneClear:        make(chan struct{}),
@@ -151,7 +154,7 @@ func NewCache[K comparable, V any](c Config[K, V]) *Cache[K, V] {
 		cache.stats = stats.New()
 	}
 	if c.TTL != nil {
-		cache.ttl = uint32((*c.TTL + time.Second - 1) / time.Second)
+		cache.ttl = getTTL(*c.TTL)
 	}
 
 	cache.withExpiration = c.TTL != nil || c.WithVariableTTL
@@ -178,22 +181,45 @@ func (c *Cache[K, V]) Has(key K) bool {
 
 // 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 || !got.IsAlive() {
-		c.stats.IncMisses()
+	n, ok := c.GetNode(key)
+	if !ok {
 		return zeroValue[V](), false
 	}
 
-	if got.IsExpired() {
-		c.writeBuffer.Push(newDeleteTask(got))
+	return n.Value(), true
+}
+
+// GetNode returns the node associated with the key in this cache.
+func (c *Cache[K, V]) GetNode(key K) (node.Node[K, V], bool) {
+	n, ok := c.hashmap.Get(key)
+	if !ok || !n.IsAlive() {
 		c.stats.IncMisses()
-		return zeroValue[V](), false
+		return nil, false
+	}
+
+	if n.HasExpired() {
+		c.writeBuffer.Push(newDeleteTask(n))
+		c.stats.IncMisses()
+		return nil, false
 	}
 
-	c.afterGet(got)
+	c.afterGet(n)
 	c.stats.IncHits()
 
-	return got.Value(), ok
+	return n, true
+}
+
+// GetNodeQuietly returns the node associated with the key in this cache.
+//
+// Unlike GetNode, this function does not produce any side effects
+// such as updating statistics or the eviction policy.
+func (c *Cache[K, V]) GetNodeQuietly(key K) (node.Node[K, V], bool) {
+	n, ok := c.hashmap.Get(key)
+	if !ok || !n.IsAlive() || n.HasExpired() {
+		return nil, false
+	}
+
+	return n, true
 }
 
 func (c *Cache[K, V]) afterGet(got node.Node[K, V]) {
@@ -300,7 +326,7 @@ func (c *Cache[K, V]) afterDelete(deleted node.Node[K, V]) {
 // DeleteByFunc deletes the association for this key from the cache when the given function returns true.
 func (c *Cache[K, V]) DeleteByFunc(f func(key K, value V) bool) {
 	c.hashmap.Range(func(n node.Node[K, V]) bool {
-		if !n.IsAlive() || n.IsExpired() {
+		if !n.IsAlive() || n.HasExpired() {
 			return true
 		}
 
@@ -331,7 +357,7 @@ func (c *Cache[K, V]) cleanup() {
 			return
 		}
 
-		expired = c.expirePolicy.RemoveExpired(expired)
+		expired = c.expiryPolicy.RemoveExpired(expired)
 		for _, n := range expired {
 			c.policy.Delete(n)
 		}
@@ -365,7 +391,7 @@ func (c *Cache[K, V]) process() {
 
 			c.evictionMutex.Lock()
 			c.policy.Clear()
-			c.expirePolicy.Clear()
+			c.expiryPolicy.Clear()
 			if t.isClose() {
 				c.isClosed = true
 			}
@@ -389,26 +415,26 @@ func (c *Cache[K, V]) process() {
 				n := t.node()
 				switch {
 				case t.isDelete():
-					c.expirePolicy.Delete(n)
+					c.expiryPolicy.Delete(n)
 					c.policy.Delete(n)
 				case t.isAdd():
 					if n.IsAlive() {
-						c.expirePolicy.Add(n)
+						c.expiryPolicy.Add(n)
 						deleted = c.policy.Add(deleted, n)
 					}
 				case t.isUpdate():
 					oldNode := t.oldNode()
-					c.expirePolicy.Delete(oldNode)
+					c.expiryPolicy.Delete(oldNode)
 					c.policy.Delete(oldNode)
 					if n.IsAlive() {
-						c.expirePolicy.Add(n)
+						c.expiryPolicy.Add(n)
 						deleted = c.policy.Add(deleted, n)
 					}
 				}
 			}
 
 			for _, n := range deleted {
-				c.expirePolicy.Delete(n)
+				c.expiryPolicy.Delete(n)
 			}
 
 			c.evictionMutex.Unlock()
@@ -446,7 +472,7 @@ func (c *Cache[K, V]) process() {
 // 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.IsAlive() || n.IsExpired() {
+		if !n.IsAlive() || n.HasExpired() {
 			return true
 		}
 
@@ -500,6 +526,11 @@ func (c *Cache[K, V]) Stats() *stats.Stats {
 	return c.stats
 }
 
+// WithExpiration returns true if the cache was configured with the expiration policy enabled.
+func (c *Cache[K, V]) WithExpiration() bool {
+	return c.withExpiration
+}
+
 func clearBuffer[T any](buffer []T) []T {
 	var zero T
 	for i := 0; i < len(buffer); i++ {
diff --git a/internal/expire/disabled.go b/internal/expiry/disabled.go
similarity index 98%
rename from internal/expire/disabled.go
rename to internal/expiry/disabled.go
index fb46cd5..61271f8 100644
--- a/internal/expire/disabled.go
+++ b/internal/expiry/disabled.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package expire
+package expiry
 
 import "github.com/maypok86/otter/internal/generated/node"
 
diff --git a/internal/expire/fixed.go b/internal/expiry/fixed.go
similarity index 94%
rename from internal/expire/fixed.go
rename to internal/expiry/fixed.go
index ceb0cfa..6ca7f9b 100644
--- a/internal/expire/fixed.go
+++ b/internal/expiry/fixed.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package expire
+package expiry
 
 import "github.com/maypok86/otter/internal/generated/node"
 
@@ -35,7 +35,7 @@ func (f *Fixed[K, V]) Delete(n node.Node[K, V]) {
 }
 
 func (f *Fixed[K, V]) RemoveExpired(expired []node.Node[K, V]) []node.Node[K, V] {
-	for !f.q.isEmpty() && f.q.head.IsExpired() {
+	for !f.q.isEmpty() && f.q.head.HasExpired() {
 		expired = append(expired, f.q.pop())
 	}
 	return expired
diff --git a/internal/expire/queue.go b/internal/expiry/queue.go
similarity index 99%
rename from internal/expire/queue.go
rename to internal/expiry/queue.go
index dc906b9..18c5a85 100644
--- a/internal/expire/queue.go
+++ b/internal/expiry/queue.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package expire
+package expiry
 
 import "github.com/maypok86/otter/internal/generated/node"
 
diff --git a/internal/expire/variable.go b/internal/expiry/variable.go
similarity index 99%
rename from internal/expire/variable.go
rename to internal/expiry/variable.go
index 522a318..b5791a1 100644
--- a/internal/expire/variable.go
+++ b/internal/expiry/variable.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package expire
+package expiry
 
 import (
 	"math"
diff --git a/internal/generated/node/b.go b/internal/generated/node/b.go
index 28190aa..a10e484 100644
--- a/internal/generated/node/b.go
+++ b/internal/generated/node/b.go
@@ -87,7 +87,7 @@ func (n *B[K, V]) SetNextExp(v Node[K, V]) {
 	panic("not implemented")
 }
 
-func (n *B[K, V]) IsExpired() bool {
+func (n *B[K, V]) HasExpired() bool {
 	return false
 }
 
diff --git a/internal/generated/node/bc.go b/internal/generated/node/bc.go
index 7342597..962ecde 100644
--- a/internal/generated/node/bc.go
+++ b/internal/generated/node/bc.go
@@ -91,7 +91,7 @@ func (n *BC[K, V]) SetNextExp(v Node[K, V]) {
 	panic("not implemented")
 }
 
-func (n *BC[K, V]) IsExpired() bool {
+func (n *BC[K, V]) HasExpired() bool {
 	return false
 }
 
diff --git a/internal/generated/node/be.go b/internal/generated/node/be.go
index 15377d9..ef66eef 100644
--- a/internal/generated/node/be.go
+++ b/internal/generated/node/be.go
@@ -103,8 +103,8 @@ func (n *BE[K, V]) SetNextExp(v Node[K, V]) {
 	n.nextExp = (*BE[K, V])(v.AsPointer())
 }
 
-func (n *BE[K, V]) IsExpired() bool {
-	return n.expiration > 0 && n.expiration < unixtime.Now()
+func (n *BE[K, V]) HasExpired() bool {
+	return n.expiration <= unixtime.Now()
 }
 
 func (n *BE[K, V]) Expiration() uint32 {
diff --git a/internal/generated/node/bec.go b/internal/generated/node/bec.go
index e438d13..f4813ca 100644
--- a/internal/generated/node/bec.go
+++ b/internal/generated/node/bec.go
@@ -107,8 +107,8 @@ func (n *BEC[K, V]) SetNextExp(v Node[K, V]) {
 	n.nextExp = (*BEC[K, V])(v.AsPointer())
 }
 
-func (n *BEC[K, V]) IsExpired() bool {
-	return n.expiration > 0 && n.expiration < unixtime.Now()
+func (n *BEC[K, V]) HasExpired() bool {
+	return n.expiration <= unixtime.Now()
 }
 
 func (n *BEC[K, V]) Expiration() uint32 {
diff --git a/internal/generated/node/manager.go b/internal/generated/node/manager.go
index 8ea396e..e48b900 100644
--- a/internal/generated/node/manager.go
+++ b/internal/generated/node/manager.go
@@ -45,8 +45,8 @@ type Node[K comparable, V any] interface {
 	NextExp() Node[K, V]
 	// SetNextExp sets the next node in the expiration policy.
 	SetNextExp(v Node[K, V])
-	// IsExpired returns true if node is expired.
-	IsExpired() bool
+	// HasExpired returns true if node has expired.
+	HasExpired() bool
 	// Expiration returns the expiration time.
 	Expiration() uint32
 	// Cost returns the cost of the node.
diff --git a/internal/s3fifo/main.go b/internal/s3fifo/main.go
index d03bf80..684e262 100644
--- a/internal/s3fifo/main.go
+++ b/internal/s3fifo/main.go
@@ -44,7 +44,7 @@ func (m *main[K, V]) evict(deleted []node.Node[K, V]) []node.Node[K, V] {
 	for m.cost > 0 {
 		n := m.q.pop()
 
-		if !n.IsAlive() || n.IsExpired() || n.Frequency() == 0 {
+		if !n.IsAlive() || n.HasExpired() || n.Frequency() == 0 {
 			n.Unmark()
 			m.cost -= n.Cost()
 			return append(deleted, n)
diff --git a/internal/s3fifo/small.go b/internal/s3fifo/small.go
index cb84528..acab906 100644
--- a/internal/s3fifo/small.go
+++ b/internal/s3fifo/small.go
@@ -53,7 +53,7 @@ func (s *small[K, V]) evict(deleted []node.Node[K, V]) []node.Node[K, V] {
 	n := s.q.pop()
 	s.cost -= n.Cost()
 	n.Unmark()
-	if !n.IsAlive() || n.IsExpired() {
+	if !n.IsAlive() || n.HasExpired() {
 		return append(deleted, n)
 	}
 
diff --git a/internal/unixtime/unixtime.go b/internal/unixtime/unixtime.go
index 0737adf..a446921 100644
--- a/internal/unixtime/unixtime.go
+++ b/internal/unixtime/unixtime.go
@@ -23,7 +23,8 @@ import (
 var (
 	// We need this package because time.Now() is slower, allocates memory,
 	// and we don't need a more precise time for the expiry time (and most other operations).
-	now uint32
+	now       uint32
+	startTime int64
 
 	mutex         sync.Mutex
 	countInstance int
@@ -32,7 +33,7 @@ var (
 
 func startTimer() {
 	done = make(chan struct{})
-	startTime := time.Now().Unix()
+	atomic.StoreInt64(&startTime, time.Now().Unix())
 	atomic.StoreUint32(&now, uint32(0))
 
 	go func() {
@@ -41,7 +42,7 @@ func startTimer() {
 		for {
 			select {
 			case t := <-ticker.C:
-				atomic.StoreUint32(&now, uint32(t.Unix()-startTime))
+				atomic.StoreUint32(&now, uint32(t.Unix()-StartTime()))
 			case <-done:
 				return
 			}
@@ -84,3 +85,8 @@ func Now() uint32 {
 func SetNow(t uint32) {
 	atomic.StoreUint32(&now, t)
 }
+
+// StartTime returns the start time of the program.
+func StartTime() int64 {
+	return atomic.LoadInt64(&startTime)
+}


Test patch:
diff --git a/cache_test.go b/cache_test.go
index 39969dc..d573f45 100644
--- a/cache_test.go
+++ b/cache_test.go
@@ -317,6 +317,60 @@ func TestCache_DeleteByFunc(t *testing.T) {
 	}
 }
 
+func TestCache_Advanced(t *testing.T) {
+	size := 256
+	defaultTTL := time.Hour
+	c, err := MustBuilder[int, int](size).
+		WithTTL(defaultTTL).
+		Build()
+	if err != nil {
+		t.Fatalf("can not create builder: %v", err)
+	}
+
+	for i := 0; i < size; i++ {
+		c.Set(i, i)
+	}
+
+	k1 := 4
+	v1, ok := c.Extension().GetQuietly(k1)
+	if !ok {
+		t.Fatalf("not found key %d", k1)
+	}
+
+	e1, ok := c.Extension().GetEntryQuietly(k1)
+	if !ok {
+		t.Fatalf("not found key %d", k1)
+	}
+
+	e2, ok := c.Extension().GetEntry(k1)
+	if !ok {
+		t.Fatalf("not found key %d", k1)
+	}
+
+	time.Sleep(time.Second)
+
+	isValidEntries := e1.Key() == k1 &&
+		e1.Value() == v1 &&
+		e1.Cost() == 1 &&
+		e1 == e2 &&
+		e1.TTL() < defaultTTL &&
+		!e1.HasExpired()
+
+	if !isValidEntries {
+		t.Fatalf("found not valid entries. e1: %+v, e2: %+v, v1:%d", e1, e2, v1)
+	}
+
+	if _, ok := c.Extension().GetQuietly(size); ok {
+		t.Fatalf("found not valid key: %d", size)
+	}
+	if _, ok := c.Extension().GetEntryQuietly(size); ok {
+		t.Fatalf("found not valid key: %d", size)
+	}
+	if _, ok := c.Extension().GetEntry(size); ok {
+		t.Fatalf("found not valid key: %d", size)
+	}
+}
+
 func TestCache_Ratio(t *testing.T) {
 	var mutex sync.Mutex
 	m := make(map[DeletionCause]int)
diff --git a/entry_test.go b/entry_test.go
new file mode 100644
index 0000000..e2a8f11
--- /dev/null
+++ b/entry_test.go
@@ -0,0 +1,69 @@
+// 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.
+// 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 otter
+
+import (
+	"testing"
+	"time"
+)
+
+func TestEntry(t *testing.T) {
+	k := 2
+	v := 3
+	exp := int64(0)
+	c := uint32(5)
+	e := Entry[int, int]{
+		key:        k,
+		value:      v,
+		expiration: exp,
+		cost:       c,
+	}
+
+	if e.Key() != k {
+		t.Fatalf("not valid key. want %d, got %d", k, e.Key())
+	}
+	if e.Value() != v {
+		t.Fatalf("not valid value. want %d, got %d", v, e.Value())
+	}
+	if e.Cost() != c {
+		t.Fatalf("not valid cost. want %d, got %d", c, e.Cost())
+	}
+	if e.Expiration() != exp {
+		t.Fatalf("not valid expiration. want %d, got %d", exp, e.Expiration())
+	}
+	if ttl := e.TTL(); ttl != -1 {
+		t.Fatalf("not valid ttl. want -1, got %d", ttl)
+	}
+	if e.HasExpired() {
+		t.Fatal("entry should not be expire")
+	}
+
+	newTTL := int64(10)
+	e.expiration = time.Now().Unix() + newTTL
+	if ttl := e.TTL(); ttl <= 0 || ttl > time.Duration(newTTL)*time.Second {
+		t.Fatalf("ttl should be in the range (0, %d] seconds, but got %d seconds", newTTL, ttl/time.Second)
+	}
+	if e.HasExpired() {
+		t.Fatal("entry should not be expire")
+	}
+
+	e.expiration -= 2 * newTTL
+	if ttl := e.TTL(); ttl != 0 {
+		t.Fatalf("ttl should be 0 seconds, but got %d seconds", ttl/time.Second)
+	}
+	if !e.HasExpired() {
+		t.Fatalf("entry should have expired")
+	}
+}
diff --git a/internal/expire/queue_test.go b/internal/expiry/queue_test.go
similarity index 99%
rename from internal/expire/queue_test.go
rename to internal/expiry/queue_test.go
index 2b6034f..9a31856 100644
--- a/internal/expire/queue_test.go
+++ b/internal/expiry/queue_test.go
@@ -9,7 +9,7 @@
 // license that can be found in the LICENSE file.
 // That can be found at https://cs.opensource.google/go/go/+/refs/tags/go1.21.5:LICENSE
 
-package expire
+package expiry
 
 import (
 	"strconv"
diff --git a/internal/expire/variable_test.go b/internal/expiry/variable_test.go
similarity index 99%
rename from internal/expire/variable_test.go
rename to internal/expiry/variable_test.go
index ef10431..f041e7e 100644
--- a/internal/expire/variable_test.go
+++ b/internal/expiry/variable_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package expire
+package expiry
 
 import (
 	"testing"