Skip to content

Commit

Permalink
quick POC for generic implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
umputun committed May 30, 2022
1 parent e9d36b6 commit 868ec69
Show file tree
Hide file tree
Showing 6 changed files with 606 additions and 0 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/ci-v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: build-v2

on:
push:
branches:
tags:
pull_request:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: set up go 1.18
uses: actions/setup-go@v2
with:
go-version: 1.18
id: go

- name: checkout
uses: actions/checkout@v2

- name: build and test
run: |
go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov
go build -race
working-directory: v2

- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.46.2
GO111MODULE=off go get -u -v github.com/mattn/goveralls
- name: run linters
run: $GITHUB_WORKSPACE/golangci-lint run --out-format=github-actions
working-directory: v2

- name: submit coverage
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: v2
284 changes: 284 additions & 0 deletions v2/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// Package cache implements Cache similar to hashicorp/golang-lru
//
// Support LRC, LRU and TTL-based eviction.
// Package is thread-safe and doesn't spawn any goroutines.
// On every Set() call, cache deletes single oldest entry in case it's expired.
// In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size,
// either using LRC or LRU eviction.
// In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited
// and will never delete entries from itself automatically.
//
// Important: only reliable way of not having expired entries stuck in a cache is to
// run cache.DeleteExpired periodically using time.Ticker, advisable period is 1/2 of TTL.
package cache

import (
"container/list"
"fmt"
"sync"
"time"
)

// Cache defines cache interface
type Cache[K comparable, V any] interface {
fmt.Stringer
Set(key K, value V, ttl time.Duration)
Get(key K) (V, bool)
Peek(key K) (V, bool)
Keys() []K
Len() int
Invalidate(key K)
InvalidateFn(fn func(key K) bool)
RemoveOldest()
DeleteExpired()
Purge()
Stat() Stats
}

// Stats provides statistics for cache
type Stats struct {
Hits, Misses int // cache effectiveness
Added, Evicted int // number of added and evicted records
}

// cacheImpl provides Cache interface implementation.
type cacheImpl[K comparable, V any] struct {
ttl time.Duration
maxKeys int
isLRU bool
onEvicted func(key K, value V)

sync.Mutex
stat Stats
items map[K]*list.Element
evictList *list.List
}

// noEvictionTTL - very long ttl to prevent eviction
const noEvictionTTL = time.Hour * 24 * 365 * 10

// NewCache returns a new Cache.
// Default MaxKeys is unlimited (0).
// Default TTL is 10 years, sane value for expirable cache is 5 minutes.
// Default eviction mode is LRC, appropriate option allow to change it to LRU.
func NewCache[K comparable, V any](options ...*options[K, V]) (Cache[K, V], error) {
res := &cacheImpl[K, V]{
items: map[K]*list.Element{},
evictList: list.New(),
ttl: noEvictionTTL,
maxKeys: 0,
}

if len(options) > 0 {
if options[0].ttl != nil {
res.ttl = *options[0].ttl
}
if options[0].maxKeys != nil {
res.maxKeys = *options[0].maxKeys
}
if options[0].lru != nil {
res.isLRU = *options[0].lru
}
if options[0].onEvicted != nil {
res.onEvicted = options[0].onEvicted
}
}

return res, nil
}

// Set key, ttl of 0 would use cache-wide TTL
func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) {
c.Lock()
defer c.Unlock()
now := time.Now()
if ttl == 0 {
ttl = c.ttl
}

// Check for existing item
if ent, ok := c.items[key]; ok {
c.evictList.MoveToFront(ent)
ent.Value.(*cacheItem[K, V]).value = value
ent.Value.(*cacheItem[K, V]).expiresAt = now.Add(ttl)
return
}

// Add new item
ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now.Add(ttl)}
entry := c.evictList.PushFront(ent)
c.items[key] = entry
c.stat.Added++

// Remove oldest entry if it is expired, only in case of non-default TTL.
if c.ttl != noEvictionTTL || ttl != noEvictionTTL {
c.removeOldestIfExpired()
}

// Verify size not exceeded
if c.maxKeys > 0 && len(c.items) > c.maxKeys {
c.removeOldest()
}
}

