├── .github └── workflows │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── smartcache.go ├── smartcache_test.go └── version.go /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | pull_request: {} 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | - macOS-latest 16 | - windows-latest 17 | steps: 18 | - name: setup go 19 | uses: actions/setup-go@v1 20 | with: 21 | go-version: 1.x 22 | - name: checkout 23 | uses: actions/checkout@v1 24 | with: 25 | fetch-depth: 1 26 | - name: lint 27 | run: | 28 | GO111MODULE=off GOBIN=$(pwd)/bin go get golang.org/x/lint/golint 29 | bin/golint -set_exit_status ./... 30 | if: "matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'" 31 | - name: test 32 | run: go test -coverprofile coverage.out -covermode atomic ./... 33 | - name: Convert coverage to lcov 34 | uses: jandelgado/gcov2lcov-action@v1.0.0 35 | with: 36 | infile: coverage.out 37 | outfile: coverage.lcov 38 | if: "matrix.os == 'ubuntu-latest'" 39 | - name: Coveralls 40 | uses: coverallsapp/github-action@master 41 | with: 42 | github-token: ${{ secrets.github_token }} 43 | path-to-lcov: coverage.lcov 44 | if: "matrix.os == 'ubuntu-latest'" 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.github 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.0.1](https://github.com/Songmu/smartcache/compare/ec40bbb1a182...v0.0.1) (2020-03-21) 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Songmu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | u := $(if $(update),-u) 2 | 3 | export GO111MODULE=on 4 | 5 | .PHONY: deps 6 | deps: 7 | go get ${u} -d 8 | go mod tidy 9 | 10 | .PHONY: devel-deps 11 | devel-deps: 12 | sh -c '\ 13 | tmpdir=$$(mktemp -d); \ 14 | cd $$tmpdir; \ 15 | go get ${u} \ 16 | golang.org/x/lint/golint \ 17 | github.com/Songmu/godzil/cmd/godzil; \ 18 | rm -rf $$tmpdir' 19 | 20 | .PHONY: test 21 | test: 22 | go test 23 | 24 | .PHONY: lint 25 | lint: devel-deps 26 | golint -set_exit_status 27 | 28 | .PHONY: release 29 | release: devel-deps 30 | godzil release 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | smartcache 2 | ======= 3 | 4 | [![Test Status](https://github.com/Songmu/smartcache/workflows/test/badge.svg?branch=master)][actions] 5 | [![Coverage Status](https://coveralls.io/repos/Songmu/smartcache/badge.svg?branch=master)][coveralls] 6 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)][license] 7 | [![GoDoc](https://godoc.org/github.com/Songmu/smartcache?status.svg)][godoc] 8 | 9 | [actions]: https://github.com/Songmu/smartcache/actions?workflow=test 10 | [coveralls]: https://coveralls.io/r/Songmu/smartcache?branch=master 11 | [license]: https://github.com/Songmu/smartcache/blob/master/LICENSE 12 | [godoc]: https://godoc.org/github.com/Songmu/smartcache 13 | 14 | The smartcache realizes smart in memory cache generation to minimize process blocks by using soft expire limit 15 | 16 | ## Synopsis 17 | 18 | ```go 19 | var ( 20 | expire = 5*time.Minute 21 | softExpire = 1*time.Minute 22 | ) 23 | ca := smartcache.New(expire, softExpire, func(ctx context.Context) (interface{}, error) { 24 | val, err := genCache(ctx) 25 | return val, err 26 | }) 27 | 28 | val, err := ca.Get(context.Background()) 29 | ``` 30 | 31 | ## Description 32 | 33 | The smartcache is an in-memory cache library with avoiding the following problems. 34 | 35 | - thundering herd 36 | - block processing when regenerating 37 | - etc. 38 | 39 | To avoid the above problems, you can set a soft expire limit to Cache. The soft expired cached value is internally pre-warmed by a single goroutine and the value is replaced seamlessly. 40 | 41 | ## Installation 42 | 43 | ```console 44 | % go get github.com/Songmu/smartcache 45 | ``` 46 | 47 | ## Author 48 | 49 | [Songmu](https://github.com/Songmu) 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Songmu/smartcache 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/Songmu/flextime v0.0.6 7 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Songmu/flextime v0.0.6 h1:q9uTNwKY014E0AmCGOFt+0AaI5OXRty8gAalRyXdn9c= 2 | github.com/Songmu/flextime v0.0.6/go.mod h1:ofUSZ/qj7f1BfQQ6rEH4ovewJ0SZmLOjBF1xa8iE87Q= 3 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= 4 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 5 | -------------------------------------------------------------------------------- /smartcache.go: -------------------------------------------------------------------------------- 1 | package smartcache 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/Songmu/flextime" 9 | "golang.org/x/sync/singleflight" 10 | ) 11 | 12 | // Cache for cache 13 | type Cache struct { 14 | expire time.Duration 15 | softExpire time.Duration 16 | generator func(context.Context) (interface{}, error) 17 | 18 | group singleflight.Group 19 | 20 | mu sync.RWMutex 21 | value interface{} 22 | nextSoftExpire time.Time 23 | nextExpire time.Time 24 | } 25 | 26 | // New returns new Cache 27 | func New(expire, softExpire time.Duration, gen func(context.Context) (interface{}, error)) *Cache { 28 | return &Cache{ 29 | expire: expire, 30 | softExpire: softExpire, 31 | generator: gen, 32 | } 33 | } 34 | 35 | func (ca *Cache) renew(ctx context.Context) (interface{}, error) { 36 | val, err, _ := ca.group.Do("renew", func() (interface{}, error) { 37 | now := flextime.Now() 38 | if now.Before(ca.nextExpire) && (ca.nextSoftExpire.IsZero() || now.Before(ca.nextSoftExpire)) { 39 | return ca.value, nil 40 | } 41 | val, err := ca.generator(ctx) 42 | if err == nil { 43 | ca.mu.Lock() 44 | ca.value = val 45 | if ca.softExpire > 0 { 46 | ca.nextSoftExpire = now.Add(ca.softExpire) 47 | } 48 | ca.nextExpire = now.Add(ca.expire) 49 | ca.mu.Unlock() 50 | } 51 | return val, err 52 | }) 53 | return val, err 54 | } 55 | 56 | // Get the cached value 57 | func (ca *Cache) Get(ctx context.Context) (interface{}, error) { 58 | now := flextime.Now() 59 | ca.mu.RLock() 60 | currVal := ca.value 61 | softExpire := ca.nextSoftExpire 62 | expire := ca.nextExpire 63 | ca.mu.RUnlock() 64 | 65 | if now.After(expire) { 66 | return ca.renew(ctx) 67 | } 68 | if !softExpire.IsZero() && now.After(softExpire) { 69 | go ca.renew(ctx) 70 | } 71 | return currVal, nil 72 | } 73 | -------------------------------------------------------------------------------- /smartcache_test.go: -------------------------------------------------------------------------------- 1 | package smartcache_test 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/Songmu/flextime" 10 | "github.com/Songmu/smartcache" 11 | ) 12 | 13 | func TestCache_Get(t *testing.T) { 14 | now := flextime.Now() 15 | defer flextime.Set(now)() 16 | 17 | var counter int 18 | ca := smartcache.New(10*time.Second, time.Second, func(ctx context.Context) (interface{}, error) { 19 | // Actually time.Sleep instead of flextime to emulate long running operations 20 | time.Sleep(50 * time.Millisecond) 21 | counter++ 22 | return counter, nil 23 | }) 24 | 25 | t.Run("create new cache", func(t *testing.T) { 26 | v, err := ca.Get(context.Background()) 27 | if err != nil { 28 | t.Errorf("error should be nil, but: %s", err) 29 | } 30 | if v.(int) != 1 { 31 | t.Errorf("value should be 1, but %v", v) 32 | } 33 | }) 34 | 35 | t.Run("get from cache", func(t *testing.T) { 36 | v, err := ca.Get(context.Background()) 37 | if err != nil { 38 | t.Errorf("error should be nil, but: %s", err) 39 | } 40 | if v.(int) != 1 { 41 | t.Errorf("value should be 1, but %v", v) 42 | } 43 | }) 44 | 45 | t.Run("soft expire and renew internal value", func(t *testing.T) { 46 | flextime.Sleep(2 * time.Second) 47 | // check the concurrent cache updates won't conflict 48 | var ( 49 | wg = sync.WaitGroup{} 50 | para = 10 51 | ) 52 | wg.Add(para) 53 | for i := 0; i < para; i++ { 54 | go func() { 55 | defer wg.Done() 56 | ca.Get(context.Background()) 57 | }() 58 | } 59 | wg.Wait() 60 | v, err := ca.Get(context.Background()) 61 | if err != nil { 62 | t.Errorf("error should be nil, but: %s", err) 63 | } 64 | if v.(int) != 1 { 65 | t.Errorf("value should be 1, but %v", v) 66 | } 67 | }) 68 | 69 | t.Run("wait for internal value update", func(t *testing.T) { 70 | time.Sleep(80 * time.Millisecond) // use real time.Sleep for waiting cache update 71 | v, err := ca.Get(context.Background()) 72 | if err != nil { 73 | t.Errorf("error should be nil, but: %s", err) 74 | } 75 | if v.(int) != 2 { 76 | t.Errorf("value should be 2, but %v", v) 77 | } 78 | }) 79 | 80 | t.Run("hard expire", func(t *testing.T) { 81 | flextime.Sleep(11 * time.Second) 82 | var ( 83 | v interface{} 84 | err error 85 | ) 86 | go func() { 87 | v, err = ca.Get(context.Background()) 88 | }() 89 | time.Sleep(50 * time.Millisecond) 90 | // check the concurrent cache updates won't conflict 91 | var ( 92 | wg = sync.WaitGroup{} 93 | para = 10 94 | ) 95 | wg.Add(para) 96 | for i := 0; i < para; i++ { 97 | go func() { 98 | defer wg.Done() 99 | ca.Get(context.Background()) 100 | }() 101 | } 102 | wg.Wait() 103 | if err != nil { 104 | t.Errorf("error should be nil, but: %s", err) 105 | } 106 | if v.(int) != 3 { 107 | t.Errorf("value should be 3, but %v", v) 108 | } 109 | 110 | v, err = ca.Get(context.Background()) 111 | if err != nil { 112 | t.Errorf("error should be nil, but: %s", err) 113 | } 114 | if v.(int) != 3 { 115 | t.Errorf("value should be 3, but %v", v) 116 | } 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package smartcache 2 | 3 | const version = "0.0.1" 4 | --------------------------------------------------------------------------------