├── .github ├── FUNDING.yml ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── ci-v2.yml │ └── ci-v3.yml ├── go.mod ├── v2 ├── go.mod ├── go.sum ├── options.go ├── cache.go └── cache_test.go ├── .gitignore ├── v3 ├── go.mod ├── options.go ├── go.sum ├── cache.go └── cache_test.go ├── benchmarks ├── go.mod ├── go.sum ├── README.md └── benchmark_test.go ├── .golangci.yml ├── go.sum ├── options.go ├── LICENSE ├── cache.go ├── cache_test.go └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [umputun] 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | # Unless a later match takes precedence, @umputun will be requested for 3 | # review when someone opens a pull request. 4 | 5 | * @umputun 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/expirable-cache 2 | 3 | go 1.20 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /v2/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/expirable-cache/v2 2 | 3 | go 1.20 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /v3/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/expirable-cache/v3 2 | 3 | go 1.20 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 10 | github.com/pmezard/go-difflib v1.0.0 // indirect 11 | gopkg.in/yaml.v3 v3.0.1 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /benchmarks/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/expirable-cache/benchmarks 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/dgraph-io/ristretto v0.2.0 7 | github.com/go-pkgz/expirable-cache/v3 v3.0.0 8 | github.com/jellydator/ttlcache/v3 v3.3.0 9 | github.com/patrickmn/go-cache v2.1.0+incompatible 10 | ) 11 | 12 | require ( 13 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 14 | github.com/dustin/go-humanize v1.0.1 // indirect 15 | github.com/pkg/errors v0.9.1 // indirect 16 | golang.org/x/sync v0.12.0 // indirect 17 | golang.org/x/sys v0.31.0 // indirect 18 | ) 19 | 20 | replace github.com/go-pkgz/expirable-cache/v3 => ../v3 21 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | concurrency: 4 4 | linters: 5 | default: none 6 | enable: 7 | - dupl 8 | - gochecknoinits 9 | - gocritic 10 | - gocyclo 11 | - gosec 12 | - govet 13 | - ineffassign 14 | - misspell 15 | - nakedret 16 | - prealloc 17 | - revive 18 | - staticcheck 19 | - unconvert 20 | - unused 21 | settings: 22 | gocyclo: 23 | min-complexity: 15 24 | gocritic: 25 | disabled-checks: 26 | - wrapperFunc 27 | enabled-tags: 28 | - performance 29 | - style 30 | - experimental 31 | govet: 32 | enable-all: true 33 | disable: 34 | - fieldalignment 35 | misspell: 36 | locale: US 37 | 38 | exclusions: 39 | generated: lax 40 | paths: 41 | - vendor 42 | - third_party$ 43 | - builtin$ 44 | - examples$ 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /v2/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "time" 4 | 5 | // Option func type 6 | type Option func(lc *cacheImpl) error 7 | 8 | // OnEvicted called automatically for automatically and manually deleted entries 9 | func OnEvicted(fn func(key string, value interface{})) Option { 10 | return func(lc *cacheImpl) error { 11 | lc.onEvicted = fn 12 | return nil 13 | } 14 | } 15 | 16 | // MaxKeys functional option defines how many keys to keep. 17 | // By default it is 0, which means unlimited. 18 | func MaxKeys(maximum int) Option { 19 | return func(lc *cacheImpl) error { 20 | lc.maxKeys = maximum 21 | return nil 22 | } 23 | } 24 | 25 | // TTL functional option defines TTL for all cache entries. 26 | // By default it is set to 10 years, sane option for expirable cache might be 5 minutes. 27 | func TTL(ttl time.Duration) Option { 28 | return func(lc *cacheImpl) error { 29 | lc.ttl = ttl 30 | return nil 31 | } 32 | } 33 | 34 | // LRU sets cache to LRU (Least Recently Used) eviction mode. 35 | func LRU() Option { 36 | return func(lc *cacheImpl) error { 37 | lc.isLRU = true 38 | return nil 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Umputun 4 | Copyright (c) 2020 Dmitry Verhoturov 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /v2/options.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "time" 4 | 5 | type options[K comparable, V any] interface { 6 | WithTTL(ttl time.Duration) Cache[K, V] 7 | WithMaxKeys(maxKeys int) Cache[K, V] 8 | WithLRU() Cache[K, V] 9 | WithOnEvicted(fn func(key K, value V)) Cache[K, V] 10 | } 11 | 12 | // WithTTL functional option defines TTL for all cache entries. 13 | // By default, it is set to 10 years, sane option for expirable cache might be 5 minutes. 14 | func (c *cacheImpl[K, V]) WithTTL(ttl time.Duration) Cache[K, V] { 15 | c.ttl = ttl 16 | return c 17 | } 18 | 19 | // WithMaxKeys functional option defines how many keys to keep. 20 | // By default, it is 0, which means unlimited. 21 | func (c *cacheImpl[K, V]) WithMaxKeys(maxKeys int) Cache[K, V] { 22 | c.maxKeys = maxKeys 23 | return c 24 | } 25 | 26 | // WithLRU sets cache to LRU (Least Recently Used) eviction mode. 27 | func (c *cacheImpl[K, V]) WithLRU() Cache[K, V] { 28 | c.isLRU = true 29 | return c 30 | } 31 | 32 | // WithOnEvicted defined function which would be called automatically for automatically and manually deleted entries 33 | func (c *cacheImpl[K, V]) WithOnEvicted(fn func(key K, value V)) Cache[K, V] { 34 | c.onEvicted = fn 35 | return c 36 | } 37 | -------------------------------------------------------------------------------- /v3/options.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "time" 4 | 5 | type options[K comparable, V any] interface { 6 | WithTTL(ttl time.Duration) Cache[K, V] 7 | WithMaxKeys(maxKeys int) Cache[K, V] 8 | WithLRU() Cache[K, V] 9 | WithOnEvicted(fn func(key K, value V)) Cache[K, V] 10 | } 11 | 12 | // WithTTL functional option defines TTL for all cache entries. 13 | // By default, it is set to 10 years, sane option for expirable cache might be 5 minutes. 14 | func (c *cacheImpl[K, V]) WithTTL(ttl time.Duration) Cache[K, V] { 15 | c.ttl = ttl 16 | return c 17 | } 18 | 19 | // WithMaxKeys functional option defines how many keys to keep. 20 | // By default, it is 0, which means unlimited. 21 | func (c *cacheImpl[K, V]) WithMaxKeys(maxKeys int) Cache[K, V] { 22 | c.maxKeys = maxKeys 23 | return c 24 | } 25 | 26 | // WithLRU sets cache to LRU (Least Recently Used) eviction mode. 27 | func (c *cacheImpl[K, V]) WithLRU() Cache[K, V] { 28 | c.isLRU = true 29 | return c 30 | } 31 | 32 | // WithOnEvicted defined function which would be called automatically for automatically and manually deleted entries 33 | func (c *cacheImpl[K, V]) WithOnEvicted(fn func(key K, value V)) Cache[K, V] { 34 | c.onEvicted = fn 35 | return c 36 | } 37 | -------------------------------------------------------------------------------- /v3/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 4 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 8 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 9 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 10 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | paths-ignore: 8 | - ".github/workflows/ci-v2.yml" 9 | - ".github/workflows/ci-v3.yml" 10 | - "v2/**" 11 | - "v3/**" 12 | pull_request: 13 | paths-ignore: 14 | - ".github/workflows/ci-v2.yml" 15 | - ".github/workflows/ci-v3.yml" 16 | - "v2/**" 17 | - "v3/**" 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: set up go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: "1.20" 31 | id: go 32 | 33 | - name: build and test 34 | run: | 35 | go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov 36 | go build -race 37 | 38 | - name: golangci-lint 39 | uses: golangci/golangci-lint-action@v7 40 | with: 41 | version: v2.6 42 | 43 | - name: install goveralls, submit coverage 44 | run: | 45 | go install github.com/mattn/goveralls@latest 46 | goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 47 | env: 48 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/ci-v2.yml: -------------------------------------------------------------------------------- 1 | name: build-v2 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | paths: 8 | - ".github/workflows/ci-v2.yml" 9 | - "v2/**" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/ci-v2.yml" 13 | - "v2/**" 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: set up go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: "1.20" 27 | cache-dependency-path: v2/go.sum 28 | id: go 29 | 30 | - name: build and test 31 | run: | 32 | go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov 33 | go build -race 34 | working-directory: v2 35 | 36 | - name: golangci-lint 37 | uses: golangci/golangci-lint-action@v7 38 | with: 39 | version: v2.6 40 | args: --config ../.golangci.yml 41 | working-directory: v2 42 | 43 | - name: install goveralls, submit coverage 44 | run: | 45 | go install github.com/mattn/goveralls@latest 46 | goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 47 | env: 48 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | working-directory: v2 50 | -------------------------------------------------------------------------------- /.github/workflows/ci-v3.yml: -------------------------------------------------------------------------------- 1 | name: build-v3 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | paths: 8 | - ".github/workflows/ci-v3.yml" 9 | - "v3/**" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/ci-v3.yml" 13 | - "v3/**" 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: set up go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: "1.20" 27 | cache-dependency-path: v3/go.sum 28 | id: go 29 | 30 | - name: build and test 31 | run: | 32 | go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov 33 | go build -race 34 | working-directory: v3 35 | 36 | - name: golangci-lint 37 | uses: golangci/golangci-lint-action@v7 38 | with: 39 | version: v2.6 40 | args: --config ../.golangci.yml 41 | working-directory: v3 42 | 43 | - name: install goveralls, submit coverage 44 | run: | 45 | go install github.com/mattn/goveralls@latest 46 | goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 47 | env: 48 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | working-directory: v3 50 | -------------------------------------------------------------------------------- /benchmarks/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 2 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 6 | github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 7 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= 8 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 9 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 10 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 11 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 12 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 13 | github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= 14 | github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= 15 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 16 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 17 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 22 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 24 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 25 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 26 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 27 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 28 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # expirable-cache benchmarks 2 | 3 | This directory contains comprehensive benchmarks comparing performance across different caching libraries for Go. 4 | 5 | ## Libraries Compared 6 | 7 | 1. **[go-pkgz/expirable-cache](https://github.com/go-pkgz/expirable-cache)** (v3) - This library, uses generics and LRU/LRC eviction 8 | 2. **[patrickmn/go-cache](https://github.com/patrickmn/go-cache)** - Lightweight in-memory key:value store/cache with expiration support 9 | 3. **[jellydator/ttlcache](https://github.com/jellydator/ttlcache)** - An in-memory cache with expiration 10 | 4. **[dgraph-io/ristretto](https://github.com/dgraph-io/ristretto)** - A high performance memory-bound Go cache from Dgraph 11 | 12 | ## Benchmark Results 13 | 14 | Here are the results from running the benchmarks on an Apple M3 processor: 15 | 16 | ``` 17 | $ go test -bench=. -benchmem 18 | goos: darwin 19 | goarch: arm64 20 | pkg: github.com/go-pkgz/expirable-cache/benchmarks 21 | cpu: Apple M3 22 | BenchmarkGoCache_Set-8 12169099 82.67 ns/op 68 B/op 1 allocs/op 23 | BenchmarkGoCache_Get-8 18444500 63.81 ns/op 3 B/op 0 allocs/op 24 | BenchmarkGoCache_SetAndGet-8 17692840 67.94 ns/op 36 B/op 1 allocs/op 25 | BenchmarkTTLCache_Set-8 2707100 448.8 ns/op 5 B/op 0 allocs/op 26 | BenchmarkTTLCache_Get-8 6269760 190.9 ns/op 51 B/op 1 allocs/op 27 | BenchmarkTTLCache_SetAndGet-8 4806730 253.9 ns/op 28 B/op 1 allocs/op 28 | BenchmarkExpirableCache_Set-8 17192541 69.14 ns/op 4 B/op 0 allocs/op 29 | BenchmarkExpirableCache_Get-8 15239731 78.12 ns/op 3 B/op 0 allocs/op 30 | BenchmarkExpirableCache_SetAndGet-8 17954317 66.62 ns/op 4 B/op 0 allocs/op 31 | BenchmarkRistretto_Set-8 1456555 820.0 ns/op 262 B/op 5 allocs/op 32 | BenchmarkRistretto_Get-8 14321246 84.23 ns/op 27 B/op 2 allocs/op 33 | BenchmarkRistretto_SetAndGet-8 5989928 198.2 ns/op 96 B/op 2 allocs/op 34 | BenchmarkGoCache_GetWithTypeAssertion-8 17971783 66.44 ns/op 3 B/op 0 allocs/op 35 | BenchmarkTTLCache_GetWithoutTypeAssertion-8 6114782 197.9 ns/op 51 B/op 1 allocs/op 36 | BenchmarkExpirableCache_GetWithoutTypeAssertion-8 15095240 78.39 ns/op 3 B/op 0 allocs/op 37 | BenchmarkRistretto_GetWithTypeAssertion-8 14458339 83.09 ns/op 27 B/op 2 allocs/op 38 | BenchmarkGoCache_RealWorldScenario-8 16622580 70.24 ns/op 4 B/op 0 allocs/op 39 | BenchmarkTTLCache_RealWorldScenario-8 6038209 198.0 ns/op 52 B/op 1 allocs/op 40 | BenchmarkExpirableCache_RealWorldScenario-8 14718102 78.83 ns/op 3 B/op 0 allocs/op 41 | BenchmarkRistretto_RealWorldScenario-8 13030276 83.40 ns/op 28 B/op 2 allocs/op 42 | ``` 43 | 44 | ## Summary of Results 45 | 46 | | Operation | [go-pkgz/expirable-cache](https://github.com/go-pkgz/expirable-cache) | [patrickmn/go-cache](https://github.com/patrickmn/go-cache) | [jellydator/ttlcache](https://github.com/jellydator/ttlcache) | [dgraph-io/ristretto](https://github.com/dgraph-io/ristretto) | 47 | |-----------|-----------------|----------|----------|-----------| 48 | | Set | 69.14 ns/op | 82.67 ns/op | 448.8 ns/op | 820.0 ns/op | 49 | | Get | 78.12 ns/op | 63.81 ns/op | 190.9 ns/op | 84.23 ns/op | 50 | | Set+Get | 66.62 ns/op | 67.94 ns/op | 253.9 ns/op | 198.2 ns/op | 51 | | Real-world scenario | 78.83 ns/op | 70.24 ns/op | 198.0 ns/op | 83.40 ns/op | 52 | | Memory allocations (Set) | 4 B/op | 68 B/op | 5 B/op | 262 B/op | 53 | | Memory allocations (Get) | 3 B/op | 3 B/op | 51 B/op | 27 B/op | 54 | 55 | ## Analysis 56 | 57 | 1. **[go-pkgz/expirable-cache](https://github.com/go-pkgz/expirable-cache)**: 58 | - Best overall balance of performance and features 59 | - Fastest Set operations among all libraries 60 | - Very competitive Get operations 61 | - Lowest memory usage across all benchmarks 62 | - Type safety through generics 63 | - Clean API with method chaining 64 | 65 | 2. **[patrickmn/go-cache](https://github.com/patrickmn/go-cache)**: 66 | - Fastest Get operations 67 | - Very competitive overall performance 68 | - However, it's known to leak goroutines and lacks modern features 69 | - Higher memory usage for Set operations than expirable-cache 70 | 71 | 3. **[dgraph-io/ristretto](https://github.com/dgraph-io/ristretto)**: 72 | - Excellent for read-heavy workloads 73 | - Much higher memory usage than other libraries 74 | - Considerably slower Set operations 75 | - Best suited for very large caches where sophisticated memory management is beneficial 76 | 77 | 4. **[jellydator/ttlcache](https://github.com/jellydator/ttlcache)**: 78 | - Significantly slower than other libraries for all operations 79 | - Higher memory usage for Get operations 80 | - Not recommended for performance-critical applications 81 | 82 | Thanks to [@analytically](https://github.com/analytically) for the benchmark code and initial analysis! 83 | 84 | ## Running the Benchmarks 85 | 86 | To run the benchmarks yourself: 87 | 88 | ```bash 89 | go test -bench=. -benchmem 90 | ``` 91 | 92 | For more focused testing: 93 | 94 | ```bash 95 | # Test only Set operations 96 | go test -bench=Set -benchmem 97 | 98 | # Test only expirable-cache 99 | go test -bench=ExpirableCache -benchmem 100 | 101 | # Test only real-world scenarios 102 | go test -bench=RealWorldScenario -benchmem 103 | ``` -------------------------------------------------------------------------------- /v2/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache implements Cache similar to hashicorp/golang-lru 2 | // 3 | // Support LRC, LRU and TTL-based eviction. 4 | // Package is thread-safe and doesn't spawn any goroutines. 5 | // On every Set() call, cache deletes single oldest entry in case it's expired. 6 | // In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size, 7 | // either using LRC or LRU eviction. 8 | // In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited 9 | // and will never delete entries from itself automatically. 10 | // 11 | // Important: only reliable way of not having expired entries stuck in a cache is to 12 | // run cache.DeleteExpired periodically using time.Ticker, advisable period is 1/2 of TTL. 13 | package cache 14 | 15 | import ( 16 | "container/list" 17 | "fmt" 18 | "sync" 19 | "time" 20 | ) 21 | 22 | // Cache defines cache interface 23 | type Cache[K comparable, V any] interface { 24 | fmt.Stringer 25 | options[K, V] 26 | Set(key K, value V, ttl time.Duration) 27 | Get(key K) (V, bool) 28 | Peek(key K) (V, bool) 29 | Keys() []K 30 | Len() int 31 | Invalidate(key K) 32 | InvalidateFn(fn func(key K) bool) 33 | RemoveOldest() 34 | DeleteExpired() 35 | Purge() 36 | Stat() Stats 37 | } 38 | 39 | // Stats provides statistics for cache 40 | type Stats struct { 41 | Hits, Misses int // cache effectiveness 42 | Added, Evicted int // number of added and evicted records 43 | } 44 | 45 | // cacheImpl provides Cache interface implementation. 46 | type cacheImpl[K comparable, V any] struct { 47 | ttl time.Duration 48 | maxKeys int 49 | isLRU bool 50 | onEvicted func(key K, value V) 51 | 52 | sync.Mutex 53 | stat Stats 54 | items map[K]*list.Element 55 | evictList *list.List 56 | } 57 | 58 | // noEvictionTTL - very long ttl to prevent eviction 59 | const noEvictionTTL = time.Hour * 24 * 365 * 10 60 | 61 | // NewCache returns a new Cache. 62 | // Default MaxKeys is unlimited (0). 63 | // Default TTL is 10 years, sane value for expirable cache is 5 minutes. 64 | // Default eviction mode is LRC, appropriate option allow to change it to LRU. 65 | func NewCache[K comparable, V any]() Cache[K, V] { 66 | return &cacheImpl[K, V]{ 67 | items: map[K]*list.Element{}, 68 | evictList: list.New(), 69 | ttl: noEvictionTTL, 70 | maxKeys: 0, 71 | } 72 | } 73 | 74 | // Set key, ttl of 0 would use cache-wide TTL 75 | func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) { 76 | if ttl == 0 { 77 | ttl = c.ttl 78 | } 79 | now := time.Now() 80 | c.Lock() 81 | defer c.Unlock() 82 | 83 | // Check for existing item 84 | if ent, ok := c.items[key]; ok { 85 | c.evictList.MoveToFront(ent) 86 | ent.Value.(*cacheItem[K, V]).value = value 87 | ent.Value.(*cacheItem[K, V]).expiresAt = now.Add(ttl) 88 | return 89 | } 90 | 91 | // Add new item 92 | ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now.Add(ttl)} 93 | entry := c.evictList.PushFront(ent) 94 | c.items[key] = entry 95 | c.stat.Added++ 96 | 97 | // Remove oldest entry if it is expired, only in case of non-default TTL. 98 | if c.ttl != noEvictionTTL || ttl != noEvictionTTL { 99 | ent := c.evictList.Back() 100 | if ent != nil && now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { 101 | c.removeElement(ent) 102 | } 103 | } 104 | 105 | // Verify size not exceeded 106 | if c.maxKeys > 0 && len(c.items) > c.maxKeys { 107 | c.removeOldest() 108 | } 109 | } 110 | 111 | // Get returns the key value if it's not expired 112 | func (c *cacheImpl[K, V]) Get(key K) (V, bool) { 113 | def := *new(V) 114 | c.Lock() 115 | defer c.Unlock() 116 | if ent, ok := c.items[key]; ok { 117 | // Expired item check 118 | if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { 119 | c.stat.Misses++ 120 | return def, false 121 | } 122 | if c.isLRU { 123 | c.evictList.MoveToFront(ent) 124 | } 125 | c.stat.Hits++ 126 | return ent.Value.(*cacheItem[K, V]).value, true 127 | } 128 | c.stat.Misses++ 129 | return def, false 130 | } 131 | 132 | // Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key. 133 | // Works exactly the same as Get in case of LRC mode (default one). 134 | func (c *cacheImpl[K, V]) Peek(key K) (V, bool) { 135 | def := *new(V) 136 | c.Lock() 137 | defer c.Unlock() 138 | if ent, ok := c.items[key]; ok { 139 | // Expired item check 140 | if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { 141 | c.stat.Misses++ 142 | return def, false 143 | } 144 | c.stat.Hits++ 145 | return ent.Value.(*cacheItem[K, V]).value, true 146 | } 147 | c.stat.Misses++ 148 | return def, false 149 | } 150 | 151 | // Keys returns a slice of the keys in the cache, from oldest to newest. 152 | func (c *cacheImpl[K, V]) Keys() []K { 153 | c.Lock() 154 | defer c.Unlock() 155 | return c.keys() 156 | } 157 | 158 | // Len return count of items in cache, including expired 159 | func (c *cacheImpl[K, V]) Len() int { 160 | c.Lock() 161 | defer c.Unlock() 162 | return c.evictList.Len() 163 | } 164 | 165 | // Invalidate key (item) from the cache 166 | func (c *cacheImpl[K, V]) Invalidate(key K) { 167 | c.Lock() 168 | defer c.Unlock() 169 | if ent, ok := c.items[key]; ok { 170 | c.removeElement(ent) 171 | } 172 | } 173 | 174 | // InvalidateFn deletes multiple keys if predicate is true 175 | func (c *cacheImpl[K, V]) InvalidateFn(fn func(key K) bool) { 176 | c.Lock() 177 | defer c.Unlock() 178 | for key, ent := range c.items { 179 | if fn(key) { 180 | c.removeElement(ent) 181 | } 182 | } 183 | } 184 | 185 | // RemoveOldest remove oldest element in the cache 186 | func (c *cacheImpl[K, V]) RemoveOldest() { 187 | c.Lock() 188 | defer c.Unlock() 189 | c.removeOldest() 190 | } 191 | 192 | // DeleteExpired clears cache of expired items 193 | func (c *cacheImpl[K, V]) DeleteExpired() { 194 | now := time.Now() 195 | c.Lock() 196 | defer c.Unlock() 197 | var nextEnt *list.Element 198 | for ent := c.evictList.Back(); ent != nil; ent = nextEnt { 199 | nextEnt = ent.Prev() 200 | if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { 201 | c.removeElement(ent) 202 | } 203 | } 204 | } 205 | 206 | // Purge clears the cache completely. 207 | func (c *cacheImpl[K, V]) Purge() { 208 | c.Lock() 209 | defer c.Unlock() 210 | for k, v := range c.items { 211 | delete(c.items, k) 212 | c.stat.Evicted++ 213 | if c.onEvicted != nil { 214 | c.onEvicted(k, v.Value.(*cacheItem[K, V]).value) 215 | } 216 | } 217 | c.evictList.Init() 218 | } 219 | 220 | // Stat gets the current stats for cache 221 | func (c *cacheImpl[K, V]) Stat() Stats { 222 | c.Lock() 223 | defer c.Unlock() 224 | return c.stat 225 | } 226 | 227 | func (c *cacheImpl[K, V]) String() string { 228 | stats := c.Stat() 229 | size := c.Len() 230 | return fmt.Sprintf("Size: %d, Stats: %+v (%0.1f%%)", size, stats, 100*float64(stats.Hits)/float64(stats.Hits+stats.Misses)) 231 | } 232 | 233 | // Keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! 234 | func (c *cacheImpl[K, V]) keys() []K { 235 | keys := make([]K, 0, len(c.items)) 236 | for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { 237 | keys = append(keys, ent.Value.(*cacheItem[K, V]).key) 238 | } 239 | return keys 240 | } 241 | 242 | // removeOldest removes the oldest item from the cache. Has to be called with lock! 243 | func (c *cacheImpl[K, V]) removeOldest() { 244 | ent := c.evictList.Back() 245 | if ent != nil { 246 | c.removeElement(ent) 247 | } 248 | } 249 | 250 | 251 | // removeElement is used to remove a given list element from the cache. Has to be called with lock! 252 | func (c *cacheImpl[K, V]) removeElement(e *list.Element) { 253 | c.evictList.Remove(e) 254 | kv := e.Value.(*cacheItem[K, V]) 255 | delete(c.items, kv.key) 256 | c.stat.Evicted++ 257 | if c.onEvicted != nil { 258 | c.onEvicted(kv.key, kv.value) 259 | } 260 | } 261 | 262 | // cacheItem is used to hold a value in the evictList 263 | type cacheItem[K comparable, V any] struct { 264 | expiresAt time.Time 265 | key K 266 | value V 267 | } 268 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | // Package cache implements Cache similar to hashicorp/golang-lru 2 | // 3 | // Support LRC, LRU and TTL-based eviction. 4 | // Package is thread-safe and doesn't spawn any goroutines. 5 | // On every Set() call, cache deletes single oldest entry in case it's expired. 6 | // In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size, 7 | // either using LRC or LRU eviction. 8 | // In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited 9 | // and will never delete entries from itself automatically. 10 | // 11 | // Important: only reliable way of not having expired entries stuck in a cache is to 12 | // run cache.DeleteExpired periodically using time.Ticker, advisable period is 1/2 of TTL. 13 | package cache 14 | 15 | import ( 16 | "container/list" 17 | "fmt" 18 | "sync" 19 | "time" 20 | ) 21 | 22 | // Cache defines cache interface 23 | type Cache interface { 24 | fmt.Stringer 25 | Set(key string, value interface{}, ttl time.Duration) 26 | Get(key string) (interface{}, bool) 27 | Peek(key string) (interface{}, bool) 28 | Keys() []string 29 | Len() int 30 | Invalidate(key string) 31 | InvalidateFn(fn func(key string) bool) 32 | RemoveOldest() 33 | DeleteExpired() 34 | Purge() 35 | Stat() Stats 36 | } 37 | 38 | // Stats provides statistics for cache 39 | type Stats struct { 40 | Hits, Misses int // cache effectiveness 41 | Added, Evicted int // number of added and evicted records 42 | } 43 | 44 | // cacheImpl provides Cache interface implementation. 45 | type cacheImpl struct { 46 | ttl time.Duration 47 | maxKeys int 48 | isLRU bool 49 | onEvicted func(key string, value interface{}) 50 | 51 | sync.Mutex 52 | stat Stats 53 | items map[string]*list.Element 54 | evictList *list.List 55 | } 56 | 57 | // noEvictionTTL - very long ttl to prevent eviction 58 | const noEvictionTTL = time.Hour * 24 * 365 * 10 59 | 60 | // NewCache returns a new Cache. 61 | // Default MaxKeys is unlimited (0). 62 | // Default TTL is 10 years, sane value for expirable cache is 5 minutes. 63 | // Default eviction mode is LRC, appropriate option allow to change it to LRU. 64 | func NewCache(options ...Option) (Cache, error) { 65 | res := cacheImpl{ 66 | items: map[string]*list.Element{}, 67 | evictList: list.New(), 68 | ttl: noEvictionTTL, 69 | maxKeys: 0, 70 | } 71 | 72 | for _, opt := range options { 73 | if err := opt(&res); err != nil { 74 | return nil, fmt.Errorf("failed to set cache option: %w", err) 75 | } 76 | } 77 | return &res, nil 78 | } 79 | 80 | // Set key, ttl of 0 would use cache-wide TTL 81 | func (c *cacheImpl) Set(key string, value interface{}, ttl time.Duration) { 82 | if ttl == 0 { 83 | ttl = c.ttl 84 | } 85 | now := time.Now() 86 | c.Lock() 87 | defer c.Unlock() 88 | 89 | // Check for existing item 90 | if ent, ok := c.items[key]; ok { 91 | c.evictList.MoveToFront(ent) 92 | ent.Value.(*cacheItem).value = value 93 | ent.Value.(*cacheItem).expiresAt = now.Add(ttl) 94 | return 95 | } 96 | 97 | // Add new item 98 | ent := &cacheItem{key: key, value: value, expiresAt: now.Add(ttl)} 99 | entry := c.evictList.PushFront(ent) 100 | c.items[key] = entry 101 | c.stat.Added++ 102 | 103 | // Remove oldest entry if it is expired, only in case of non-default TTL. 104 | if c.ttl != noEvictionTTL || ttl != noEvictionTTL { 105 | ent := c.evictList.Back() 106 | if ent != nil && now.After(ent.Value.(*cacheItem).expiresAt) { 107 | c.removeElement(ent) 108 | } 109 | } 110 | 111 | // Verify size not exceeded 112 | if c.maxKeys > 0 && len(c.items) > c.maxKeys { 113 | c.removeOldest() 114 | } 115 | } 116 | 117 | // Get returns the key value if it's not expired 118 | func (c *cacheImpl) Get(key string) (interface{}, bool) { 119 | c.Lock() 120 | defer c.Unlock() 121 | if ent, ok := c.items[key]; ok { 122 | // Expired item check 123 | if time.Now().After(ent.Value.(*cacheItem).expiresAt) { 124 | c.stat.Misses++ 125 | return nil, false 126 | } 127 | if c.isLRU { 128 | c.evictList.MoveToFront(ent) 129 | } 130 | c.stat.Hits++ 131 | return ent.Value.(*cacheItem).value, true 132 | } 133 | c.stat.Misses++ 134 | return nil, false 135 | } 136 | 137 | // Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key. 138 | // Works exactly the same as Get in case of LRC mode (default one). 139 | func (c *cacheImpl) Peek(key string) (interface{}, bool) { 140 | c.Lock() 141 | defer c.Unlock() 142 | if ent, ok := c.items[key]; ok { 143 | // Expired item check 144 | if time.Now().After(ent.Value.(*cacheItem).expiresAt) { 145 | c.stat.Misses++ 146 | return nil, false 147 | } 148 | c.stat.Hits++ 149 | return ent.Value.(*cacheItem).value, true 150 | } 151 | c.stat.Misses++ 152 | return nil, false 153 | } 154 | 155 | // Keys returns a slice of the keys in the cache, from oldest to newest. 156 | func (c *cacheImpl) Keys() []string { 157 | c.Lock() 158 | defer c.Unlock() 159 | return c.keys() 160 | } 161 | 162 | // Len return count of items in cache, including expired 163 | func (c *cacheImpl) Len() int { 164 | c.Lock() 165 | defer c.Unlock() 166 | return c.evictList.Len() 167 | } 168 | 169 | // Invalidate key (item) from the cache 170 | func (c *cacheImpl) Invalidate(key string) { 171 | c.Lock() 172 | defer c.Unlock() 173 | if ent, ok := c.items[key]; ok { 174 | c.removeElement(ent) 175 | } 176 | } 177 | 178 | // InvalidateFn deletes multiple keys if predicate is true 179 | func (c *cacheImpl) InvalidateFn(fn func(key string) bool) { 180 | c.Lock() 181 | defer c.Unlock() 182 | for key, ent := range c.items { 183 | if fn(key) { 184 | c.removeElement(ent) 185 | } 186 | } 187 | } 188 | 189 | // RemoveOldest remove oldest element in the cache 190 | func (c *cacheImpl) RemoveOldest() { 191 | c.Lock() 192 | defer c.Unlock() 193 | c.removeOldest() 194 | } 195 | 196 | // DeleteExpired clears cache of expired items 197 | func (c *cacheImpl) DeleteExpired() { 198 | now := time.Now() 199 | c.Lock() 200 | defer c.Unlock() 201 | var nextEnt *list.Element 202 | for ent := c.evictList.Back(); ent != nil; ent = nextEnt { 203 | nextEnt = ent.Prev() 204 | if now.After(ent.Value.(*cacheItem).expiresAt) { 205 | c.removeElement(ent) 206 | } 207 | } 208 | } 209 | 210 | // Purge clears the cache completely. 211 | func (c *cacheImpl) Purge() { 212 | c.Lock() 213 | defer c.Unlock() 214 | for k, v := range c.items { 215 | delete(c.items, k) 216 | c.stat.Evicted++ 217 | if c.onEvicted != nil { 218 | c.onEvicted(k, v.Value.(*cacheItem).value) 219 | } 220 | } 221 | c.evictList.Init() 222 | } 223 | 224 | // Stat gets the current stats for cache 225 | func (c *cacheImpl) Stat() Stats { 226 | c.Lock() 227 | defer c.Unlock() 228 | return c.stat 229 | } 230 | 231 | func (c *cacheImpl) String() string { 232 | stats := c.Stat() 233 | size := c.Len() 234 | return fmt.Sprintf("Size: %d, Stats: %+v (%0.1f%%)", size, stats, 100*float64(stats.Hits)/float64(stats.Hits+stats.Misses)) 235 | } 236 | 237 | // Keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! 238 | func (c *cacheImpl) keys() []string { 239 | keys := make([]string, 0, len(c.items)) 240 | for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { 241 | keys = append(keys, ent.Value.(*cacheItem).key) 242 | } 243 | return keys 244 | } 245 | 246 | // removeOldest removes the oldest item from the cache. Has to be called with lock! 247 | func (c *cacheImpl) removeOldest() { 248 | ent := c.evictList.Back() 249 | if ent != nil { 250 | c.removeElement(ent) 251 | } 252 | } 253 | 254 | 255 | // removeElement is used to remove a given list element from the cache. Has to be called with lock! 256 | func (c *cacheImpl) removeElement(e *list.Element) { 257 | c.evictList.Remove(e) 258 | kv := e.Value.(*cacheItem) 259 | delete(c.items, kv.key) 260 | c.stat.Evicted++ 261 | if c.onEvicted != nil { 262 | c.onEvicted(kv.key, kv.value) 263 | } 264 | } 265 | 266 | // cacheItem is used to hold a value in the evictList 267 | type cacheItem struct { 268 | expiresAt time.Time 269 | key string 270 | value interface{} 271 | } 272 | -------------------------------------------------------------------------------- /v2/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | "math/big" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func getRand(tb testing.TB) int64 { 16 | out, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) 17 | if err != nil { 18 | tb.Fatal(err) 19 | } 20 | return out.Int64() 21 | } 22 | 23 | func BenchmarkLRU_Rand_NoExpire(b *testing.B) { 24 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) 25 | 26 | trace := make([]int64, b.N*2) 27 | for i := 0; i < b.N*2; i++ { 28 | trace[i] = getRand(b) % 32768 29 | } 30 | 31 | b.ResetTimer() 32 | 33 | var hit, miss int 34 | for i := 0; i < 2*b.N; i++ { 35 | if i%2 == 0 { 36 | l.Set(trace[i], trace[i], 0) 37 | } else { 38 | if _, ok := l.Get(trace[i]); ok { 39 | hit++ 40 | } else { 41 | miss++ 42 | } 43 | } 44 | } 45 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 46 | } 47 | 48 | func BenchmarkLRU_Freq_NoExpire(b *testing.B) { 49 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) 50 | 51 | trace := make([]int64, b.N*2) 52 | for i := 0; i < b.N*2; i++ { 53 | if i%2 == 0 { 54 | trace[i] = getRand(b) % 16384 55 | } else { 56 | trace[i] = getRand(b) % 32768 57 | } 58 | } 59 | 60 | b.ResetTimer() 61 | 62 | for i := 0; i < b.N; i++ { 63 | l.Set(trace[i], trace[i], 0) 64 | } 65 | var hit, miss int 66 | for i := 0; i < b.N; i++ { 67 | if _, ok := l.Get(trace[i]); ok { 68 | hit++ 69 | } else { 70 | miss++ 71 | } 72 | } 73 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 74 | } 75 | 76 | func BenchmarkLRU_Rand_WithExpire(b *testing.B) { 77 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) 78 | 79 | trace := make([]int64, b.N*2) 80 | for i := 0; i < b.N*2; i++ { 81 | trace[i] = getRand(b) % 32768 82 | } 83 | 84 | b.ResetTimer() 85 | 86 | var hit, miss int 87 | for i := 0; i < 2*b.N; i++ { 88 | if i%2 == 0 { 89 | l.Set(trace[i], trace[i], 0) 90 | } else { 91 | if _, ok := l.Get(trace[i]); ok { 92 | hit++ 93 | } else { 94 | miss++ 95 | } 96 | } 97 | } 98 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 99 | } 100 | 101 | func BenchmarkLRU_Freq_WithExpire(b *testing.B) { 102 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) 103 | 104 | trace := make([]int64, b.N*2) 105 | for i := 0; i < b.N*2; i++ { 106 | if i%2 == 0 { 107 | trace[i] = getRand(b) % 16384 108 | } else { 109 | trace[i] = getRand(b) % 32768 110 | } 111 | } 112 | 113 | b.ResetTimer() 114 | 115 | for i := 0; i < b.N; i++ { 116 | l.Set(trace[i], trace[i], 0) 117 | } 118 | var hit, miss int 119 | for i := 0; i < b.N; i++ { 120 | if _, ok := l.Get(trace[i]); ok { 121 | hit++ 122 | } else { 123 | miss++ 124 | } 125 | } 126 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 127 | } 128 | 129 | func TestCacheNoPurge(t *testing.T) { 130 | lc := NewCache[string, string]() 131 | 132 | lc.Set("key1", "val1", 0) 133 | assert.Equal(t, 1, lc.Len()) 134 | 135 | v, ok := lc.Peek("key1") 136 | assert.Equal(t, "val1", v) 137 | assert.True(t, ok) 138 | 139 | v, ok = lc.Peek("key2") 140 | assert.Empty(t, v) 141 | assert.False(t, ok) 142 | 143 | assert.Equal(t, []string{"key1"}, lc.Keys()) 144 | } 145 | 146 | func TestCacheWithDeleteExpired(t *testing.T) { 147 | var evicted []string 148 | lc := NewCache[string, string]().WithTTL(150 * time.Millisecond).WithOnEvicted( 149 | func(key string, value string) { 150 | evicted = append(evicted, key, value) 151 | }) 152 | 153 | lc.Set("key1", "val1", 0) 154 | 155 | time.Sleep(100 * time.Millisecond) // not enough to expire 156 | lc.DeleteExpired() 157 | assert.Equal(t, 1, lc.Len()) 158 | 159 | v, ok := lc.Get("key1") 160 | assert.Equal(t, "val1", v) 161 | assert.True(t, ok) 162 | 163 | time.Sleep(200 * time.Millisecond) // expire 164 | lc.DeleteExpired() 165 | v, ok = lc.Get("key1") 166 | assert.False(t, ok) 167 | assert.Equal(t, "", v) 168 | 169 | assert.Equal(t, 0, lc.Len()) 170 | assert.Equal(t, []string{"key1", "val1"}, evicted) 171 | 172 | // add new entry 173 | lc.Set("key2", "val2", 0) 174 | assert.Equal(t, 1, lc.Len()) 175 | 176 | // nothing deleted 177 | lc.DeleteExpired() 178 | assert.Equal(t, 1, lc.Len()) 179 | assert.Equal(t, []string{"key1", "val1"}, evicted) 180 | 181 | // Purge, cache should be clean 182 | lc.Purge() 183 | assert.Equal(t, 0, lc.Len()) 184 | assert.Equal(t, []string{"key1", "val1", "key2", "val2"}, evicted) 185 | } 186 | 187 | func TestCacheWithPurgeEnforcedBySize(t *testing.T) { 188 | lc := NewCache[string, string]().WithTTL(time.Hour).WithMaxKeys(10) 189 | 190 | for i := 0; i < 100; i++ { 191 | i := i 192 | lc.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i), 0) 193 | v, ok := lc.Get(fmt.Sprintf("key%d", i)) 194 | assert.Equal(t, fmt.Sprintf("val%d", i), v) 195 | assert.True(t, ok) 196 | assert.True(t, lc.Len() < 20) 197 | } 198 | 199 | assert.Equal(t, 10, lc.Len()) 200 | } 201 | 202 | func TestCacheConcurrency(t *testing.T) { 203 | lc := NewCache[string, string]() 204 | wg := sync.WaitGroup{} 205 | wg.Add(1000) 206 | for i := 0; i < 1000; i++ { 207 | go func(i int) { 208 | lc.Set(fmt.Sprintf("key-%d", i/10), fmt.Sprintf("val-%d", i/10), 0) 209 | wg.Done() 210 | }(i) 211 | } 212 | wg.Wait() 213 | assert.Equal(t, 100, lc.Len()) 214 | } 215 | 216 | func TestCacheInvalidateAndEvict(t *testing.T) { 217 | var evicted int 218 | lc := NewCache[string, string]().WithLRU().WithOnEvicted(func(_ string, _ string) { evicted++ }) 219 | 220 | lc.Set("key1", "val1", 0) 221 | lc.Set("key2", "val2", 0) 222 | 223 | val, ok := lc.Get("key1") 224 | assert.True(t, ok) 225 | assert.Equal(t, "val1", val) 226 | assert.Equal(t, 0, evicted) 227 | 228 | lc.Invalidate("key1") 229 | assert.Equal(t, 1, evicted) 230 | val, ok = lc.Get("key1") 231 | assert.Empty(t, val) 232 | assert.False(t, ok) 233 | 234 | val, ok = lc.Get("key2") 235 | assert.True(t, ok) 236 | assert.Equal(t, "val2", val) 237 | 238 | lc.InvalidateFn(func(key string) bool { 239 | return key == "key2" 240 | }) 241 | assert.Equal(t, 2, evicted) 242 | _, ok = lc.Get("key2") 243 | assert.False(t, ok) 244 | assert.Equal(t, 0, lc.Len()) 245 | } 246 | 247 | func TestCacheExpired(t *testing.T) { 248 | lc := NewCache[string, string]().WithTTL(time.Millisecond * 5) 249 | 250 | lc.Set("key1", "val1", 0) 251 | assert.Equal(t, 1, lc.Len()) 252 | 253 | v, ok := lc.Peek("key1") 254 | assert.Equal(t, v, "val1") 255 | assert.True(t, ok) 256 | 257 | v, ok = lc.Get("key1") 258 | assert.Equal(t, v, "val1") 259 | assert.True(t, ok) 260 | 261 | time.Sleep(time.Millisecond * 10) // wait for entry to expire 262 | assert.Equal(t, 1, lc.Len()) // but not purged 263 | 264 | v, ok = lc.Peek("key1") 265 | assert.Empty(t, v) 266 | assert.False(t, ok) 267 | 268 | v, ok = lc.Get("key1") 269 | assert.Empty(t, v) 270 | assert.False(t, ok) 271 | } 272 | 273 | func TestCacheRemoveOldest(t *testing.T) { 274 | lc := NewCache[string, string]().WithLRU().WithMaxKeys(2) 275 | 276 | lc.Set("key1", "val1", 0) 277 | assert.Equal(t, 1, lc.Len()) 278 | 279 | v, ok := lc.Get("key1") 280 | assert.True(t, ok) 281 | assert.Equal(t, "val1", v) 282 | 283 | assert.Equal(t, []string{"key1"}, lc.Keys()) 284 | assert.Equal(t, 1, lc.Len()) 285 | 286 | lc.Set("key2", "val2", 0) 287 | assert.Equal(t, []string{"key1", "key2"}, lc.Keys()) 288 | assert.Equal(t, 2, lc.Len()) 289 | 290 | lc.RemoveOldest() 291 | 292 | assert.Equal(t, []string{"key2"}, lc.Keys()) 293 | assert.Equal(t, 1, lc.Len()) 294 | } 295 | 296 | func ExampleCache() { 297 | // make cache with short TTL and 3 max keys 298 | cache := NewCache[string, string]().WithMaxKeys(3).WithTTL(time.Millisecond * 10) 299 | 300 | // set value under key1. 301 | // with 0 ttl (last parameter) will use cache-wide setting instead (10ms). 302 | cache.Set("key1", "val1", 0) 303 | 304 | // get value under key1 305 | r, ok := cache.Get("key1") 306 | 307 | // check for OK value, because otherwise return would be nil and 308 | // type conversion will panic 309 | if ok { 310 | fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r) 311 | } 312 | 313 | time.Sleep(time.Millisecond * 11) 314 | 315 | // get value under key1 after key expiration 316 | r, ok = cache.Get("key1") 317 | // don't convert to string as with ok == false value would be nil 318 | fmt.Printf("value after expiration is found: %v, value: %q\n", ok, r) 319 | 320 | // set value under key2, would evict old entry because it is already expired. 321 | // ttl (last parameter) overrides cache-wide ttl. 322 | cache.Set("key2", "val2", time.Minute*5) 323 | 324 | fmt.Printf("%+v\n", cache) 325 | // Output: 326 | // value before expiration is found: true, value: "val1" 327 | // value after expiration is found: false, value: "" 328 | // Size: 1, Stats: {Hits:1 Misses:1 Added:2 Evicted:1} (50.0%) 329 | } 330 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | "math/big" 8 | "strconv" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func getRand(tb testing.TB) int64 { 17 | out, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) 18 | if err != nil { 19 | tb.Fatal(err) 20 | } 21 | return out.Int64() 22 | } 23 | 24 | func BenchmarkLRU_Rand_NoExpire(b *testing.B) { 25 | l, _ := NewCache(MaxKeys(8192), LRU()) 26 | 27 | trace := make([]int64, b.N*2) 28 | for i := 0; i < b.N*2; i++ { 29 | trace[i] = getRand(b) % 32768 30 | } 31 | 32 | b.ResetTimer() 33 | 34 | var hit, miss int 35 | for i := 0; i < 2*b.N; i++ { 36 | if i%2 == 0 { 37 | l.Set(strconv.Itoa(int(trace[i])), trace[i], 0) 38 | } else { 39 | if _, ok := l.Get(strconv.Itoa(int(trace[i]))); ok { 40 | hit++ 41 | } else { 42 | miss++ 43 | } 44 | } 45 | } 46 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 47 | } 48 | 49 | func BenchmarkLRU_Freq_NoExpire(b *testing.B) { 50 | l, _ := NewCache(MaxKeys(8192), LRU()) 51 | 52 | trace := make([]int64, b.N*2) 53 | for i := 0; i < b.N*2; i++ { 54 | if i%2 == 0 { 55 | trace[i] = getRand(b) % 16384 56 | } else { 57 | trace[i] = getRand(b) % 32768 58 | } 59 | } 60 | 61 | b.ResetTimer() 62 | 63 | for i := 0; i < b.N; i++ { 64 | l.Set(strconv.Itoa(int(trace[i])), trace[i], 0) 65 | } 66 | var hit, miss int 67 | for i := 0; i < b.N; i++ { 68 | if _, ok := l.Get(strconv.Itoa(int(trace[i]))); ok { 69 | hit++ 70 | } else { 71 | miss++ 72 | } 73 | } 74 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 75 | } 76 | 77 | func BenchmarkLRU_Rand_WithExpire(b *testing.B) { 78 | l, _ := NewCache(MaxKeys(8192), LRU(), TTL(time.Millisecond*10)) 79 | 80 | trace := make([]int64, b.N*2) 81 | for i := 0; i < b.N*2; i++ { 82 | trace[i] = getRand(b) % 32768 83 | } 84 | 85 | b.ResetTimer() 86 | 87 | var hit, miss int 88 | for i := 0; i < 2*b.N; i++ { 89 | if i%2 == 0 { 90 | l.Set(strconv.Itoa(int(trace[i])), trace[i], 0) 91 | } else { 92 | if _, ok := l.Get(strconv.Itoa(int(trace[i]))); ok { 93 | hit++ 94 | } else { 95 | miss++ 96 | } 97 | } 98 | } 99 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 100 | } 101 | 102 | func BenchmarkLRU_Freq_WithExpire(b *testing.B) { 103 | l, _ := NewCache(MaxKeys(8192), LRU(), TTL(time.Millisecond*10)) 104 | 105 | trace := make([]int64, b.N*2) 106 | for i := 0; i < b.N*2; i++ { 107 | if i%2 == 0 { 108 | trace[i] = getRand(b) % 16384 109 | } else { 110 | trace[i] = getRand(b) % 32768 111 | } 112 | } 113 | 114 | b.ResetTimer() 115 | 116 | for i := 0; i < b.N; i++ { 117 | l.Set(strconv.Itoa(int(trace[i])), trace[i], 0) 118 | } 119 | var hit, miss int 120 | for i := 0; i < b.N; i++ { 121 | if _, ok := l.Get(strconv.Itoa(int(trace[i]))); ok { 122 | hit++ 123 | } else { 124 | miss++ 125 | } 126 | } 127 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 128 | } 129 | 130 | func TestCacheNoPurge(t *testing.T) { 131 | lc, err := NewCache() 132 | assert.NoError(t, err) 133 | 134 | lc.Set("key1", "val1", 0) 135 | assert.Equal(t, 1, lc.Len()) 136 | 137 | v, ok := lc.Peek("key1") 138 | assert.Equal(t, "val1", v) 139 | assert.True(t, ok) 140 | 141 | v, ok = lc.Peek("key2") 142 | assert.Empty(t, v) 143 | assert.False(t, ok) 144 | 145 | assert.Equal(t, []string{"key1"}, lc.Keys()) 146 | } 147 | 148 | func TestCacheWithDeleteExpired(t *testing.T) { 149 | var evicted []string 150 | lc, err := NewCache( 151 | TTL(150*time.Millisecond), 152 | OnEvicted(func(key string, value interface{}) { evicted = append(evicted, key, value.(string)) }), 153 | ) 154 | assert.NoError(t, err) 155 | 156 | lc.Set("key1", "val1", 0) 157 | 158 | time.Sleep(100 * time.Millisecond) // not enough to expire 159 | lc.DeleteExpired() 160 | assert.Equal(t, 1, lc.Len()) 161 | 162 | v, ok := lc.Get("key1") 163 | assert.Equal(t, "val1", v) 164 | assert.True(t, ok) 165 | 166 | time.Sleep(200 * time.Millisecond) // expire 167 | lc.DeleteExpired() 168 | v, ok = lc.Get("key1") 169 | assert.False(t, ok) 170 | assert.Nil(t, v) 171 | 172 | assert.Equal(t, 0, lc.Len()) 173 | assert.Equal(t, []string{"key1", "val1"}, evicted) 174 | 175 | // add new entry 176 | lc.Set("key2", "val2", 0) 177 | assert.Equal(t, 1, lc.Len()) 178 | 179 | // nothing deleted 180 | lc.DeleteExpired() 181 | assert.Equal(t, 1, lc.Len()) 182 | assert.Equal(t, []string{"key1", "val1"}, evicted) 183 | 184 | // Purge, cache should be clean 185 | lc.Purge() 186 | assert.Equal(t, 0, lc.Len()) 187 | assert.Equal(t, []string{"key1", "val1", "key2", "val2"}, evicted) 188 | } 189 | 190 | func TestCacheWithPurgeEnforcedBySize(t *testing.T) { 191 | lc, err := NewCache(MaxKeys(10), TTL(time.Hour)) 192 | assert.NoError(t, err) 193 | 194 | for i := 0; i < 100; i++ { 195 | i := i 196 | lc.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i), 0) 197 | v, ok := lc.Get(fmt.Sprintf("key%d", i)) 198 | assert.Equal(t, fmt.Sprintf("val%d", i), v) 199 | assert.True(t, ok) 200 | assert.True(t, lc.Len() < 20) 201 | } 202 | 203 | assert.Equal(t, 10, lc.Len()) 204 | } 205 | 206 | func TestCacheConcurrency(t *testing.T) { 207 | lc, err := NewCache() 208 | assert.NoError(t, err) 209 | wg := sync.WaitGroup{} 210 | wg.Add(1000) 211 | for i := 0; i < 1000; i++ { 212 | go func(i int) { 213 | lc.Set(fmt.Sprintf("key-%d", i/10), fmt.Sprintf("val-%d", i/10), 0) 214 | wg.Done() 215 | }(i) 216 | } 217 | wg.Wait() 218 | assert.Equal(t, 100, lc.Len()) 219 | } 220 | 221 | func TestCacheInvalidateAndEvict(t *testing.T) { 222 | var evicted int 223 | lc, err := NewCache(LRU(), OnEvicted(func(_ string, _ interface{}) { evicted++ })) 224 | assert.NoError(t, err) 225 | 226 | lc.Set("key1", "val1", 0) 227 | lc.Set("key2", "val2", 0) 228 | 229 | val, ok := lc.Get("key1") 230 | assert.True(t, ok) 231 | assert.Equal(t, "val1", val) 232 | assert.Equal(t, 0, evicted) 233 | 234 | lc.Invalidate("key1") 235 | assert.Equal(t, 1, evicted) 236 | val, ok = lc.Get("key1") 237 | assert.Empty(t, val) 238 | assert.False(t, ok) 239 | 240 | val, ok = lc.Get("key2") 241 | assert.True(t, ok) 242 | assert.Equal(t, "val2", val) 243 | 244 | lc.InvalidateFn(func(key string) bool { 245 | return key == "key2" 246 | }) 247 | assert.Equal(t, 2, evicted) 248 | _, ok = lc.Get("key2") 249 | assert.False(t, ok) 250 | assert.Equal(t, 0, lc.Len()) 251 | } 252 | 253 | func TestCacheBadOption(t *testing.T) { 254 | lc, err := NewCache(func(_ *cacheImpl) error { 255 | return fmt.Errorf("mock err") 256 | }) 257 | assert.EqualError(t, err, "failed to set cache option: mock err") 258 | assert.Nil(t, lc) 259 | } 260 | 261 | func TestCacheExpired(t *testing.T) { 262 | lc, err := NewCache(TTL(time.Millisecond * 5)) 263 | assert.NoError(t, err) 264 | 265 | lc.Set("key1", "val1", 0) 266 | assert.Equal(t, 1, lc.Len()) 267 | 268 | v, ok := lc.Peek("key1") 269 | assert.Equal(t, v, "val1") 270 | assert.True(t, ok) 271 | 272 | v, ok = lc.Get("key1") 273 | assert.Equal(t, v, "val1") 274 | assert.True(t, ok) 275 | 276 | time.Sleep(time.Millisecond * 10) // wait for entry to expire 277 | assert.Equal(t, 1, lc.Len()) // but not purged 278 | 279 | v, ok = lc.Peek("key1") 280 | assert.Empty(t, v) 281 | assert.False(t, ok) 282 | 283 | v, ok = lc.Get("key1") 284 | assert.Empty(t, v) 285 | assert.False(t, ok) 286 | } 287 | 288 | func TestCacheRemoveOldest(t *testing.T) { 289 | lc, err := NewCache(LRU(), MaxKeys(2)) 290 | assert.NoError(t, err) 291 | 292 | lc.Set("key1", "val1", 0) 293 | assert.Equal(t, 1, lc.Len()) 294 | 295 | v, ok := lc.Get("key1") 296 | assert.True(t, ok) 297 | assert.Equal(t, "val1", v) 298 | 299 | assert.Equal(t, []string{"key1"}, lc.Keys()) 300 | assert.Equal(t, 1, lc.Len()) 301 | 302 | lc.Set("key2", "val2", 0) 303 | assert.Equal(t, []string{"key1", "key2"}, lc.Keys()) 304 | assert.Equal(t, 2, lc.Len()) 305 | 306 | lc.RemoveOldest() 307 | 308 | assert.Equal(t, []string{"key2"}, lc.Keys()) 309 | assert.Equal(t, 1, lc.Len()) 310 | } 311 | 312 | func ExampleCache() { 313 | // make cache with short TTL and 3 max keys 314 | cache, _ := NewCache(MaxKeys(3), TTL(time.Millisecond*10)) 315 | 316 | // set value under key1. 317 | // with 0 ttl (last parameter) will use cache-wide setting instead (10ms). 318 | cache.Set("key1", "val1", 0) 319 | 320 | // get value under key1 321 | r, ok := cache.Get("key1") 322 | 323 | // check for OK value, because otherwise return would be nil and 324 | // type conversion will panic 325 | if ok { 326 | rstr := r.(string) // convert cached value from interface{} to real type 327 | fmt.Printf("value before expiration is found: %v, value: %v\n", ok, rstr) 328 | } 329 | 330 | time.Sleep(time.Millisecond * 11) 331 | 332 | // get value under key1 after key expiration 333 | r, ok = cache.Get("key1") 334 | // don't convert to string as with ok == false value would be nil 335 | fmt.Printf("value after expiration is found: %v, value: %v\n", ok, r) 336 | 337 | // set value under key2, would evict old entry because it is already expired. 338 | // ttl (last parameter) overrides cache-wide ttl. 339 | cache.Set("key2", "val2", time.Minute*5) 340 | 341 | fmt.Printf("%+v\n", cache) 342 | // Output: 343 | // value before expiration is found: true, value: val1 344 | // value after expiration is found: false, value: 345 | // Size: 1, Stats: {Hits:1 Misses:1 Added:2 Evicted:1} (50.0%) 346 | } 347 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # expirable-cache 2 | 3 | [![Build Status](https://github.com/go-pkgz/expirable-cache/workflows/build/badge.svg)](https://github.com/go-pkgz/expirable-cache/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/expirable-cache/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/expirable-cache?branch=master) 5 | [![godoc](https://godoc.org/github.com/go-pkgz/expirable-cache?status.svg)](https://pkg.go.dev/github.com/go-pkgz/expirable-cache?tab=doc) 6 | 7 | Package cache implements expirable cache. 8 | 9 | - Support LRC, LRU and TTL-based eviction. 10 | - Package is thread-safe and doesn't spawn any goroutines. 11 | - On every Set() call, cache deletes single oldest entry in case it's expired. 12 | - In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size, 13 | either using LRC or LRU eviction. 14 | - In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited 15 | and will never delete entries from itself automatically. 16 | 17 | **Important**: only reliable way of not having expired entries stuck in a cache is to 18 | run cache.DeleteExpired periodically using [time.Ticker](https://golang.org/pkg/time/#Ticker), 19 | advisable period is 1/2 of TTL. 20 | 21 | This cache is heavily inspired by [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) _simplelru_ implementation. v3 implements `simplelru.LRUCache` interface, so if you use a subset of functions, so you can switch from `github.com/hashicorp/golang-lru/v2/simplelru` or `github.com/hashicorp/golang-lru/v2/expirable` without any changes in your code except for cache creation. Key differences are: 22 | 23 | - Support LRC (Least Recently Created) in addition to LRU and TTL-based eviction 24 | - Supports per-key TTL setting 25 | - Doesn't spawn any goroutines, whereas `hashicorp/golang-lru/v2/expirable` spawns goroutine which is never killed ([as of now](https://github.com/hashicorp/golang-lru/issues/159)) 26 | - Provides stats about hits and misses, added and evicted entries 27 | 28 | ### Usage example 29 | 30 | ```go 31 | package main 32 | 33 | import ( 34 | "fmt" 35 | "time" 36 | 37 | "github.com/go-pkgz/expirable-cache/v3" 38 | ) 39 | 40 | func main() { 41 | // make cache with short TTL and 3 max keys 42 | c := cache.NewCache[string, string]().WithMaxKeys(3).WithTTL(time.Millisecond * 10) 43 | 44 | // set value under key1. 45 | // with 0 ttl (last parameter) will use cache-wide setting instead (10ms). 46 | c.Set("key1", "val1", 0) 47 | 48 | // get value under key1 49 | r, ok := c.Get("key1") 50 | 51 | // check for OK value, because otherwise return would be nil and 52 | // type conversion will panic 53 | if ok { 54 | rstr := r.(string) // convert cached value from interface{} to real type 55 | fmt.Printf("value before expiration is found: %v, value: %v\n", ok, rstr) 56 | } 57 | 58 | time.Sleep(time.Millisecond * 11) 59 | 60 | // get value under key1 after key expiration 61 | r, ok = c.Get("key1") 62 | // don't convert to string as with ok == false value would be nil 63 | fmt.Printf("value after expiration is found: %v, value: %v\n", ok, r) 64 | 65 | // set value under key2, would evict old entry because it is already expired. 66 | // ttl (last parameter) overrides cache-wide ttl. 67 | c.Set("key2", "val2", time.Minute*5) 68 | 69 | fmt.Printf("%+v\n", c) 70 | // Output: 71 | // value before expiration is found: true, value: val1 72 | // value after expiration is found: false, value: 73 | // Size: 1, Stats: {Hits:1 Misses:1 Added:2 Evicted:1} (50.0%) 74 | } 75 | ``` 76 | 77 | ### Performance Comparison 78 | 79 | For detailed benchmarks comparing different versions and cache implementations, see the [benchmarks](./benchmarks) directory. 80 | 81 | Based on all the benchmarks across four different caching libraries: 82 | 83 | 1. **[go-pkgz/expirable-cache](https://github.com/go-pkgz/expirable-cache)** remains the best overall option: 84 | - Excellent performance across all operations 85 | - Lowest memory usage and allocations 86 | - Type safety through generics 87 | - Clean API with method chaining 88 | - Simple implementation 89 | 90 | 2. **[dgraph-io/ristretto](https://github.com/dgraph-io/ristretto)** is a strong contender for specific use cases: 91 | - Great performance for read-heavy workloads 92 | - Sophisticated memory management for very large caches 93 | - Built-in metrics and statistics 94 | - Designed for high-concurrency environments 95 | 96 | 3. **[patrickmn/go-cache](https://github.com/patrickmn/go-cache)** is still fastest for pure raw performance but lacks modern features, and leaks goroutines 97 | 98 | 4. **[jellydator/ttlcache](https://github.com/jellydator/ttlcache)** lags behind in performance compared to all other options. 99 | 100 | #### Version Improvements 101 | 102 | v2 and v3 use Go generics and achieve significant performance improvements over v1: 103 | 104 | - v2 is approximately **28-42% faster** than v1 for basic operations 105 | - v3 maintains the performance gains of v2 while being compatible with the Hashicorp `simplelru` interface 106 | - Recent optimizations have improved performance across all versions 107 | 108 | #### Performance Comparison 109 | 110 | | Operation | v1 | v2 | v3 | Improvement v1→v3 | 111 | |-------------------------|-----------|-----------|-----------|-------------------| 112 | | Random LRU (no expire) | 188.3 ns/op | 127.5 ns/op | 132.3 ns/op | ~30% faster | 113 | | Frequency LRU (no expire) | 180.3 ns/op | 127.4 ns/op | 128.1 ns/op | ~29% faster | 114 | | Random LRU (with expire) | 191.9 ns/op | 129.7 ns/op | 130.7 ns/op | ~32% faster | 115 | | Frequency LRU (with expire) | 181.3 ns/op | 126.7 ns/op | 131.2 ns/op | ~28% faster | 116 | 117 | #### Cross-Library Comparison 118 | 119 | Recent benchmarks comparing expirable-cache with other popular Go caching libraries: 120 | 121 | | Operation | [go-pkgz/expirable-cache](https://github.com/go-pkgz/expirable-cache) | [patrickmn/go-cache](https://github.com/patrickmn/go-cache) | [jellydator/ttlcache](https://github.com/jellydator/ttlcache) | [dgraph-io/ristretto](https://github.com/dgraph-io/ristretto) | 122 | |-----------|-----------------|----------|----------|-----------| 123 | | Set | 69.14 ns/op | 82.67 ns/op | 448.8 ns/op | 820.0 ns/op | 124 | | Get | 78.12 ns/op | 63.81 ns/op | 190.9 ns/op | 84.23 ns/op | 125 | | Set+Get | 66.62 ns/op | 67.94 ns/op | 253.9 ns/op | 198.2 ns/op | 126 | | Real-world scenario | 78.83 ns/op | 70.24 ns/op | 198.0 ns/op | 83.40 ns/op | 127 | | Memory allocations | Lowest | Low | Medium | Highest | 128 | 129 |
130 | v1 benchmark results 131 | 132 | ``` 133 | ~/expirable-cache ❯ go test -bench=. 134 | goos: darwin 135 | goarch: arm64 136 | pkg: github.com/go-pkgz/expirable-cache 137 | BenchmarkLRU_Rand_NoExpire-8 4494738 272.4 ns/op 138 | --- BENCH: BenchmarkLRU_Rand_NoExpire-8 139 | cache_test.go:46: hit: 0 miss: 1 ratio: 0.000000 140 | cache_test.go:46: hit: 1 miss: 99 ratio: 0.010000 141 | cache_test.go:46: hit: 1352 miss: 8648 ratio: 0.135200 142 | cache_test.go:46: hit: 248678 miss: 751322 ratio: 0.248678 143 | cache_test.go:46: hit: 1121791 miss: 3372947 ratio: 0.249579 144 | BenchmarkLRU_Freq_NoExpire-8 4612648 261.6 ns/op 145 | --- BENCH: BenchmarkLRU_Freq_NoExpire-8 146 | cache_test.go:74: hit: 1 miss: 0 ratio: 1.000000 147 | cache_test.go:74: hit: 100 miss: 0 ratio: 1.000000 148 | cache_test.go:74: hit: 9825 miss: 175 ratio: 0.982500 149 | cache_test.go:74: hit: 312345 miss: 687655 ratio: 0.312345 150 | cache_test.go:74: hit: 1414620 miss: 3198028 ratio: 0.306683 151 | BenchmarkLRU_Rand_WithExpire-8 4109704 286.5 ns/op 152 | --- BENCH: BenchmarkLRU_Rand_WithExpire-8 153 | cache_test.go:99: hit: 0 miss: 1 ratio: 0.000000 154 | cache_test.go:99: hit: 0 miss: 100 ratio: 0.000000 155 | cache_test.go:99: hit: 1304 miss: 8696 ratio: 0.130400 156 | cache_test.go:99: hit: 248310 miss: 751690 ratio: 0.248310 157 | cache_test.go:99: hit: 1027317 miss: 3082387 ratio: 0.249973 158 | BenchmarkLRU_Freq_WithExpire-8 4341217 279.6 ns/op 159 | --- BENCH: BenchmarkLRU_Freq_WithExpire-8 160 | cache_test.go:127: hit: 1 miss: 0 ratio: 1.000000 161 | cache_test.go:127: hit: 100 miss: 0 ratio: 1.000000 162 | cache_test.go:127: hit: 9868 miss: 132 ratio: 0.986800 163 | cache_test.go:127: hit: 38221 miss: 961779 ratio: 0.038221 164 | cache_test.go:127: hit: 37296 miss: 4303921 ratio: 0.008591 165 | PASS 166 | ok github.com/go-pkgz/expirable-cache 18.307s 167 | ``` 168 |
169 | 170 |
171 | v3 benchmark results 172 | 173 | ``` 174 | ~/Desktop/expirable-cache/v3 master !2 ❯ go test -bench=. 175 | goos: darwin 176 | goarch: arm64 177 | pkg: github.com/go-pkgz/expirable-cache/v3 178 | BenchmarkLRU_Rand_NoExpire-8 7556680 158.1 ns/op 179 | --- BENCH: BenchmarkLRU_Rand_NoExpire-8 180 | cache_test.go:47: hit: 0 miss: 1 ratio: 0.000000 181 | cache_test.go:47: hit: 0 miss: 100 ratio: 0.000000 182 | cache_test.go:47: hit: 1409 miss: 8591 ratio: 0.140900 183 | cache_test.go:47: hit: 249063 miss: 750937 ratio: 0.249063 184 | cache_test.go:47: hit: 1887563 miss: 5669117 ratio: 0.249787 185 | BenchmarkLRU_Freq_NoExpire-8 7876738 150.9 ns/op 186 | --- BENCH: BenchmarkLRU_Freq_NoExpire-8 187 | cache_test.go:75: hit: 1 miss: 0 ratio: 1.000000 188 | cache_test.go:75: hit: 100 miss: 0 ratio: 1.000000 189 | cache_test.go:75: hit: 9850 miss: 150 ratio: 0.985000 190 | cache_test.go:75: hit: 310888 miss: 689112 ratio: 0.310888 191 | cache_test.go:75: hit: 2413312 miss: 5463426 ratio: 0.306385 192 | BenchmarkLRU_Rand_WithExpire-8 6822362 175.3 ns/op 193 | --- BENCH: BenchmarkLRU_Rand_WithExpire-8 194 | cache_test.go:100: hit: 0 miss: 1 ratio: 0.000000 195 | cache_test.go:100: hit: 0 miss: 100 ratio: 0.000000 196 | cache_test.go:100: hit: 1326 miss: 8674 ratio: 0.132600 197 | cache_test.go:100: hit: 248508 miss: 751492 ratio: 0.248508 198 | cache_test.go:100: hit: 1704172 miss: 5118190 ratio: 0.249792 199 | BenchmarkLRU_Freq_WithExpire-8 7098261 168.1 ns/op 200 | --- BENCH: BenchmarkLRU_Freq_WithExpire-8 201 | cache_test.go:128: hit: 1 miss: 0 ratio: 1.000000 202 | cache_test.go:128: hit: 100 miss: 0 ratio: 1.000000 203 | cache_test.go:128: hit: 9842 miss: 158 ratio: 0.984200 204 | cache_test.go:128: hit: 90167 miss: 909833 ratio: 0.090167 205 | cache_test.go:128: hit: 90421 miss: 7007840 ratio: 0.012738 206 | PASS 207 | ok github.com/go-pkgz/expirable-cache/v3 24.315s 208 | ``` 209 |
210 | 211 |
212 | 213 | For detailed benchmarks and methodology, see the [benchmarks directory](./benchmarks). -------------------------------------------------------------------------------- /v3/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache implements Cache similar to hashicorp/golang-lru 2 | // 3 | // Support LRC, LRU and TTL-based eviction. 4 | // Package is thread-safe and doesn't spawn any goroutines. 5 | // On every Set() call, cache deletes single oldest entry in case it's expired. 6 | // In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size, 7 | // either using LRC or LRU eviction. 8 | // In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited 9 | // and will never delete entries from itself automatically. 10 | // 11 | // Important: only reliable way of not having expired entries stuck in a cache is to 12 | // run cache.DeleteExpired periodically using time.Ticker, advisable period is 1/2 of TTL. 13 | package cache 14 | 15 | import ( 16 | "container/list" 17 | "fmt" 18 | "sync" 19 | "time" 20 | ) 21 | 22 | // Cache defines cache interface 23 | type Cache[K comparable, V any] interface { 24 | fmt.Stringer 25 | options[K, V] 26 | Add(key K, value V) bool 27 | Set(key K, value V, ttl time.Duration) 28 | Get(key K) (V, bool) 29 | GetExpiration(key K) (time.Time, bool) 30 | GetOldest() (K, V, bool) 31 | Contains(key K) (ok bool) 32 | Peek(key K) (V, bool) 33 | Values() []V 34 | Keys() []K 35 | Len() int 36 | Remove(key K) bool 37 | Invalidate(key K) 38 | InvalidateFn(fn func(key K) bool) 39 | RemoveOldest() (K, V, bool) 40 | DeleteExpired() 41 | Purge() 42 | Resize(int) int 43 | Stat() Stats 44 | } 45 | 46 | // Stats provides statistics for cache 47 | type Stats struct { 48 | Hits, Misses int // cache effectiveness 49 | Added, Evicted int // number of added and evicted records 50 | } 51 | 52 | // cacheImpl provides Cache interface implementation. 53 | type cacheImpl[K comparable, V any] struct { 54 | ttl time.Duration 55 | maxKeys int 56 | isLRU bool 57 | onEvicted func(key K, value V) 58 | 59 | sync.Mutex 60 | stat Stats 61 | items map[K]*list.Element 62 | evictList *list.List 63 | } 64 | 65 | // noEvictionTTL - very long ttl to prevent eviction 66 | const noEvictionTTL = time.Hour * 24 * 365 * 10 67 | 68 | // NewCache returns a new Cache. 69 | // Default MaxKeys is unlimited (0). 70 | // Default TTL is 10 years, sane value for expirable cache is 5 minutes. 71 | // Default eviction mode is LRC, appropriate option allow to change it to LRU. 72 | func NewCache[K comparable, V any]() Cache[K, V] { 73 | return &cacheImpl[K, V]{ 74 | items: map[K]*list.Element{}, 75 | evictList: list.New(), 76 | ttl: noEvictionTTL, 77 | maxKeys: 0, 78 | } 79 | } 80 | 81 | // Add adds a value to the cache. Returns true if an eviction occurred. 82 | // Returns false if there was no eviction: the item was already in the cache, 83 | // or the size was not exceeded. 84 | func (c *cacheImpl[K, V]) Add(key K, value V) (evicted bool) { 85 | return c.addWithTTL(key, value, c.ttl) 86 | } 87 | 88 | // Set key, ttl of 0 would use cache-wide TTL 89 | func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) { 90 | c.addWithTTL(key, value, ttl) 91 | } 92 | 93 | // Returns true if an eviction occurred. 94 | // Returns false if there was no eviction: the item was already in the cache, 95 | // or the size was not exceeded. 96 | func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted bool) { 97 | if ttl == 0 { 98 | ttl = c.ttl 99 | } 100 | now := time.Now() 101 | c.Lock() 102 | defer c.Unlock() 103 | // Check for existing item 104 | if ent, ok := c.items[key]; ok { 105 | c.evictList.MoveToFront(ent) 106 | ent.Value.(*cacheItem[K, V]).value = value 107 | ent.Value.(*cacheItem[K, V]).expiresAt = now.Add(ttl) 108 | return false 109 | } 110 | 111 | // Add new item 112 | ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now.Add(ttl)} 113 | entry := c.evictList.PushFront(ent) 114 | c.items[key] = entry 115 | c.stat.Added++ 116 | 117 | // Remove the oldest entry if it is expired, only in case of non-default TTL. 118 | if c.ttl != noEvictionTTL || ttl != noEvictionTTL { 119 | ent := c.evictList.Back() 120 | if ent != nil && now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { 121 | c.removeElement(ent) 122 | } 123 | } 124 | 125 | evict := c.maxKeys > 0 && len(c.items) > c.maxKeys 126 | // Verify size not exceeded 127 | if evict { 128 | c.removeOldest() 129 | } 130 | return evict 131 | } 132 | 133 | // Get returns the key value if it's not expired 134 | func (c *cacheImpl[K, V]) Get(key K) (V, bool) { 135 | def := *new(V) 136 | c.Lock() 137 | defer c.Unlock() 138 | if ent, ok := c.items[key]; ok { 139 | // Expired item check 140 | if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { 141 | c.stat.Misses++ 142 | return ent.Value.(*cacheItem[K, V]).value, false 143 | } 144 | if c.isLRU { 145 | c.evictList.MoveToFront(ent) 146 | } 147 | c.stat.Hits++ 148 | return ent.Value.(*cacheItem[K, V]).value, true 149 | } 150 | c.stat.Misses++ 151 | return def, false 152 | } 153 | 154 | // Contains checks if a key is in the cache, without updating the recent-ness 155 | // or deleting it for being stale. 156 | func (c *cacheImpl[K, V]) Contains(key K) (ok bool) { 157 | c.Lock() 158 | defer c.Unlock() 159 | _, ok = c.items[key] 160 | return ok 161 | } 162 | 163 | // Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key. 164 | // Works exactly the same as Get in case of LRC mode (default one). 165 | func (c *cacheImpl[K, V]) Peek(key K) (V, bool) { 166 | def := *new(V) 167 | c.Lock() 168 | defer c.Unlock() 169 | if ent, ok := c.items[key]; ok { 170 | // Expired item check 171 | if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { 172 | c.stat.Misses++ 173 | return ent.Value.(*cacheItem[K, V]).value, false 174 | } 175 | c.stat.Hits++ 176 | return ent.Value.(*cacheItem[K, V]).value, true 177 | } 178 | c.stat.Misses++ 179 | return def, false 180 | } 181 | 182 | // GetExpiration returns the expiration time of the key. Non-existing key returns zero time. 183 | func (c *cacheImpl[K, V]) GetExpiration(key K) (time.Time, bool) { 184 | c.Lock() 185 | defer c.Unlock() 186 | if ent, ok := c.items[key]; ok { 187 | return ent.Value.(*cacheItem[K, V]).expiresAt, true 188 | } 189 | return time.Time{}, false 190 | } 191 | 192 | // Keys returns a slice of the keys in the cache, from oldest to newest. 193 | func (c *cacheImpl[K, V]) Keys() []K { 194 | c.Lock() 195 | defer c.Unlock() 196 | return c.keys() 197 | } 198 | 199 | // Values returns a slice of the values in the cache, from oldest to newest. 200 | // Expired entries are filtered out. 201 | func (c *cacheImpl[K, V]) Values() []V { 202 | values := make([]V, 0, len(c.items)) 203 | now := time.Now() 204 | c.Lock() 205 | defer c.Unlock() 206 | for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { 207 | if !now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { 208 | values = append(values, ent.Value.(*cacheItem[K, V]).value) 209 | } 210 | } 211 | return values 212 | } 213 | 214 | // Len return count of items in cache, including expired 215 | func (c *cacheImpl[K, V]) Len() int { 216 | c.Lock() 217 | defer c.Unlock() 218 | return c.evictList.Len() 219 | } 220 | 221 | // Resize changes the cache size. Size of 0 means unlimited. 222 | func (c *cacheImpl[K, V]) Resize(size int) int { 223 | c.Lock() 224 | defer c.Unlock() 225 | if size <= 0 { 226 | c.maxKeys = 0 227 | return 0 228 | } 229 | diff := c.evictList.Len() - size 230 | if diff < 0 { 231 | diff = 0 232 | } 233 | for i := 0; i < diff; i++ { 234 | c.removeOldest() 235 | } 236 | c.maxKeys = size 237 | return diff 238 | } 239 | 240 | // Invalidate key (item) from the cache 241 | func (c *cacheImpl[K, V]) Invalidate(key K) { 242 | c.Lock() 243 | defer c.Unlock() 244 | if ent, ok := c.items[key]; ok { 245 | c.removeElement(ent) 246 | } 247 | } 248 | 249 | // InvalidateFn deletes multiple keys if predicate is true 250 | func (c *cacheImpl[K, V]) InvalidateFn(fn func(key K) bool) { 251 | c.Lock() 252 | defer c.Unlock() 253 | for key, ent := range c.items { 254 | if fn(key) { 255 | c.removeElement(ent) 256 | } 257 | } 258 | } 259 | 260 | // Remove removes the provided key from the cache, returning if the 261 | // key was contained. 262 | func (c *cacheImpl[K, V]) Remove(key K) bool { 263 | c.Lock() 264 | defer c.Unlock() 265 | if ent, ok := c.items[key]; ok { 266 | c.removeElement(ent) 267 | return true 268 | } 269 | return false 270 | } 271 | 272 | // RemoveOldest remove the oldest element in the cache 273 | func (c *cacheImpl[K, V]) RemoveOldest() (key K, value V, ok bool) { 274 | c.Lock() 275 | defer c.Unlock() 276 | if ent := c.evictList.Back(); ent != nil { 277 | c.removeElement(ent) 278 | return ent.Value.(*cacheItem[K, V]).key, ent.Value.(*cacheItem[K, V]).value, true 279 | } 280 | return 281 | } 282 | 283 | // GetOldest returns the oldest entry 284 | func (c *cacheImpl[K, V]) GetOldest() (key K, value V, ok bool) { 285 | c.Lock() 286 | defer c.Unlock() 287 | if ent := c.evictList.Back(); ent != nil { 288 | return ent.Value.(*cacheItem[K, V]).key, ent.Value.(*cacheItem[K, V]).value, true 289 | } 290 | return 291 | } 292 | 293 | // DeleteExpired clears cache of expired items 294 | func (c *cacheImpl[K, V]) DeleteExpired() { 295 | now := time.Now() 296 | c.Lock() 297 | defer c.Unlock() 298 | var nextEnt *list.Element 299 | for ent := c.evictList.Back(); ent != nil; ent = nextEnt { 300 | nextEnt = ent.Prev() 301 | if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { 302 | c.removeElement(ent) 303 | } 304 | } 305 | } 306 | 307 | // Purge clears the cache completely. 308 | func (c *cacheImpl[K, V]) Purge() { 309 | c.Lock() 310 | defer c.Unlock() 311 | for k, v := range c.items { 312 | delete(c.items, k) 313 | c.stat.Evicted++ 314 | if c.onEvicted != nil { 315 | c.onEvicted(k, v.Value.(*cacheItem[K, V]).value) 316 | } 317 | } 318 | c.evictList.Init() 319 | } 320 | 321 | // Stat gets the current stats for cache 322 | func (c *cacheImpl[K, V]) Stat() Stats { 323 | c.Lock() 324 | defer c.Unlock() 325 | return c.stat 326 | } 327 | 328 | func (c *cacheImpl[K, V]) String() string { 329 | stats := c.Stat() 330 | size := c.Len() 331 | return fmt.Sprintf("Size: %d, Stats: %+v (%0.1f%%)", size, stats, 100*float64(stats.Hits)/float64(stats.Hits+stats.Misses)) 332 | } 333 | 334 | // Keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! 335 | func (c *cacheImpl[K, V]) keys() []K { 336 | keys := make([]K, 0, len(c.items)) 337 | for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { 338 | keys = append(keys, ent.Value.(*cacheItem[K, V]).key) 339 | } 340 | return keys 341 | } 342 | 343 | // removeOldest removes the oldest item from the cache. Has to be called with lock! 344 | func (c *cacheImpl[K, V]) removeOldest() { 345 | ent := c.evictList.Back() 346 | if ent != nil { 347 | c.removeElement(ent) 348 | } 349 | } 350 | 351 | // removeElement is used to remove a given list element from the cache. Has to be called with lock! 352 | func (c *cacheImpl[K, V]) removeElement(e *list.Element) { 353 | c.evictList.Remove(e) 354 | kv := e.Value.(*cacheItem[K, V]) 355 | delete(c.items, kv.key) 356 | c.stat.Evicted++ 357 | if c.onEvicted != nil { 358 | c.onEvicted(kv.key, kv.value) 359 | } 360 | } 361 | 362 | // cacheItem is used to hold a value in the evictList 363 | type cacheItem[K comparable, V any] struct { 364 | expiresAt time.Time 365 | key K 366 | value V 367 | } 368 | -------------------------------------------------------------------------------- /v3/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | "math/big" 8 | "reflect" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/hashicorp/golang-lru/v2/simplelru" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func getRand(tb testing.TB) int64 { 18 | out, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) 19 | if err != nil { 20 | tb.Fatal(err) 21 | } 22 | return out.Int64() 23 | } 24 | 25 | func BenchmarkLRU_Rand_NoExpire(b *testing.B) { 26 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) 27 | 28 | trace := make([]int64, b.N*2) 29 | for i := 0; i < b.N*2; i++ { 30 | trace[i] = getRand(b) % 32768 31 | } 32 | 33 | b.ResetTimer() 34 | 35 | var hit, miss int 36 | for i := 0; i < 2*b.N; i++ { 37 | if i%2 == 0 { 38 | l.Add(trace[i], trace[i]) 39 | } else { 40 | if _, ok := l.Get(trace[i]); ok { 41 | hit++ 42 | } else { 43 | miss++ 44 | } 45 | } 46 | } 47 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 48 | } 49 | 50 | func BenchmarkLRU_Freq_NoExpire(b *testing.B) { 51 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) 52 | 53 | trace := make([]int64, b.N*2) 54 | for i := 0; i < b.N*2; i++ { 55 | if i%2 == 0 { 56 | trace[i] = getRand(b) % 16384 57 | } else { 58 | trace[i] = getRand(b) % 32768 59 | } 60 | } 61 | 62 | b.ResetTimer() 63 | 64 | for i := 0; i < b.N; i++ { 65 | l.Add(trace[i], trace[i]) 66 | } 67 | var hit, miss int 68 | for i := 0; i < b.N; i++ { 69 | if _, ok := l.Get(trace[i]); ok { 70 | hit++ 71 | } else { 72 | miss++ 73 | } 74 | } 75 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 76 | } 77 | 78 | func BenchmarkLRU_Rand_WithExpire(b *testing.B) { 79 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) 80 | 81 | trace := make([]int64, b.N*2) 82 | for i := 0; i < b.N*2; i++ { 83 | trace[i] = getRand(b) % 32768 84 | } 85 | 86 | b.ResetTimer() 87 | 88 | var hit, miss int 89 | for i := 0; i < 2*b.N; i++ { 90 | if i%2 == 0 { 91 | l.Add(trace[i], trace[i]) 92 | } else { 93 | if _, ok := l.Get(trace[i]); ok { 94 | hit++ 95 | } else { 96 | miss++ 97 | } 98 | } 99 | } 100 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 101 | } 102 | 103 | func BenchmarkLRU_Freq_WithExpire(b *testing.B) { 104 | l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) 105 | 106 | trace := make([]int64, b.N*2) 107 | for i := 0; i < b.N*2; i++ { 108 | if i%2 == 0 { 109 | trace[i] = getRand(b) % 16384 110 | } else { 111 | trace[i] = getRand(b) % 32768 112 | } 113 | } 114 | 115 | b.ResetTimer() 116 | 117 | for i := 0; i < b.N; i++ { 118 | l.Add(trace[i], trace[i]) 119 | } 120 | var hit, miss int 121 | for i := 0; i < b.N; i++ { 122 | if _, ok := l.Get(trace[i]); ok { 123 | hit++ 124 | } else { 125 | miss++ 126 | } 127 | } 128 | b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) 129 | } 130 | 131 | func TestSimpleLRUInterface(_ *testing.T) { 132 | var _ simplelru.LRUCache[int, int] = NewCache[int, int]() 133 | } 134 | 135 | func TestCacheNoPurge(t *testing.T) { 136 | lc := NewCache[string, string]() 137 | 138 | k, v, ok := lc.GetOldest() 139 | assert.Empty(t, k) 140 | assert.Empty(t, v) 141 | assert.False(t, ok) 142 | 143 | lc.Add("key1", "val1") 144 | assert.Equal(t, 1, lc.Len()) 145 | assert.True(t, lc.Contains("key1")) 146 | assert.False(t, lc.Contains("key2")) 147 | 148 | v, ok = lc.Peek("key1") 149 | assert.Equal(t, "val1", v) 150 | assert.True(t, ok) 151 | 152 | k, v, ok = lc.GetOldest() 153 | assert.Equal(t, "key1", k) 154 | assert.Equal(t, "val1", v) 155 | assert.True(t, ok) 156 | 157 | lc.Add("key3", "val3") 158 | lc.Add("key4", "val4") 159 | lc.Peek("key3") 160 | k, v, ok = lc.GetOldest() 161 | assert.Equal(t, "key1", k) 162 | assert.Equal(t, "val1", v) 163 | assert.True(t, ok) 164 | 165 | lc.Add("key1", "val1") 166 | k, v, ok = lc.GetOldest() 167 | assert.Equal(t, "key3", k) 168 | assert.Equal(t, "val3", v) 169 | assert.True(t, ok) 170 | 171 | v, ok = lc.Peek("key2") 172 | assert.Empty(t, v) 173 | assert.False(t, ok) 174 | 175 | assert.Equal(t, []string{"key3", "key4", "key1"}, lc.Keys()) 176 | } 177 | 178 | func TestCacheWithDeleteExpired(t *testing.T) { 179 | var evicted []string 180 | lc := NewCache[string, string]().WithTTL(150 * time.Millisecond).WithOnEvicted( 181 | func(key string, value string) { 182 | evicted = append(evicted, key, value) 183 | }) 184 | 185 | lc.Set("key1", "val1", 0) 186 | 187 | time.Sleep(100 * time.Millisecond) // not enough to expire 188 | lc.DeleteExpired() 189 | assert.Equal(t, 1, lc.Len()) 190 | 191 | v, ok := lc.Get("key1") 192 | assert.Equal(t, "val1", v) 193 | assert.True(t, ok) 194 | 195 | time.Sleep(200 * time.Millisecond) // expire 196 | lc.DeleteExpired() 197 | v, ok = lc.Get("key1") 198 | assert.False(t, ok) 199 | assert.Equal(t, "", v) 200 | 201 | assert.Equal(t, 0, lc.Len()) 202 | assert.Equal(t, []string{"key1", "val1"}, evicted) 203 | 204 | // add new entry 205 | lc.Set("key2", "val2", 0) 206 | assert.Equal(t, 1, lc.Len()) 207 | 208 | // nothing deleted 209 | lc.DeleteExpired() 210 | assert.Equal(t, 1, lc.Len()) 211 | assert.Equal(t, []string{"key1", "val1"}, evicted) 212 | 213 | // Purge, cache should be clean 214 | lc.Purge() 215 | assert.Equal(t, 0, lc.Len()) 216 | assert.Equal(t, []string{"key1", "val1", "key2", "val2"}, evicted) 217 | } 218 | 219 | func TestCache_Values(t *testing.T) { 220 | lc := NewCache[string, string]().WithMaxKeys(3) 221 | 222 | lc.Add("key1", "val1") 223 | lc.Add("key2", "val2") 224 | lc.Add("key3", "val3") 225 | 226 | values := lc.Values() 227 | if !reflect.DeepEqual(values, []string{"val1", "val2", "val3"}) { 228 | t.Fatalf("values differs from expected") 229 | } 230 | 231 | assert.Equal(t, 0, lc.Resize(0)) 232 | assert.Equal(t, 1, lc.Resize(2)) 233 | assert.Equal(t, 0, lc.Resize(5)) 234 | assert.Equal(t, 1, lc.Resize(1)) 235 | } 236 | 237 | func TestCacheWithPurgeEnforcedBySize(t *testing.T) { 238 | lc := NewCache[string, string]().WithTTL(time.Hour).WithMaxKeys(10) 239 | 240 | for i := 0; i < 100; i++ { 241 | i := i 242 | lc.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i), 0) 243 | v, ok := lc.Get(fmt.Sprintf("key%d", i)) 244 | assert.Equal(t, fmt.Sprintf("val%d", i), v) 245 | assert.True(t, ok) 246 | assert.True(t, lc.Len() < 20) 247 | } 248 | 249 | assert.Equal(t, 10, lc.Len()) 250 | } 251 | 252 | func TestCacheConcurrency(t *testing.T) { 253 | lc := NewCache[string, string]() 254 | wg := sync.WaitGroup{} 255 | wg.Add(1000) 256 | for i := 0; i < 1000; i++ { 257 | go func(i int) { 258 | lc.Set(fmt.Sprintf("key-%d", i/10), fmt.Sprintf("val-%d", i/10), 0) 259 | wg.Done() 260 | }(i) 261 | } 262 | wg.Wait() 263 | assert.Equal(t, 100, lc.Len()) 264 | } 265 | 266 | func TestCacheInvalidateAndEvict(t *testing.T) { 267 | var evicted int 268 | lc := NewCache[string, string]().WithLRU().WithOnEvicted(func(_ string, _ string) { evicted++ }) 269 | 270 | lc.Set("key1", "val1", 0) 271 | lc.Set("key2", "val2", 0) 272 | lc.Set("key3", "val3", 0) 273 | 274 | val, ok := lc.Get("key1") 275 | assert.True(t, ok) 276 | assert.Equal(t, "val1", val) 277 | assert.Equal(t, 0, evicted) 278 | 279 | lc.Invalidate("key1") 280 | assert.Equal(t, 1, evicted) 281 | val, ok = lc.Get("key1") 282 | assert.Empty(t, val) 283 | assert.False(t, ok) 284 | 285 | val, ok = lc.Get("key2") 286 | assert.True(t, ok) 287 | assert.Equal(t, "val2", val) 288 | 289 | lc.InvalidateFn(func(key string) bool { 290 | return key == "key2" 291 | }) 292 | assert.Equal(t, 2, evicted) 293 | _, ok = lc.Get("key2") 294 | assert.False(t, ok) 295 | assert.Equal(t, 1, lc.Len()) 296 | 297 | assert.True(t, lc.Remove("key3")) 298 | assert.Equal(t, 3, evicted) 299 | val, ok = lc.Get("key3") 300 | assert.Empty(t, val) 301 | assert.False(t, ok) 302 | assert.False(t, lc.Remove("key3")) 303 | assert.Zero(t, lc.Len()) 304 | } 305 | 306 | func TestCacheExpired(t *testing.T) { 307 | lc := NewCache[string, string]().WithTTL(time.Millisecond * 5) 308 | 309 | lc.Set("key1", "val1", 0) 310 | assert.Equal(t, 1, lc.Len()) 311 | 312 | v, ok := lc.Peek("key1") 313 | assert.Equal(t, v, "val1") 314 | assert.True(t, ok) 315 | 316 | v, ok = lc.Get("key1") 317 | assert.Equal(t, v, "val1") 318 | assert.True(t, ok) 319 | 320 | time.Sleep(time.Millisecond * 10) // wait for entry to expire 321 | assert.Equal(t, 1, lc.Len()) // but not purged 322 | 323 | v, ok = lc.Peek("key1") 324 | assert.Equal(t, "val1", v, "expired and marked as such, but value is available") 325 | assert.False(t, ok) 326 | 327 | v, ok = lc.Get("key1") 328 | assert.Equal(t, "val1", v, "expired and marked as such, but value is available") 329 | assert.False(t, ok) 330 | 331 | assert.Empty(t, lc.Values()) 332 | } 333 | 334 | func TestCache_GetExpiration(t *testing.T) { 335 | lc := NewCache[string, string]().WithTTL(time.Second * 5) 336 | 337 | lc.Set("key1", "val1", time.Second*5) 338 | assert.Equal(t, 1, lc.Len()) 339 | 340 | exp, ok := lc.GetExpiration("key1") 341 | assert.True(t, ok) 342 | assert.True(t, exp.After(time.Now().Add(time.Second*4))) 343 | assert.True(t, exp.Before(time.Now().Add(time.Second*6))) 344 | 345 | lc.Set("key2", "val2", time.Second*10) 346 | assert.Equal(t, 2, lc.Len()) 347 | 348 | exp, ok = lc.GetExpiration("key2") 349 | assert.True(t, ok) 350 | assert.True(t, exp.After(time.Now().Add(time.Second*9))) 351 | assert.True(t, exp.Before(time.Now().Add(time.Second*11))) 352 | 353 | exp, ok = lc.GetExpiration("non-existing-key") 354 | assert.False(t, ok) 355 | assert.Zero(t, exp) 356 | } 357 | 358 | func TestCacheRemoveOldest(t *testing.T) { 359 | lc := NewCache[string, string]().WithLRU().WithMaxKeys(2) 360 | 361 | lc.Set("key1", "val1", 0) 362 | assert.Equal(t, 1, lc.Len()) 363 | 364 | v, ok := lc.Get("key1") 365 | assert.True(t, ok) 366 | assert.Equal(t, "val1", v) 367 | 368 | assert.Equal(t, []string{"key1"}, lc.Keys()) 369 | assert.Equal(t, 1, lc.Len()) 370 | 371 | lc.Set("key2", "val2", 0) 372 | assert.Equal(t, []string{"key1", "key2"}, lc.Keys()) 373 | assert.Equal(t, 2, lc.Len()) 374 | 375 | k, v, ok := lc.RemoveOldest() 376 | assert.Equal(t, "key1", k) 377 | assert.Equal(t, "val1", v) 378 | assert.True(t, ok) 379 | 380 | assert.Equal(t, []string{"key2"}, lc.Keys()) 381 | assert.Equal(t, 1, lc.Len()) 382 | 383 | k, v, ok = lc.RemoveOldest() 384 | assert.Equal(t, "key2", k) 385 | assert.Equal(t, "val2", v) 386 | assert.True(t, ok) 387 | 388 | k, v, ok = lc.RemoveOldest() 389 | assert.Empty(t, k) 390 | assert.Empty(t, v) 391 | assert.False(t, ok) 392 | 393 | assert.Empty(t, lc.Keys()) 394 | 395 | } 396 | 397 | func ExampleCache() { 398 | // make cache with short TTL and 3 max keys 399 | cache := NewCache[string, string]().WithMaxKeys(3).WithTTL(time.Millisecond * 10) 400 | 401 | // set value under key1. 402 | // with 0 ttl (last parameter) will use cache-wide setting instead (10ms). 403 | cache.Set("key1", "val1", 0) 404 | 405 | // get value under key1 406 | r, ok := cache.Get("key1") 407 | 408 | // check for OK value, because otherwise return would be nil and 409 | // type conversion will panic 410 | if ok { 411 | fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r) 412 | } 413 | 414 | time.Sleep(time.Millisecond * 11) 415 | 416 | // get value under key1 after key expiration 417 | r, ok = cache.Get("key1") 418 | // don't convert to string as with ok == false value would be nil 419 | fmt.Printf("value after expiration is found: %v, value: %q\n", ok, r) 420 | 421 | // set value under key2, would evict old entry because it is already expired. 422 | // ttl (last parameter) overrides cache-wide ttl. 423 | cache.Set("key2", "val2", time.Minute*5) 424 | 425 | fmt.Printf("%+v\n", cache) 426 | // Output: 427 | // value before expiration is found: true, value: "val1" 428 | // value after expiration is found: false, value: "val1" 429 | // Size: 1, Stats: {Hits:1 Misses:1 Added:2 Evicted:1} (50.0%) 430 | } 431 | -------------------------------------------------------------------------------- /benchmarks/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/dgraph-io/ristretto" 11 | expcache "github.com/go-pkgz/expirable-cache/v3" 12 | "github.com/jellydator/ttlcache/v3" 13 | gocache "github.com/patrickmn/go-cache" 14 | ) 15 | 16 | const ( 17 | numItems = 10000 18 | ) 19 | 20 | type testItem struct { 21 | ID int 22 | Name string 23 | Value float64 24 | Data []byte 25 | } 26 | 27 | func generateRandomItems(n int) []testItem { 28 | items := make([]testItem, n) 29 | for i := 0; i < n; i++ { 30 | items[i] = testItem{ 31 | ID: i, 32 | Name: fmt.Sprintf("item-%d", i), 33 | Value: rand.Float64() * 100, 34 | Data: make([]byte, 100), 35 | } 36 | rand.Read(items[i].Data) 37 | } 38 | return items 39 | } 40 | 41 | // Benchmarks for go-cache 42 | func BenchmarkGoCache_Set(b *testing.B) { 43 | cache := gocache.New(5*time.Minute, 10*time.Minute) 44 | items := generateRandomItems(numItems) 45 | 46 | b.ResetTimer() 47 | for i := 0; i < b.N; i++ { 48 | item := items[i%numItems] 49 | cache.Set(strconv.Itoa(item.ID), item, gocache.DefaultExpiration) 50 | } 51 | } 52 | 53 | func BenchmarkGoCache_Get(b *testing.B) { 54 | cache := gocache.New(5*time.Minute, 10*time.Minute) 55 | items := generateRandomItems(numItems) 56 | 57 | // Populate cache 58 | for _, item := range items { 59 | cache.Set(strconv.Itoa(item.ID), item, gocache.DefaultExpiration) 60 | } 61 | 62 | b.ResetTimer() 63 | for i := 0; i < b.N; i++ { 64 | key := strconv.Itoa(rand.Intn(numItems)) 65 | _, _ = cache.Get(key) 66 | } 67 | } 68 | 69 | func BenchmarkGoCache_SetAndGet(b *testing.B) { 70 | cache := gocache.New(5*time.Minute, 10*time.Minute) 71 | items := generateRandomItems(numItems) 72 | 73 | b.ResetTimer() 74 | for i := 0; i < b.N; i++ { 75 | if i%2 == 0 { 76 | item := items[i%numItems] 77 | cache.Set(strconv.Itoa(item.ID), item, gocache.DefaultExpiration) 78 | } else { 79 | key := strconv.Itoa(rand.Intn(numItems)) 80 | _, _ = cache.Get(key) 81 | } 82 | } 83 | } 84 | 85 | // Benchmarks for ttlcache 86 | func BenchmarkTTLCache_Set(b *testing.B) { 87 | cache := ttlcache.New[string, testItem]( 88 | ttlcache.WithTTL[string, testItem](5 * time.Minute), 89 | ) 90 | go cache.Start() 91 | defer cache.Stop() 92 | 93 | items := generateRandomItems(numItems) 94 | 95 | b.ResetTimer() 96 | for i := 0; i < b.N; i++ { 97 | item := items[i%numItems] 98 | cache.Set(strconv.Itoa(item.ID), item, ttlcache.DefaultTTL) 99 | } 100 | } 101 | 102 | func BenchmarkTTLCache_Get(b *testing.B) { 103 | cache := ttlcache.New[string, testItem]( 104 | ttlcache.WithTTL[string, testItem](5 * time.Minute), 105 | ) 106 | go cache.Start() 107 | defer cache.Stop() 108 | 109 | items := generateRandomItems(numItems) 110 | 111 | // Populate cache 112 | for _, item := range items { 113 | cache.Set(strconv.Itoa(item.ID), item, ttlcache.DefaultTTL) 114 | } 115 | 116 | b.ResetTimer() 117 | for i := 0; i < b.N; i++ { 118 | key := strconv.Itoa(rand.Intn(numItems)) 119 | _ = cache.Get(key) 120 | } 121 | } 122 | 123 | func BenchmarkTTLCache_SetAndGet(b *testing.B) { 124 | cache := ttlcache.New[string, testItem]( 125 | ttlcache.WithTTL[string, testItem](5 * time.Minute), 126 | ) 127 | go cache.Start() 128 | defer cache.Stop() 129 | 130 | items := generateRandomItems(numItems) 131 | 132 | b.ResetTimer() 133 | for i := 0; i < b.N; i++ { 134 | if i%2 == 0 { 135 | item := items[i%numItems] 136 | cache.Set(strconv.Itoa(item.ID), item, ttlcache.DefaultTTL) 137 | } else { 138 | key := strconv.Itoa(rand.Intn(numItems)) 139 | _ = cache.Get(key) 140 | } 141 | } 142 | } 143 | 144 | // Benchmarks for expirable-cache 145 | func BenchmarkExpirableCache_Set(b *testing.B) { 146 | cache := expcache.NewCache[string, testItem]().WithTTL(5 * time.Minute) 147 | items := generateRandomItems(numItems) 148 | 149 | b.ResetTimer() 150 | for i := 0; i < b.N; i++ { 151 | item := items[i%numItems] 152 | cache.Set(strconv.Itoa(item.ID), item, 0) // use default TTL 153 | } 154 | } 155 | 156 | func BenchmarkExpirableCache_Get(b *testing.B) { 157 | cache := expcache.NewCache[string, testItem]().WithTTL(5 * time.Minute) 158 | items := generateRandomItems(numItems) 159 | 160 | // Populate cache 161 | for _, item := range items { 162 | cache.Set(strconv.Itoa(item.ID), item, 0) 163 | } 164 | 165 | b.ResetTimer() 166 | for i := 0; i < b.N; i++ { 167 | key := strconv.Itoa(rand.Intn(numItems)) 168 | _, _ = cache.Get(key) 169 | } 170 | } 171 | 172 | func BenchmarkExpirableCache_SetAndGet(b *testing.B) { 173 | cache := expcache.NewCache[string, testItem]().WithTTL(5 * time.Minute) 174 | items := generateRandomItems(numItems) 175 | 176 | b.ResetTimer() 177 | for i := 0; i < b.N; i++ { 178 | if i%2 == 0 { 179 | item := items[i%numItems] 180 | cache.Set(strconv.Itoa(item.ID), item, 0) 181 | } else { 182 | key := strconv.Itoa(rand.Intn(numItems)) 183 | _, _ = cache.Get(key) 184 | } 185 | } 186 | } 187 | 188 | // Benchmarks for Ristretto cache 189 | func BenchmarkRistretto_Set(b *testing.B) { 190 | // Create a new cache with a size of 100MB 191 | cache, err := ristretto.NewCache(&ristretto.Config{ 192 | NumCounters: 1e7, // number of keys to track frequency of (10M) 193 | MaxCost: 1 << 28, // maximum cost of cache (100MB) 194 | BufferItems: 64, // number of keys per Get buffer 195 | }) 196 | if err != nil { 197 | b.Fatal(err) 198 | } 199 | defer cache.Close() 200 | items := generateRandomItems(numItems) 201 | 202 | b.ResetTimer() 203 | for i := 0; i < b.N; i++ { 204 | item := items[i%numItems] 205 | cache.Set(strconv.Itoa(item.ID), item, 1) 206 | cache.Wait() // ensure item is set before next operation 207 | } 208 | } 209 | 210 | func BenchmarkRistretto_Get(b *testing.B) { 211 | cache, err := ristretto.NewCache(&ristretto.Config{ 212 | NumCounters: 1e7, // number of keys to track frequency of (10M) 213 | MaxCost: 1 << 28, // maximum cost of cache (100MB) 214 | BufferItems: 64, // number of keys per Get buffer 215 | }) 216 | if err != nil { 217 | b.Fatal(err) 218 | } 219 | defer cache.Close() 220 | items := generateRandomItems(numItems) 221 | 222 | // Populate cache 223 | for _, item := range items { 224 | cache.Set(strconv.Itoa(item.ID), item, 1) 225 | } 226 | cache.Wait() // ensure all items are set 227 | 228 | b.ResetTimer() 229 | for i := 0; i < b.N; i++ { 230 | key := strconv.Itoa(rand.Intn(numItems)) 231 | _, _ = cache.Get(key) 232 | } 233 | } 234 | 235 | func BenchmarkRistretto_SetAndGet(b *testing.B) { 236 | cache, err := ristretto.NewCache(&ristretto.Config{ 237 | NumCounters: 1e7, // number of keys to track frequency of (10M) 238 | MaxCost: 1 << 28, // maximum cost of cache (100MB) 239 | BufferItems: 64, // number of keys per Get buffer 240 | }) 241 | if err != nil { 242 | b.Fatal(err) 243 | } 244 | defer cache.Close() 245 | items := generateRandomItems(numItems) 246 | 247 | // Populate cache so gets have something to find 248 | for _, item := range items { 249 | cache.Set(strconv.Itoa(item.ID), item, 1) 250 | } 251 | cache.Wait() // ensure all items are set 252 | 253 | b.ResetTimer() 254 | for i := 0; i < b.N; i++ { 255 | if i%2 == 0 { 256 | item := items[i%numItems] 257 | cache.Set(strconv.Itoa(item.ID), item, 1) 258 | } else { 259 | key := strconv.Itoa(rand.Intn(numItems)) 260 | _, _ = cache.Get(key) 261 | } 262 | } 263 | } 264 | 265 | // Benchmark interface{} vs generic access patterns 266 | func BenchmarkGoCache_GetWithTypeAssertion(b *testing.B) { 267 | cache := gocache.New(5*time.Minute, 10*time.Minute) 268 | items := generateRandomItems(numItems) 269 | 270 | // Populate cache 271 | for _, item := range items { 272 | cache.Set(strconv.Itoa(item.ID), item, gocache.DefaultExpiration) 273 | } 274 | 275 | b.ResetTimer() 276 | for i := 0; i < b.N; i++ { 277 | key := strconv.Itoa(rand.Intn(numItems)) 278 | if val, found := cache.Get(key); found { 279 | _ = val.(testItem) 280 | } 281 | } 282 | } 283 | 284 | func BenchmarkTTLCache_GetWithoutTypeAssertion(b *testing.B) { 285 | cache := ttlcache.New[string, testItem]( 286 | ttlcache.WithTTL[string, testItem](5 * time.Minute), 287 | ) 288 | go cache.Start() 289 | defer cache.Stop() 290 | 291 | items := generateRandomItems(numItems) 292 | 293 | // Populate cache 294 | for _, item := range items { 295 | cache.Set(strconv.Itoa(item.ID), item, ttlcache.DefaultTTL) 296 | } 297 | 298 | b.ResetTimer() 299 | for i := 0; i < b.N; i++ { 300 | key := strconv.Itoa(rand.Intn(numItems)) 301 | if item := cache.Get(key); item != nil { 302 | _ = item.Value() 303 | } 304 | } 305 | } 306 | 307 | func BenchmarkExpirableCache_GetWithoutTypeAssertion(b *testing.B) { 308 | cache := expcache.NewCache[string, testItem]().WithTTL(5 * time.Minute) 309 | items := generateRandomItems(numItems) 310 | 311 | // Populate cache 312 | for _, item := range items { 313 | cache.Set(strconv.Itoa(item.ID), item, 0) 314 | } 315 | 316 | b.ResetTimer() 317 | for i := 0; i < b.N; i++ { 318 | key := strconv.Itoa(rand.Intn(numItems)) 319 | if val, found := cache.Get(key); found { 320 | _ = val 321 | } 322 | } 323 | } 324 | 325 | func BenchmarkRistretto_GetWithTypeAssertion(b *testing.B) { 326 | cache, err := ristretto.NewCache(&ristretto.Config{ 327 | NumCounters: 1e7, // number of keys to track frequency of (10M) 328 | MaxCost: 1 << 28, // maximum cost of cache (100MB) 329 | BufferItems: 64, // number of keys per Get buffer 330 | }) 331 | if err != nil { 332 | b.Fatal(err) 333 | } 334 | defer cache.Close() 335 | items := generateRandomItems(numItems) 336 | 337 | // Populate cache 338 | for _, item := range items { 339 | cache.Set(strconv.Itoa(item.ID), item, 1) 340 | } 341 | cache.Wait() // ensure all items are set 342 | 343 | b.ResetTimer() 344 | for i := 0; i < b.N; i++ { 345 | key := strconv.Itoa(rand.Intn(numItems)) 346 | if val, found := cache.Get(key); found { 347 | _ = val.(testItem) 348 | } 349 | } 350 | } 351 | 352 | // Real-world scenario benchmark 353 | func BenchmarkGoCache_RealWorldScenario(b *testing.B) { 354 | cache := gocache.New(5*time.Minute, 10*time.Minute) 355 | items := generateRandomItems(numItems) 356 | 357 | // Populate cache with 80% of items 358 | for i := 0; i < int(float64(numItems)*0.8); i++ { 359 | cache.Set(strconv.Itoa(items[i].ID), items[i], gocache.DefaultExpiration) 360 | } 361 | 362 | b.ResetTimer() 363 | for i := 0; i < b.N; i++ { 364 | key := strconv.Itoa(rand.Intn(numItems)) 365 | if val, found := cache.Get(key); found { 366 | _ = val.(testItem) 367 | } else { 368 | // Cache miss, add to cache 369 | index := rand.Intn(numItems) 370 | cache.Set(strconv.Itoa(index), items[index], gocache.DefaultExpiration) 371 | } 372 | } 373 | } 374 | 375 | func BenchmarkTTLCache_RealWorldScenario(b *testing.B) { 376 | cache := ttlcache.New[string, testItem]( 377 | ttlcache.WithTTL[string, testItem](5 * time.Minute), 378 | ) 379 | go cache.Start() 380 | defer cache.Stop() 381 | 382 | items := generateRandomItems(numItems) 383 | 384 | // Populate cache with 80% of items 385 | for i := 0; i < int(float64(numItems)*0.8); i++ { 386 | cache.Set(strconv.Itoa(items[i].ID), items[i], ttlcache.DefaultTTL) 387 | } 388 | 389 | b.ResetTimer() 390 | for i := 0; i < b.N; i++ { 391 | key := strconv.Itoa(rand.Intn(numItems)) 392 | if item := cache.Get(key); item != nil { 393 | _ = item.Value() 394 | } else { 395 | // Cache miss, add to cache 396 | index := rand.Intn(numItems) 397 | cache.Set(strconv.Itoa(index), items[index], ttlcache.DefaultTTL) 398 | } 399 | } 400 | } 401 | 402 | func BenchmarkExpirableCache_RealWorldScenario(b *testing.B) { 403 | cache := expcache.NewCache[string, testItem]().WithTTL(5 * time.Minute) 404 | items := generateRandomItems(numItems) 405 | 406 | // Populate cache with 80% of items 407 | for i := 0; i < int(float64(numItems)*0.8); i++ { 408 | cache.Set(strconv.Itoa(items[i].ID), items[i], 0) 409 | } 410 | 411 | b.ResetTimer() 412 | for i := 0; i < b.N; i++ { 413 | key := strconv.Itoa(rand.Intn(numItems)) 414 | if val, found := cache.Get(key); found { 415 | _ = val 416 | } else { 417 | // Cache miss, add to cache 418 | index := rand.Intn(numItems) 419 | cache.Set(strconv.Itoa(index), items[index], 0) 420 | } 421 | } 422 | } 423 | 424 | func BenchmarkRistretto_RealWorldScenario(b *testing.B) { 425 | cache, err := ristretto.NewCache(&ristretto.Config{ 426 | NumCounters: 1e7, // number of keys to track frequency of (10M) 427 | MaxCost: 1 << 28, // maximum cost of cache (100MB) 428 | BufferItems: 64, // number of keys per Get buffer 429 | }) 430 | if err != nil { 431 | b.Fatal(err) 432 | } 433 | defer cache.Close() 434 | items := generateRandomItems(numItems) 435 | 436 | // Populate cache with 80% of items 437 | for i := 0; i < int(float64(numItems)*0.8); i++ { 438 | cache.Set(strconv.Itoa(items[i].ID), items[i], 1) 439 | } 440 | cache.Wait() // ensure all items are set 441 | 442 | b.ResetTimer() 443 | for i := 0; i < b.N; i++ { 444 | key := strconv.Itoa(rand.Intn(numItems)) 445 | if val, found := cache.Get(key); found { 446 | _ = val.(testItem) 447 | } else { 448 | // Cache miss, add to cache 449 | index := rand.Intn(numItems) 450 | cache.Set(strconv.Itoa(index), items[index], 1) 451 | } 452 | } 453 | } 454 | --------------------------------------------------------------------------------