diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f6a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +build/ +.task/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..273e2af --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,39 @@ +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - copyloopvar + - dupl + - err113 + - errname + - exptostd + - fatcontext + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gosec + - perfsprint + - testifylint + +linters-settings: + testifylint: + enable-all: true + disable: + - require-error + gocognit: + min-complexity: 5 + gocyclo: + min-complexity: 5 + gocritic: + enable-all: true + settings: + hugeParam: + sizeThreshold: 255 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf9fb36 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +### [0.5.0] - 2025-04-21 - 🚀 Stable release! + +Everything works. diff --git a/README.md b/README.md new file mode 100644 index 0000000..160dc52 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# ezcache + +ezcache is a simple in-memory cache for golang that has data expiry. + +## Usage + +```go +import "codeberg.org/danjones000/ezcache" + +// ... + +// Create a user cache which will cache users for five minutes +userCache := ezcache.New(func(id uint64) (User, error) { + // logic to fetch user from database + return User{}, nil +}, 5 * time.Minute) + +user, err := user.Get(userID) +// Next time you do user.Get with the same userID within five minutes, it will be fetched from cache. +// After five minutes, it will fetch from the database again. +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..ef9f12e --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,62 @@ +version: '3' + +tasks: + default: + cmds: + - task: fmt + - task: test + - task: lint + + fmt: + desc: Format go code + sources: + - '**/*.go' + cmds: + - go fmt ./... + - go mod tidy + + gen: + desc: Generate files + sources: + - '**/*.go' + cmds: + - go generate ./... + + lint: + desc: Do static analysis + sources: + - '**/*.go' + cmds: + - golangci-lint run + + test: + desc: Run unit tests + deps: [fmt] + sources: + - '**/*.go' + generates: + - build/cover.out + cmds: + - go test -race -cover -coverprofile build/cover.out ./... + + coverage-report: + desc: Build coverage report + deps: [test] + sources: + - build/cover.out + generates: + - build/cover.html + cmds: + - go tool cover -html=build/cover.out -o build/cover.html + + serve-report: + desc: Serve the coverage report + deps: [coverage-report] + cmds: + - ip addr list | grep inet + - php -S 0.0.0.0:3434 -t build + + serve-docs: + desc: Serve the current docs + cmds: + - godoc -http=0.0.0.0:3434 -play diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..6cb8268 --- /dev/null +++ b/cache.go @@ -0,0 +1,31 @@ +package ezcache + +import ( + "errors" + "time" +) + +// ErrInvalidFetcher is returned by Cache.SetFetcher if the fetcher is invalid. +// This is probably only going to happen if it's nil. +var ErrInvalidFetcher = errors.New("invalid fetcher") + +// ErrInvalidExpiry is returned by Cache.SetExpiry if the duration is invalid. +// This is usually if it is <= 0. +var ErrInvalidExpiry = errors.New("invalid duration") + +type Fetcher[K comparable, V any] func(K) (V, error) + +// Cache represents a Cache for values. +type Cache[K comparable, V any] interface { + // Get will fetch the value for key. If in the cache, it will fetch the cached value. + // If not in the cache, it will use the Fetcher. + // It may return [ErrInvalidFetcher], but if an error is returned, it's probably returned by the + // [Fetcher] itself. + Get(key K) (V, error) + + // SetFetcher sets the fetcher for this [Cache]. + SetFetcher(f Fetcher[K, V]) error + + // SetExpiry sets the expiry for this [Cache]. + SetExpiry(d time.Duration) error +} diff --git a/ezcache.go b/ezcache.go new file mode 100644 index 0000000..51b52ed --- /dev/null +++ b/ezcache.go @@ -0,0 +1,76 @@ +package ezcache + +import ( + "sync" + "time" +) + +type ezc[K comparable, V any] struct { + fetch Fetcher[K, V] + exp time.Duration + cache map[K]V + setTime map[K]time.Time + lock sync.RWMutex + timeLock sync.RWMutex +} + +func New[K comparable, V any](fetcher Fetcher[K, V], exp time.Duration) (Cache[K, V], error) { + c := &ezc[K, V]{} + err := c.SetFetcher(fetcher) + if err != nil { + return nil, err + } + err = c.SetExpiry(exp) + if err != nil { + return nil, err + } + + c.cache = make(map[K]V) + c.setTime = make(map[K]time.Time) + return c, nil +} + +func (c *ezc[K, V]) Get(key K) (V, error) { + c.timeLock.RLock() + setTime, ok := c.setTime[key] + c.timeLock.RUnlock() + if ok && time.Since(setTime) <= c.exp { + c.lock.RLock() + val, ok := c.cache[key] + c.lock.RUnlock() + if ok { + return val, nil + } + } + + val, err := c.fetch(key) + if err != nil { + return val, err + } + + c.lock.Lock() + defer c.lock.Unlock() + c.cache[key] = val + + c.timeLock.Lock() + defer c.timeLock.Unlock() + c.setTime[key] = time.Now() + + return val, nil +} + +func (c *ezc[K, V]) SetFetcher(f Fetcher[K, V]) error { + if f == nil { + return ErrInvalidFetcher + } + c.fetch = f + return nil +} + +func (c *ezc[K, V]) SetExpiry(exp time.Duration) error { + if exp <= 0 { + return ErrInvalidExpiry + } + c.exp = exp + return nil +} diff --git a/ezcache_test.go b/ezcache_test.go new file mode 100644 index 0000000..b713a75 --- /dev/null +++ b/ezcache_test.go @@ -0,0 +1,84 @@ +package ezcache_test + +import ( + "fmt" + "testing" + "time" + + "codeberg.org/danjones000/ezcache" + "github.com/stretchr/testify/assert" +) + +var fetcher ezcache.Fetcher[uint8, string] = func(key uint8) (string, error) { return fmt.Sprintf("%d", key), nil } + +func TestNewHappy(t *testing.T) { + cache, err := ezcache.New(fetcher, 1) + + assert.NoError(t, err) + assert.NotNil(t, cache) +} + +func TestNewNilFetcher(t *testing.T) { + cache, err := ezcache.New[uint8, string](nil, 1) + + assert.ErrorIs(t, err, ezcache.ErrInvalidFetcher) + assert.Nil(t, cache) +} + +func TestNewBadExpiry(tt *testing.T) { + testcases := []struct { + name string + exp time.Duration + }{ + {"zero", 0}, + {"negative", -5}, + } + for _, tc := range testcases { + tt.Run(tc.name, func(t *testing.T) { + cache, err := ezcache.New(fetcher, tc.exp) + + assert.ErrorIs(t, err, ezcache.ErrInvalidExpiry) + assert.Nil(t, cache) + }) + } +} + +func TestGetHappy(t *testing.T) { + var hit bool + cache, _ := ezcache.New(func(key uint8) (string, error) { hit = true; return fetcher(key) }, 5*time.Second) + + val, err := cache.Get(4) + assert.NoError(t, err) + assert.Equal(t, "4", val) + assert.True(t, hit) + + hit = false + val, err = cache.Get(4) + assert.NoError(t, err) + assert.Equal(t, "4", val) + assert.False(t, hit) +} + +func TestGetExpire(t *testing.T) { + var hit bool + cache, _ := ezcache.New(func(key uint8) (string, error) { hit = true; return fetcher(key) }, 1) + + val, err := cache.Get(4) + assert.NoError(t, err) + assert.Equal(t, "4", val) + assert.True(t, hit) + + hit = false + time.Sleep(2) + val, err = cache.Get(4) + assert.NoError(t, err) + assert.Equal(t, "4", val) + assert.True(t, hit) +} + +func TestGetError(t *testing.T) { + cache, _ := ezcache.New(func(k uint8) (byte, error) { return 0, fmt.Errorf("Nope for %d", k) }, 1) + + _, err := cache.Get(4) + assert.ErrorContains(t, err, "Nope for 4") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6718616 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module codeberg.org/danjones000/ezcache + +go 1.23.7 + +require github.com/stretchr/testify v1.10.0 + +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..713a0b4 --- /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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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=