// Get returns the key value if it's not expired
func (c *cacheImpl[K, V]) Get(key K) (V, bool) {
def := *new(V)
c.Lock()
defer c.Unlock()
if ent, ok := c.items[key]; ok {
// Expired item check
if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.stat.Misses++
return def, false
}
if c.isLRU {
c.evictList.MoveToFront(ent)
}
c.stat.Hits++
return ent.Value.(*cacheItem[K, V]).value, true
}
c.stat.Misses++
return def, false
}

// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key.
// Works exactly the same as Get in case of LRC mode (default one).
func (c *cacheImpl[K, V]) Peek(key K) (V, bool) {
def := *new(V)
c.Lock()
defer c.Unlock()
if ent, ok := c.items[key]; ok {
// Expired item check
if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.stat.Misses++
return def, false
}
c.stat.Hits++
return ent.Value.(*cacheItem[K, V]).value, true
}
c.stat.Misses++
return def, false
}

// Keys returns a slice of the keys in the cache, from oldest to newest.
func (c *cacheImpl[K, V]) Keys() []K {
c.Lock()
defer c.Unlock()
return c.keys()
}

// Len return count of items in cache, including expired
func (c *cacheImpl[K, V]) Len() int {
c.Lock()
defer c.Unlock()
return c.evictList.Len()
}

// Invalidate key (item) from the cache
func (c *cacheImpl[K, V]) Invalidate(key K) {
c.Lock()
defer c.Unlock()
if ent, ok := c.items[key]; ok {
c.removeElement(ent)
}
}

// InvalidateFn deletes multiple keys if predicate is true
func (c *cacheImpl[K, V]) InvalidateFn(fn func(key K) bool) {
c.Lock()
defer c.Unlock()
for key, ent := range c.items {
if fn(key) {
c.removeElement(ent)
}
}
}

// RemoveOldest remove oldest element in the cache
func (c *cacheImpl[K, V]) RemoveOldest() {
c.Lock()
defer c.Unlock()
c.removeOldest()
}

// DeleteExpired clears cache of expired items
func (c *cacheImpl[K, V]) DeleteExpired() {
c.Lock()
defer c.Unlock()
for _, key := range c.keys() {
if time.Now().After(c.items[key].Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(c.items[key])
}
}
}

// Purge clears the cache completely.
func (c *cacheImpl[K, V]) Purge() {
c.Lock()
defer c.Unlock()
for k, v := range c.items {
delete(c.items, k)
c.stat.Evicted++
if c.onEvicted != nil {
c.onEvicted(k, v.Value.(*cacheItem[K, V]).value)
}
}
c.evictList.Init()
}

// Stat gets the current stats for cache
func (c *cacheImpl[K, V]) Stat() Stats {
c.Lock()
defer c.Unlock()
return c.stat
}

func (c *cacheImpl[K, V]) String() string {
stats := c.Stat()
size := c.Len()
return fmt.Sprintf("Size: %d, Stats: %+v (%0.1f%%)", size, stats, 100*float64(stats.Hits)/float64(stats.Hits+stats.Misses))
}

// Keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock!
func (c *cacheImpl[K, V]) keys() []K {
keys := make([]K, 0, len(c.items))
for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() {
keys = append(keys, ent.Value.(*cacheItem[K, V]).key)
}
return keys
}

// removeOldest removes the oldest item from the cache. Has to be called with lock!
func (c *cacheImpl[K, V]) removeOldest() {
ent := c.evictList.Back()
if ent != nil {
c.removeElement(ent)
}
}

// removeOldest removes the oldest item from the cache in case it's already expired. Has to be called with lock!
func (c *cacheImpl[K, V]) removeOldestIfExpired() {
ent := c.evictList.Back()
if ent != nil && time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(ent)
}
}

// removeElement is used to remove a given list element from the cache. Has to be called with lock!
func (c *cacheImpl[K, V]) removeElement(e *list.Element) {
c.evictList.Remove(e)
kv := e.Value.(*cacheItem[K, V])
delete(c.items, kv.key)
c.stat.Evicted++
if c.onEvicted != nil {
c.onEvicted(kv.key, kv.value)
}
}

// cacheItem is used to hold a value in the evictList
type cacheItem[K comparable, V any] struct {
expiresAt time.Time
key K
value V
}
Loading

0 comments on commit 868ec69

Please sign in to comment.