From ede93a27219b5735703b01c739ff9f5d5a240db3 Mon Sep 17 00:00:00 2001 From: Lazhar Ichir Date: Fri, 27 Oct 2023 09:04:14 +0100 Subject: [PATCH] Initial commit --- .gitignore | 12 +++ LICENSE | 21 ++++++ Makefile | 5 ++ cache.go | 119 +++++++++++++++++++++++++++++ cache_test.go | 114 ++++++++++++++++++++++++++++ cacher.go | 12 +++ entry.go | 52 +++++++++++++ entry_test.go | 46 ++++++++++++ evicter.go | 16 ++++ evicter_lfu.go | 47 ++++++++++++ evicter_lfu_test.go | 50 +++++++++++++ evicter_lru.go | 48 ++++++++++++ evicter_lru_test.go | 25 +++++++ evicter_no.go | 19 +++++ evicter_no_test.go | 21 ++++++ go.mod | 11 +++ go.sum | 10 +++ readme.md | 179 ++++++++++++++++++++++++++++++++++++++++++++ 18 files changed, 807 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 cache.go create mode 100644 cache_test.go create mode 100644 cacher.go create mode 100644 entry.go create mode 100644 entry_test.go create mode 100644 evicter.go create mode 100644 evicter_lfu.go create mode 100644 evicter_lfu_test.go create mode 100644 evicter_lru.go create mode 100644 evicter_lru_test.go create mode 100644 evicter_no.go create mode 100644 evicter_no_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2dd955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0251c29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Lazhar Ichir + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8295c60 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +test: + go test -v ./... + +testrace: + go test -race github.com/lazharichir/gocached -v -count=1 ./... \ No newline at end of file diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..9d4121b --- /dev/null +++ b/cache.go @@ -0,0 +1,119 @@ +package gocached + +import ( + "context" + "sync" + "time" +) + +type Options struct { + DefaultTTL *time.Duration +} + +func WithDefaultTTL(ttl time.Duration) func(*Options) { + return func(options *Options) { + options.DefaultTTL = &ttl + } +} + +func NewCache[K comparable, V any]( + evicter Evicter[K], + optFns ...func(*Options), +) *Cache[K, V] { + if evicter == nil { + evicter = NewNoEvicter[K]() + } + + options := &Options{} + for _, optFn := range optFns { + optFn(options) + } + + return &Cache[K, V]{ + options: *options, + data: make(map[K]Entry[K, V]), + lock: sync.RWMutex{}, + evicter: evicter, + } +} + +type Cache[K comparable, V any] struct { + options Options + evicter Evicter[K] + data map[K]Entry[K, V] + lock sync.RWMutex +} + +func (cache *Cache[K, V]) Set(ctx context.Context, key K, value V, opts ...EntryFn) error { + cache.lock.Lock() + defer cache.lock.Unlock() + return cache.set(ctx, key, value, opts...) +} + +func (cache *Cache[K, V]) Get(ctx context.Context, key K) (V, bool, error) { + cache.lock.RLock() + defer cache.lock.RUnlock() + return cache.get(ctx, key) +} + +func (cache *Cache[K, V]) Del(ctx context.Context, key K) error { + cache.lock.Lock() + defer cache.lock.Unlock() + return cache.del(ctx, key) +} + +func (cache *Cache[K, V]) Has(ctx context.Context, key K) bool { + cache.lock.RLock() + defer cache.lock.RUnlock() + return cache.has(ctx, key) +} + +func (cache *Cache[K, V]) set(ctx context.Context, key K, value V, opts ...EntryFn) error { + entry := Entry[K, V]{ + Key: key, + Value: value, + Written: time.Now(), + Options: makeEntryOptions(opts), + } + cache.data[key] = entry + cache.evicter.Promote(ctx, key, 1) + return nil +} + +func (cache *Cache[K, V]) getEntry(ctx context.Context, key K) (Entry[K, V], bool) { + if cache.has(ctx, key) { + entry := cache.data[key] + return entry, true + } + return Entry[K, V]{}, false +} + +func (cache *Cache[K, V]) get(ctx context.Context, key K) (V, bool, error) { + entry, ok := cache.getEntry(ctx, key) + + if !ok { + return *new(V), false, nil + } + + entryIsExplicitlyOutdated := entry.IsOutdated() + entryIsExpiredDueToDefaultTTL := cache.options.DefaultTTL != nil && time.Now().Sub(entry.Written) > *cache.options.DefaultTTL + + if entryIsExplicitlyOutdated || entryIsExpiredDueToDefaultTTL { + cache.del(ctx, key) + return *new(V), false, nil + } + + return entry.Value, true, nil +} + +func (cache *Cache[K, V]) del(ctx context.Context, key K) error { + delete(cache.data, key) + cache.evicter.Evict(ctx, key) + return nil +} + +func (cache *Cache[K, V]) has(ctx context.Context, key K) bool { + _, ok := cache.data[key] + cache.evicter.Promote(ctx, key, 1) + return ok +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..1f1473a --- /dev/null +++ b/cache_test.go @@ -0,0 +1,114 @@ +package gocached_test + +import ( + "context" + "testing" + "time" + + "github.com/lazharichir/gocached" + "github.com/stretchr/testify/assert" +) + +func TestCache(t *testing.T) { + ctx := context.Background() + + // Initialize cache + c := gocached.NewCache[string, string](nil) + + // Test Set and Get + c.Set(ctx, "key1", "value1") + c.Set(ctx, "key2", "value2") + + v1, found, err := c.Get(ctx, "key1") + assert.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "value1", v1) + + v2, found, err := c.Get(ctx, "key2") + assert.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "value2", v2) + + // Test Get with non-existing key + v3, found, err := c.Get(ctx, "key3") + assert.NoError(t, err) + assert.False(t, found) + assert.Equal(t, *new(string), v3) + + // Test Has + assert.True(t, c.Has(ctx, "key1")) + assert.False(t, c.Has(ctx, "key3")) + + // Test Del + c.Del(ctx, "key1") + assert.False(t, c.Has(ctx, "key1")) +} + +func TestDefaultTTL(t *testing.T) { + ctx := context.Background() + ttl := 250 * time.Millisecond + cache := gocached.NewCache[string, int](nil, gocached.WithDefaultTTL(ttl)) + cache.Set(ctx, "1", 1) + + v1, ok, _ := cache.Get(ctx, "1") + assert.True(t, ok, "key 1 should be present") + assert.Equal(t, 1, v1, "key 1 should be present") + + time.Sleep(ttl + (20 * time.Millisecond)) + + v1, ok, _ = cache.Get(ctx, "1") + assert.False(t, ok, "key 1 should have expired") + assert.Equal(t, 0, v1, "key 1 should have expired") +} + +func TestEntryWithTTL(t *testing.T) { + ttl := 100 * time.Millisecond + ctx := context.Background() + cache := gocached.NewCache[string, int](nil) + cache.Set(ctx, "1", 1) + cache.Set(ctx, "2", 2, gocached.WithTTL(ttl)) + + v1, ok, _ := cache.Get(ctx, "1") + assert.True(t, ok, "key 1 should be present") + assert.Equal(t, 1, v1, "key 1 should be present") + + v2, ok, _ := cache.Get(ctx, "2") + assert.True(t, ok, "key 2 should be present") + assert.Equal(t, 2, v2, "key 2 should be present") + + time.Sleep(ttl + (20 * time.Millisecond)) + + v1, ok, _ = cache.Get(ctx, "1") + assert.True(t, ok, "key 1 should still be present") + assert.Equal(t, 1, v1, "key 1 should still be present") + + v2, ok, _ = cache.Get(ctx, "2") + assert.False(t, ok, "key 2 should have expired") + assert.Equal(t, 0, v2, "key 2 should have expired") +} + +func TestEntryWithExpiryDate(t *testing.T) { + ctx := context.Background() + expiryDate := time.Now().Add(100 * time.Millisecond) + cache := gocached.NewCache[string, int](nil) + cache.Set(ctx, "1", 1) + cache.Set(ctx, "2", 2, gocached.WithExpiryDate(expiryDate)) + + v1, ok, _ := cache.Get(ctx, "1") + assert.True(t, ok, "key 1 should be present") + assert.Equal(t, 1, v1, "key 1 should be present") + + v2, ok, _ := cache.Get(ctx, "2") + assert.True(t, ok, "key 2 should be present") + assert.Equal(t, 2, v2, "key 2 should be present") + + time.Sleep(100 * time.Millisecond) + + v1, ok, _ = cache.Get(ctx, "1") + assert.True(t, ok, "key 1 should still be present") + assert.Equal(t, 1, v1, "key 1 should still be present") + + v2, ok, _ = cache.Get(ctx, "2") + assert.False(t, ok, "key 2 should have expired") + assert.Equal(t, 0, v2, "key 2 should have expired") +} diff --git a/cacher.go b/cacher.go new file mode 100644 index 0000000..781bfdf --- /dev/null +++ b/cacher.go @@ -0,0 +1,12 @@ +package gocached + +import ( + "context" +) + +type Cacher[K comparable, V any] interface { + Set(ctx context.Context, key K, value V, opts ...EntryFn) error + Get(ctx context.Context, key K) (V, bool, error) + Has(ctx context.Context, key K) bool + Del(ctx context.Context, key K) error +} diff --git a/entry.go b/entry.go new file mode 100644 index 0000000..ff94db9 --- /dev/null +++ b/entry.go @@ -0,0 +1,52 @@ +package gocached + +import "time" + +type Entry[K comparable, V any] struct { + Key K + Value V + Written time.Time + Options EntryOptions +} + +func (entry *Entry[K, V]) IsOutdated() bool { + if entry.IsPastExpiryDate() || entry.IsPastTTL() { + return true + } + return false +} + +func (entry *Entry[K, V]) IsPastExpiryDate() bool { + return entry.Options.ExpiryDate != nil && time.Now().After(*entry.Options.ExpiryDate) +} + +func (entry *Entry[K, V]) IsPastTTL() bool { + return entry.Options.TTL != nil && time.Now().Sub(entry.Written) > *entry.Options.TTL +} + +type EntryOptions struct { + ExpiryDate *time.Time + TTL *time.Duration +} + +type EntryFn func(*EntryOptions) + +func makeEntryOptions(opts []EntryFn) EntryOptions { + options := EntryOptions{} + for _, optFn := range opts { + optFn(&options) + } + return options +} + +func WithTTL(ttl time.Duration) EntryFn { + return func(opts *EntryOptions) { + opts.TTL = &ttl + } +} + +func WithExpiryDate(expiryDate time.Time) EntryFn { + return func(opts *EntryOptions) { + opts.ExpiryDate = &expiryDate + } +} diff --git a/entry_test.go b/entry_test.go new file mode 100644 index 0000000..4ba7e98 --- /dev/null +++ b/entry_test.go @@ -0,0 +1,46 @@ +package gocached_test + +import ( + "testing" + "time" + + "github.com/lazharichir/gocached" + "github.com/stretchr/testify/assert" +) + +func TestEntry(t *testing.T) { + expiryDate := time.Now().Add(1 * time.Hour) + ttl := 10 * time.Minute + entry := &gocached.Entry[string, int]{ + Key: "foo", + Value: 42, + Written: time.Now(), + Options: gocached.EntryOptions{ + ExpiryDate: &expiryDate, + TTL: &ttl, + }, + } + + // Test IsOutdated method + assert.False(t, entry.IsOutdated(), "Expected entry to not be outdated") + + // Test isPastExpiryDate method + assert.False(t, entry.IsPastExpiryDate(), "Expected entry to not be past expiry date") + + // Test isPastTTL method + assert.False(t, entry.IsPastTTL(), "Expected entry to not be past TTL") +} + +func TestEntryOptions(t *testing.T) { + expiryDate := time.Now().Add(1 * time.Hour) + ttl := 10 * time.Minute + + // Test EntryOptions + options := gocached.EntryOptions{ + ExpiryDate: &expiryDate, + TTL: &ttl, + } + + assert.Equal(t, expiryDate, *options.ExpiryDate, "Expected expiry date to be %v, got %v", expiryDate, *options.ExpiryDate) + assert.Equal(t, ttl, *options.TTL, "Expected TTL to be %v, got %v", ttl, *options.TTL) +} diff --git a/evicter.go b/evicter.go new file mode 100644 index 0000000..b752844 --- /dev/null +++ b/evicter.go @@ -0,0 +1,16 @@ +package gocached + +import ( + "context" +) + +type Evicter[K comparable] interface { + // Promote promotes the key. + Promote(ctx context.Context, key K, value int) + // Demote demotes the key. + Demote(ctx context.Context, key K, value int) + // Evictees returns the keys to evict. + Evictees(ctx context.Context, n int) []K + // Evict removes the key from the evicter and forgets about it completely. + Evict(ctx context.Context, key K) +} diff --git a/evicter_lfu.go b/evicter_lfu.go new file mode 100644 index 0000000..be4f967 --- /dev/null +++ b/evicter_lfu.go @@ -0,0 +1,47 @@ +package gocached + +import ( + "context" + "sort" +) + +type LFUEvicter[K comparable] struct { + frequency map[K]int +} + +func NewLFUEvicter[K comparable]() *LFUEvicter[K] { + return &LFUEvicter[K]{ + frequency: make(map[K]int), + } +} + +func (evicter *LFUEvicter[K]) Promote(ctx context.Context, key K, value int) { + evicter.frequency[key] = evicter.frequency[key] + value +} + +func (evicter *LFUEvicter[K]) Demote(ctx context.Context, key K, value int) { + evicter.frequency[key] = evicter.frequency[key] - value +} + +func (evicter *LFUEvicter[K]) Evict(ctx context.Context, key K) { + delete(evicter.frequency, key) +} + +func (evicter *LFUEvicter[K]) Evictees(ctx context.Context, n int) []K { + // Create a slice to store the keys + keys := make([]K, 0, len(evicter.frequency)) + for key := range evicter.frequency { + keys = append(keys, key) + } + + // Sort the keys by their frequency + sort.Slice(keys, func(i, j int) bool { + return evicter.frequency[keys[i]] < evicter.frequency[keys[j]] + }) + + // Return the first n keys + if n > len(keys) { + n = len(keys) + } + return keys[:n] +} diff --git a/evicter_lfu_test.go b/evicter_lfu_test.go new file mode 100644 index 0000000..364645c --- /dev/null +++ b/evicter_lfu_test.go @@ -0,0 +1,50 @@ +package gocached + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLFUEvicter(t *testing.T) { + ctx := context.Background() + lfu := NewLFUEvicter[string]() + + lfu.Promote(ctx, "key3", 1) + lfu.Promote(ctx, "key3", 1) + lfu.Promote(ctx, "key3", 1) + + lfu.Promote(ctx, "key2", 1) + lfu.Promote(ctx, "key2", 1) + + lfu.Promote(ctx, "key1", 1) + + assert.Equal(t, []string{"key1"}, lfu.Evictees(ctx, 1)) + + lfu.Promote(ctx, "key1", 5) + + assert.Equal(t, []string{"key2"}, lfu.Evictees(ctx, 1)) + + lfu.Demote(ctx, "key1", 1) + lfu.Demote(ctx, "key2", 1) + lfu.Demote(ctx, "key3", 1) + + assert.Equal(t, []string{"key2"}, lfu.Evictees(ctx, 1)) + + lfu.Demote(ctx, "key3", 3) + lfu.Demote(ctx, "key2", 1) + + assert.Equal(t, []string{"key3"}, lfu.Evictees(ctx, 1)) + + assert.ElementsMatch(t, []string{"key3", "key2"}, lfu.Evictees(ctx, 2)) + + lfu.Evict(ctx, "key3") + + assert.ElementsMatch(t, []string{"key2", "key1"}, lfu.Evictees(ctx, 2)) + + lfu.Evict(ctx, "key1") + lfu.Evict(ctx, "key2") + + assert.ElementsMatch(t, []string{}, lfu.Evictees(ctx, 2)) +} diff --git a/evicter_lru.go b/evicter_lru.go new file mode 100644 index 0000000..4b138b0 --- /dev/null +++ b/evicter_lru.go @@ -0,0 +1,48 @@ +package gocached + +import ( + "context" + "sort" + "time" +) + +type LRUEvicter[K comparable] struct { + internal map[K]time.Time +} + +func NewLRUEvicter[K comparable]() *LRUEvicter[K] { + return &LRUEvicter[K]{ + internal: make(map[K]time.Time), + } +} + +func (evicter *LRUEvicter[K]) Promote(ctx context.Context, key K, value int) { + evicter.internal[key] = time.Now() +} + +func (evicter *LRUEvicter[K]) Demote(ctx context.Context, key K, value int) { + evicter.Demote(ctx, key, value) +} + +func (evicter *LRUEvicter[K]) Evict(ctx context.Context, key K) { + delete(evicter.internal, key) +} + +func (evicter *LRUEvicter[K]) Evictees(ctx context.Context, n int) []K { + // Create a slice to store the keys + keys := make([]K, 0, len(evicter.internal)) + for key := range evicter.internal { + keys = append(keys, key) + } + + // Sort the keys by their internal + sort.Slice(keys, func(i, j int) bool { + return evicter.internal[keys[i]].Before(evicter.internal[keys[j]]) + }) + + // Return the first n keys + if n > len(keys) { + n = len(keys) + } + return keys[:n] +} diff --git a/evicter_lru_test.go b/evicter_lru_test.go new file mode 100644 index 0000000..301dd04 --- /dev/null +++ b/evicter_lru_test.go @@ -0,0 +1,25 @@ +package gocached + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLRUEvicter(t *testing.T) { + ctx := context.Background() + lfu := NewLRUEvicter[string]() + + lfu.Promote(ctx, "key1", 0) + lfu.Promote(ctx, "key2", 0) + lfu.Promote(ctx, "key3", 0) + + assert.Equal(t, []string{"key1"}, lfu.Evictees(ctx, 1)) + + lfu.Promote(ctx, "key1", 0) + + assert.Equal(t, []string{"key2"}, lfu.Evictees(ctx, 1)) + assert.Equal(t, []string{"key2", "key3"}, lfu.Evictees(ctx, 2)) + +} diff --git a/evicter_no.go b/evicter_no.go new file mode 100644 index 0000000..05d5f68 --- /dev/null +++ b/evicter_no.go @@ -0,0 +1,19 @@ +package gocached + +import "context" + +type NoEvicter[K comparable] struct{} + +func NewNoEvicter[K comparable]() *NoEvicter[K] { + return &NoEvicter[K]{} +} + +func (evicter *NoEvicter[K]) Promote(ctx context.Context, key K, delta int) {} + +func (evicter *NoEvicter[K]) Demote(ctx context.Context, key K, delta int) {} + +func (evicter *NoEvicter[K]) Evict(ctx context.Context, key K) {} + +func (evicter *NoEvicter[K]) Evictees(ctx context.Context, n int) []K { + return []K{} +} diff --git a/evicter_no_test.go b/evicter_no_test.go new file mode 100644 index 0000000..82d7cf5 --- /dev/null +++ b/evicter_no_test.go @@ -0,0 +1,21 @@ +package gocached_test + +import ( + "context" + "testing" + + "github.com/lazharichir/gocached" + "github.com/stretchr/testify/assert" +) + +func TestNoEvicter(t *testing.T) { + ctx := context.Background() + ev := gocached.NewNoEvicter[any]() + assert.NotNil(t, ev) + assert.NotPanics(t, func() { + ev.Promote(ctx, 1, 0) + ev.Demote(ctx, 1, 0) + ev.Evictees(ctx, 1) + ev.Evict(ctx, 1) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab000a0 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/lazharichir/gocached + +go 1.21.3 + +require github.com/stretchr/testify v1.8.4 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e9d304d --- /dev/null +++ b/readme.md @@ -0,0 +1,179 @@ +With `gocached`, Golang developers can simply add in-memory caching to their applications without having to worry about the underlying implementation. It is lightweight and easy-to-use. It is thread-safe and provides a simple API for storing and retrieving any type of data. + +⚠️ This was created as an exercise and prototype for an internal tool. It's not fully-featured and PRs are welcome. + +## Getting Started + +### Installation + +```bash +go get github.com/lazharichir/gocached +``` + +### Usage + +```golang +import ( + "context" + "fmt" + "time" + + "github.com/lazharichir/gocached" +) + +func main() { + ctx := context.Background() + + // Add an optional Eviction Policy that will be used to evict items from the cache + // - LRU: Least Recently Used + // - LFU: Least Frequently Used + lru := gocached.NewLRUEvicter[string]() + + // Create a new cache instance: + // - the cache keys are of type string + // - the cache values are of type string + cache := gocached.NewCache[string, int](lru, gocached.WithDefaultTTL(5*time.Second)) + + // Set a value in the cache (key-value) + cache.Set(ctx, "the_key_1", 123456789, gocached.WithTTL(10*time.Second)) + + // Has checks if a key exists in the cache + if cache.Has(ctx, "the_key_1") { + fmt.Println("the_key_1 exists in the cache") + } else { + fmt.Println("the_key_1 does not exist in the cache [BIG PROBLEM]") + } + + // Get a value from the cache + // - value is automatically typed to int + // - found is a boolean indicating if the key was found in the cache + // - err is an error if something went wrong + value, found, err := cache.Get(ctx, "the_key_1") + if err != nil { + panic(err) + } + if found { + fmt.Println("the_key_1 was found:", value) // prints "the_key_1 was found: 123456789" + } else { + fmt.Println("the_key_1 was not found [ANOTHER BIG PROBLEM]") + } + + // Delete a value from the cache + cache.Delete(ctx, "the_key_1") +} +``` + +## Generics + +`gocached` is written in Golang, and makes use of Generics to ensure type safety at compile time. This means that you can store any type of data in the cache, and retrieve it without having to worry about type conversions, by creating specific cache instances. + +```golang +type User struct { + ID string + Name string + Age int +} + +type Product struct { + ID string + Name string + Price float64 +} + +userCache := gocached.NewCache[string, User](nil) +productCache := gocached.NewCache[string, Product](nil) + +usr, found, err := userCache.Get(ctx, "user-1") +// usr is of type User + +product, found, err := productCache.Get(ctx, "product-1") +// product is of type Product +``` + +If you prefer to store bytes in the cache, you can use the `[]byte` type and then simply convert it to the type you want. + +```golang +bytesCache := gocached.NewCache[string, []byte](nil) +``` + +## Evictions + +`gocached` supports two types of eviction policies: +* Least Recently Used (LRU) +* Least Frequently Used (LFU) + +By default, there is no eviction policy, and the cache will grow indefinitely. You can add an eviction policy when creating a new cache instance. + +```golang +lru := gocached.NewLRUEvicter[string]() +lruCache := gocached.NewCache[string, int](lru) + +lfu := gocached.NewLFUEvicter[string]() +lfuCache := gocached.NewCache[string, int](lfu) +``` + +## Options + +`gocached` supports a few options that can be used to customize the default cache behavior, as well as specific cached entries. + +### Default TTL + +The default TTL (Time To Live) is a cache-wide option that can be used to set a default expiration time for all cached entries. It can be overridden when setting a new entry in the cache. + +```golang +cache := gocached.NewCache[string, int](nil, gocached.WithDefaultTTL(5*time.Second)) +``` + +### Entry TTL + +The entry TTL is an option that can be used to set a specific expiration time for a cached entry. It overrides the default TTL. + +```golang +cache := gocached.NewCache[string, int](nil, gocached.WithDefaultTTL(5*time.Second)) +cache.Set(ctx, "the_key_1", 123456789, gocached.WithTTL(10*time.Second)) +``` + +### Entry Expiry Date + +The entry expiry date is an option that can be used to set a specific expiry date for a cached entry. + +```golang +cache := gocached.NewCache[string, int](nil) +cache.Set(ctx, "the_key_1", 123456789, gocached.WithExpiryDate(time.Now().Add(10*time.Second))) +``` + +## Thread Safety + +`gocached` is thread-safe, and can be used in a multi-threaded environment. The four main entry points (Set, Get, Has, Del) make use of a sync.RWMutex. + +## Feature Ideas + +`gocached` is still in its early stages, and there is a lot of room for improvement. The following features would make for great additions: + +* [ ] Add a maximum size option to the cache +* [ ] Add optional callbacks for cache events (e.g. on evict, on set, on get, on del) +* [ ] Batch operations (e.g. BatchSet, BatchGet, BatchHas, BatchDel) +* [ ] Measure and improve performance +* [ ] Add optional metrics (e.g. number of hits, misses, evictions) + +## Miscellanous + +### But Is It Super Fast Though? + +[First make it work, then make it right, and finally make it fast.](https://wiki.c2.com/?MakeItWorkMakeItRightMakeItFast) `gocached` is still young and performance has not been scientifically measured and benchmarked yet. There are already a few low-hanging fruits to improve performance. + +### Contribution + +Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make to `gocached` are greatly appreciated. Whether it be fixing bugs, proposing new features, or discussing potential improvements, your input helps shape the library into a better tool for everyone. + +Steps to contribute: + +1. **Fork** the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a **Pull Request** + +### License + +Distributed under the MIT License. See LICENSE for more information. \ No newline at end of file