├── .editorconfig ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── cache.go ├── cache_test.go ├── codecov.yml ├── file.go ├── file_test.go ├── go.mod ├── go.sum ├── manager.go ├── manager_test.go ├── memory.go ├── memory_test.go ├── mongo ├── mongo.go └── mongo_test.go ├── mysql ├── mysql.go └── mysql_test.go ├── postgres ├── postgres.go └── postgres_test.go ├── redis ├── redis.go └── redis_test.go ├── sqlite ├── sqlite.go └── sqlite_test.go └── type.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://git.io/JCUAY 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | reviewers: 9 | - "flamego/core" 10 | commit-message: 11 | prefix: "mod:" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Describe the pull request 2 | 3 | A clear and concise description of what the pull request is about, i.e. what problem should be fixed? 4 | 5 | Link to the issue: 6 | 7 | ### Checklist 8 | 9 | - [ ] I agree to follow the [Code of Conduct](https://go.dev/conduct) by submitting this pull request. 10 | - [ ] I have read and acknowledge the [Contributing guide](https://github.com/flamego/flamego/blob/main/.github/contributing.md). 11 | - [ ] I have added test cases to cover the new code. 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - '.golangci.yml' 9 | - '.github/workflows/go.yml' 10 | pull_request: 11 | paths: 12 | - '**.go' 13 | - 'go.mod' 14 | - '.golangci.yml' 15 | - '.github/workflows/go.yml' 16 | env: 17 | GOPROXY: "https://proxy.golang.org" 18 | 19 | jobs: 20 | lint: 21 | name: Lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | - name: Run golangci-lint 27 | uses: golangci/golangci-lint-action@v4 28 | with: 29 | version: latest 30 | args: --timeout=30m 31 | 32 | test: 33 | name: Test 34 | strategy: 35 | matrix: 36 | go-version: [ 1.24.x ] 37 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 38 | runs-on: ${{ matrix.platform }} 39 | steps: 40 | - name: Install Go 41 | uses: actions/setup-go@v5 42 | with: 43 | go-version: ${{ matrix.go-version }} 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | - name: Run tests with coverage 47 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic 48 | 49 | postgres: 50 | name: Postgres 51 | strategy: 52 | matrix: 53 | go-version: [ 1.24.x ] 54 | platform: [ ubuntu-latest ] 55 | runs-on: ${{ matrix.platform }} 56 | services: 57 | postgres: 58 | image: postgres:9.6 59 | env: 60 | POSTGRES_PASSWORD: postgres 61 | options: >- 62 | --health-cmd pg_isready 63 | --health-interval 10s 64 | --health-timeout 5s 65 | --health-retries 5 66 | ports: 67 | - 5432:5432 68 | steps: 69 | - name: Install Go 70 | uses: actions/setup-go@v5 71 | with: 72 | go-version: ${{ matrix.go-version }} 73 | - name: Checkout code 74 | uses: actions/checkout@v4 75 | - name: Run tests with coverage 76 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./postgres 77 | env: 78 | PGPORT: 5432 79 | PGHOST: localhost 80 | PGUSER: postgres 81 | PGPASSWORD: postgres 82 | PGSSLMODE: disable 83 | 84 | redis: 85 | name: Redis 86 | strategy: 87 | matrix: 88 | go-version: [ 1.24.x ] 89 | platform: [ ubuntu-latest ] 90 | runs-on: ${{ matrix.platform }} 91 | services: 92 | redis: 93 | image: redis:4 94 | options: >- 95 | --health-cmd "redis-cli ping" 96 | --health-interval 10s 97 | --health-timeout 5s 98 | --health-retries 5 99 | ports: 100 | - 6379:6379 101 | steps: 102 | - name: Install Go 103 | uses: actions/setup-go@v5 104 | with: 105 | go-version: ${{ matrix.go-version }} 106 | - name: Checkout code 107 | uses: actions/checkout@v4 108 | - name: Run tests with coverage 109 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./redis 110 | env: 111 | REDIS_HOST: localhost 112 | REDIS_PORT: 6379 113 | 114 | mysql: 115 | name: MySQL 116 | strategy: 117 | matrix: 118 | go-version: [ 1.24.x ] 119 | platform: [ ubuntu-22.04 ] # Use the lowest version possible for backwards compatibility 120 | runs-on: ${{ matrix.platform }} 121 | steps: 122 | - name: Start MySQL server 123 | run: sudo systemctl start mysql 124 | - name: Install Go 125 | uses: actions/setup-go@v5 126 | with: 127 | go-version: ${{ matrix.go-version }} 128 | - name: Checkout code 129 | uses: actions/checkout@v4 130 | - name: Run tests with coverage 131 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./mysql 132 | env: 133 | MYSQL_USER: root 134 | MYSQL_PASSWORD: root 135 | MYSQL_HOST: localhost 136 | MYSQL_PORT: 3306 137 | 138 | mongo: 139 | name: Mongo 140 | strategy: 141 | matrix: 142 | go-version: [ 1.24.x ] 143 | platform: [ ubuntu-latest ] 144 | runs-on: ${{ matrix.platform }} 145 | services: 146 | mongodb: 147 | image: mongo:5 148 | env: 149 | MONGO_INITDB_ROOT_USERNAME: root 150 | MONGO_INITDB_ROOT_PASSWORD: password 151 | options: >- 152 | --health-cmd mongo 153 | --health-interval 10s 154 | --health-timeout 5s 155 | --health-retries 5 156 | ports: 157 | - 27017:27017 158 | steps: 159 | - name: Install Go 160 | uses: actions/setup-go@v5 161 | with: 162 | go-version: ${{ matrix.go-version }} 163 | - name: Checkout code 164 | uses: actions/checkout@v4 165 | - name: Run tests with coverage 166 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./mongo 167 | env: 168 | MONGODB_URI: mongodb://root:password@localhost:27017 169 | 170 | sqlite: 171 | name: SQLite 172 | strategy: 173 | matrix: 174 | go-version: [ 1.24.x ] 175 | platform: [ ubuntu-latest ] 176 | runs-on: ${{ matrix.platform }} 177 | steps: 178 | - name: Install Go 179 | uses: actions/setup-go@v5 180 | with: 181 | go-version: ${{ matrix.go-version }} 182 | - name: Checkout code 183 | uses: actions/checkout@v4 184 | - name: Run tests with coverage 185 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./sqlite 186 | -------------------------------------------------------------------------------- /.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 | /.idea 17 | .envrc 18 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | nakedret: 3 | max-func-lines: 0 # Disallow any unnamed return statement 4 | 5 | linters: 6 | enable: 7 | - unused 8 | - errcheck 9 | - gosimple 10 | - govet 11 | - ineffassign 12 | - staticcheck 13 | - typecheck 14 | - nakedret 15 | - gofmt 16 | - rowserrcheck 17 | - unconvert 18 | - goimports 19 | - unparam 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Flamego 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cache 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/flamego/cache/go.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/flamego/cache/actions?query=workflow%3AGo) 4 | [![Codecov](https://img.shields.io/codecov/c/gh/flamego/cache?logo=codecov&style=for-the-badge)](https://app.codecov.io/gh/flamego/cache) 5 | [![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/github.com/flamego/cache?tab=doc) 6 | 7 | Package cache is a middleware that provides the cache management for [Flamego](https://github.com/flamego/flamego). 8 | 9 | ## Installation 10 | 11 | The minimum requirement of Go is **1.24**. 12 | 13 | go get github.com/flamego/cache 14 | 15 | ## Getting started 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "net/http" 22 | "time" 23 | 24 | "github.com/flamego/cache" 25 | "github.com/flamego/flamego" 26 | ) 27 | 28 | func main() { 29 | f := flamego.Classic() 30 | f.Use(cache.Cacher()) 31 | f.Get("/set", func(r *http.Request, cache cache.Cache) error { 32 | return cache.Set(r.Context(), "cooldown", true, time.Minute) 33 | }) 34 | f.Get("/get", func(r *http.Request, cache cache.Cache) string { 35 | v, err := cache.Get(r.Context(), "cooldown") 36 | if err != nil && err != os.ErrNotExist { 37 | return err.Error() 38 | } 39 | 40 | cooldown, ok := v.(bool) 41 | if !ok || !cooldown { 42 | return "It has been cooled" 43 | } 44 | return "Still hot" 45 | }) 46 | f.Run() 47 | } 48 | ``` 49 | 50 | ## Getting help 51 | 52 | - Read [documentation and examples](https://flamego.dev/middleware/cache.html). 53 | - Please [file an issue](https://github.com/flamego/flamego/issues) or [start a discussion](https://github.com/flamego/flamego/discussions) on the [flamego/flamego](https://github.com/flamego/flamego) repository. 54 | 55 | ## License 56 | 57 | This project is under the MIT License. See the [LICENSE](LICENSE) file for the full license text. 58 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/flamego/flamego" 12 | ) 13 | 14 | // Cache is a cache store with capabilities of setting, reading, deleting and GC 15 | // cache data. 16 | type Cache interface { 17 | // Get returns the value of given key in the cache. It returns os.ErrNotExist if 18 | // no such key exists or the key has expired. 19 | Get(ctx context.Context, key string) (interface{}, error) 20 | // Set sets the value of the key with given lifetime in the cache. 21 | Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error 22 | // Delete deletes a key from the cache. 23 | Delete(ctx context.Context, key string) error 24 | // Flush wipes out all existing data in the cache. 25 | Flush(ctx context.Context) error 26 | // GC performs a GC operation on the cache store. 27 | GC(ctx context.Context) error 28 | } 29 | 30 | // Options contains options for the cache.Cacher middleware. 31 | type Options struct { 32 | // Initer is the initialization function of the cache store. Default is 33 | // cache.MemoryIniter. 34 | Initer Initer 35 | // Config is the configuration object to be passed to the Initer for the cache 36 | // store. 37 | Config interface{} 38 | // GCInterval is the time interval for GC operations. Default is 5 minutes. 39 | GCInterval time.Duration 40 | // ErrorFunc is the function used to print errors when something went wrong on 41 | // the background. Default is to drop errors silently. 42 | ErrorFunc func(err error) 43 | } 44 | 45 | // Cacher returns a middleware handler that injects cache.Cache into the request 46 | // context, which is used for manipulating cache data. 47 | func Cacher(opts ...Options) flamego.Handler { 48 | var opt Options 49 | if len(opts) > 0 { 50 | opt = opts[0] 51 | } 52 | 53 | parseOptions := func(opts Options) Options { 54 | if opts.Initer == nil { 55 | opts.Initer = MemoryIniter() 56 | } 57 | 58 | if opts.GCInterval.Seconds() < 1 { 59 | opts.GCInterval = 5 * time.Minute 60 | } 61 | 62 | if opts.ErrorFunc == nil { 63 | opts.ErrorFunc = func(error) {} 64 | } 65 | 66 | return opts 67 | } 68 | 69 | opt = parseOptions(opt) 70 | ctx := context.Background() 71 | 72 | store, err := opt.Initer(ctx, opt.Config) 73 | if err != nil { 74 | panic("cache: " + err.Error()) 75 | } 76 | 77 | mgr := newManager(store) 78 | mgr.startGC(ctx, opt.GCInterval, opt.ErrorFunc) 79 | 80 | return flamego.ContextInvoker(func(c flamego.Context) { 81 | c.Map(store) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "bytes" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | 15 | "github.com/flamego/flamego" 16 | ) 17 | 18 | func TestCacher(t *testing.T) { 19 | f := flamego.NewWithLogger(&bytes.Buffer{}) 20 | f.Use(Cacher()) 21 | f.Get("/", func(c flamego.Context, cache Cache) { 22 | _ = cache.GC(c.Request().Context()) 23 | }) 24 | 25 | resp := httptest.NewRecorder() 26 | req, err := http.NewRequest(http.MethodGet, "/", nil) 27 | assert.Nil(t, err) 28 | 29 | f.ServeHTTP(resp, req) 30 | 31 | assert.Equal(t, http.StatusOK, resp.Code) 32 | } 33 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "60...95" 3 | status: 4 | project: 5 | default: 6 | threshold: 1% 7 | 8 | comment: 9 | layout: 'diff' 10 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "crypto/sha1" 11 | "encoding/gob" 12 | "encoding/hex" 13 | "fmt" 14 | "io/fs" 15 | "os" 16 | "path/filepath" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/pkg/errors" 21 | ) 22 | 23 | // fileItem is a file cache item. 24 | type fileItem struct { 25 | Value interface{} 26 | ExpiredAt time.Time // The expiration time of the cache item 27 | } 28 | 29 | var _ Cache = (*fileStore)(nil) 30 | 31 | // fileStore is a file implementation of the cache store. 32 | type fileStore struct { 33 | nowFunc func() time.Time // The function to return the current time 34 | rootDir string // The root directory of file cache items stored on the local file system 35 | encoder Encoder // The encoder to encode the cache data before saving 36 | decoder Decoder // The decoder to decode binary to cache data after reading 37 | } 38 | 39 | // newFileStore returns a new file cache store based on given configuration. 40 | func newFileStore(cfg FileConfig) *fileStore { 41 | return &fileStore{ 42 | nowFunc: cfg.nowFunc, 43 | rootDir: cfg.RootDir, 44 | encoder: cfg.Encoder, 45 | decoder: cfg.Decoder, 46 | } 47 | } 48 | 49 | // filename returns the computed file name with given key. 50 | func (s *fileStore) filename(key string) string { 51 | h := sha1.Sum([]byte(key)) 52 | hash := hex.EncodeToString(h[:]) 53 | return filepath.Join(s.rootDir, string(hash[0]), string(hash[1]), hash) 54 | } 55 | 56 | // isFile returns true if given path exists as a file (i.e. not a directory). 57 | func isFile(path string) bool { 58 | f, e := os.Stat(path) 59 | if e != nil { 60 | return false 61 | } 62 | return !f.IsDir() 63 | } 64 | 65 | func (s *fileStore) read(filename string) (*fileItem, error) { 66 | binary, err := os.ReadFile(filename) 67 | if err != nil { 68 | return nil, errors.Wrapf(err, "read file") 69 | } 70 | 71 | v, err := s.decoder(binary) 72 | if err != nil { 73 | return nil, errors.Wrap(err, "decode") 74 | } 75 | 76 | item, ok := v.(*fileItem) 77 | if !ok { 78 | return nil, os.ErrNotExist 79 | } 80 | return item, nil 81 | } 82 | 83 | func (s *fileStore) Get(ctx context.Context, key string) (interface{}, error) { 84 | filename := s.filename(key) 85 | 86 | if !isFile(filename) { 87 | return nil, os.ErrNotExist 88 | } 89 | 90 | item, err := s.read(filename) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | if !item.ExpiredAt.After(s.nowFunc()) { 96 | go func() { _ = s.Delete(ctx, key) }() 97 | return nil, os.ErrNotExist 98 | } 99 | return item.Value, nil 100 | } 101 | 102 | func (s *fileStore) Set(_ context.Context, key string, value interface{}, lifetime time.Duration) error { 103 | binary, err := s.encoder(fileItem{ 104 | Value: value, 105 | ExpiredAt: s.nowFunc().Add(lifetime).UTC(), 106 | }) 107 | if err != nil { 108 | return errors.Wrap(err, "encode") 109 | } 110 | 111 | filename := s.filename(key) 112 | err = os.MkdirAll(filepath.Dir(filename), os.ModePerm) 113 | if err != nil { 114 | return errors.Wrap(err, "create parent directories") 115 | } 116 | 117 | err = os.WriteFile(filename, binary, 0600) 118 | if err != nil { 119 | return errors.Wrap(err, "write file") 120 | } 121 | return nil 122 | } 123 | 124 | func (s *fileStore) Delete(_ context.Context, key string) error { 125 | return os.Remove(s.filename(key)) 126 | } 127 | 128 | func (s *fileStore) Flush(_ context.Context) error { 129 | return os.RemoveAll(s.rootDir) 130 | } 131 | 132 | func (s *fileStore) GC(ctx context.Context) error { 133 | err := filepath.WalkDir(s.rootDir, func(path string, d fs.DirEntry, err error) error { 134 | select { 135 | case <-ctx.Done(): 136 | return ctx.Err() 137 | default: 138 | } 139 | 140 | if err != nil { 141 | return err 142 | } 143 | if d.IsDir() { 144 | return nil 145 | } 146 | 147 | item, err := s.read(path) 148 | if err != nil { 149 | if errors.Cause(err).(*os.PathError).Err == syscall.ENOENT { 150 | return nil // Consider file not exists as expired. 151 | } 152 | return err 153 | } 154 | 155 | if item.ExpiredAt.After(s.nowFunc()) { 156 | return nil 157 | } 158 | 159 | err = os.Remove(path) 160 | if err != nil && err.(*os.PathError).Err != syscall.ENOENT { 161 | return err 162 | } 163 | return nil 164 | }) 165 | if err != nil && err != ctx.Err() { 166 | return err 167 | } 168 | return nil 169 | } 170 | 171 | // FileConfig contains options for the file cache store. 172 | type FileConfig struct { 173 | nowFunc func() time.Time // For tests only 174 | 175 | // RootDir is the root directory of file cache items stored on the local file 176 | // system. Default is "cache". 177 | RootDir string 178 | // Encoder is the encoder to encode cache data. Default is a Gob encoder. 179 | Encoder Encoder 180 | // Decoder is the decoder to decode cache data. Default is a Gob decoder. 181 | Decoder Decoder 182 | } 183 | 184 | // FileIniter returns the Initer for the file cache store. 185 | func FileIniter() Initer { 186 | return func(_ context.Context, args ...interface{}) (Cache, error) { 187 | var cfg *FileConfig 188 | for i := range args { 189 | switch v := args[i].(type) { 190 | case FileConfig: 191 | cfg = &v 192 | } 193 | } 194 | 195 | if cfg == nil { 196 | return nil, fmt.Errorf("config object with the type '%T' not found", FileConfig{}) 197 | } 198 | if cfg.nowFunc == nil { 199 | cfg.nowFunc = time.Now 200 | } 201 | if cfg.RootDir == "" { 202 | cfg.RootDir = "cache" 203 | } 204 | if cfg.Encoder == nil { 205 | cfg.Encoder = GobEncoder 206 | } 207 | if cfg.Decoder == nil { 208 | cfg.Decoder = func(binary []byte) (interface{}, error) { 209 | buf := bytes.NewBuffer(binary) 210 | var v fileItem 211 | return &v, gob.NewDecoder(buf).Decode(&v) 212 | } 213 | } 214 | 215 | return newFileStore(*cfg), nil 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/gob" 11 | "net/http" 12 | "net/http/httptest" 13 | "os" 14 | "path/filepath" 15 | "testing" 16 | "time" 17 | 18 | "github.com/stretchr/testify/assert" 19 | 20 | "github.com/flamego/flamego" 21 | ) 22 | 23 | func TestFileStore(t *testing.T) { 24 | gob.Register(time.Duration(0)) 25 | 26 | f := flamego.NewWithLogger(&bytes.Buffer{}) 27 | f.Use(Cacher( 28 | Options{ 29 | Initer: FileIniter(), 30 | Config: FileConfig{ 31 | nowFunc: time.Now, 32 | RootDir: filepath.Join(os.TempDir(), "cache"), 33 | }, 34 | }, 35 | )) 36 | 37 | f.Get("/", func(c flamego.Context, cache Cache) { 38 | ctx := c.Request().Context() 39 | 40 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 41 | 42 | v, err := cache.Get(ctx, "username") 43 | assert.Nil(t, err) 44 | username, ok := v.(string) 45 | assert.True(t, ok) 46 | assert.Equal(t, "flamego", username) 47 | 48 | assert.Nil(t, cache.Delete(ctx, "username")) 49 | _, err = cache.Get(ctx, "username") 50 | assert.Equal(t, os.ErrNotExist, err) 51 | 52 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 53 | v, err = cache.Get(ctx, "timeout") 54 | assert.Nil(t, err) 55 | timeout, ok := v.(time.Duration) 56 | assert.True(t, ok) 57 | assert.Equal(t, time.Minute, timeout) 58 | 59 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 60 | assert.Nil(t, cache.Flush(ctx)) 61 | _, err = cache.Get(ctx, "random") 62 | assert.Equal(t, os.ErrNotExist, err) 63 | }) 64 | 65 | resp := httptest.NewRecorder() 66 | req, err := http.NewRequest(http.MethodGet, "/", nil) 67 | assert.Nil(t, err) 68 | 69 | f.ServeHTTP(resp, req) 70 | 71 | assert.Equal(t, http.StatusOK, resp.Code) 72 | } 73 | 74 | func TestFileStore_GC(t *testing.T) { 75 | ctx := context.Background() 76 | now := time.Now() 77 | store, err := FileIniter()( 78 | ctx, 79 | FileConfig{ 80 | nowFunc: func() time.Time { return now }, 81 | RootDir: filepath.Join(os.TempDir(), "cache"), 82 | }, 83 | ) 84 | assert.Nil(t, err) 85 | 86 | assert.Nil(t, store.Set(ctx, "1", "1", time.Second)) 87 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 88 | assert.Nil(t, store.Set(ctx, "3", "3", 3*time.Second)) 89 | 90 | // Read on an expired cache item should remove it 91 | now = now.Add(2 * time.Second) 92 | _, err = store.Get(ctx, "1") 93 | assert.Equal(t, os.ErrNotExist, err) 94 | 95 | // "2" should be recycled 96 | assert.Nil(t, store.GC(ctx)) 97 | _, err = store.Get(ctx, "2") 98 | assert.Equal(t, os.ErrNotExist, err) 99 | 100 | // "3" should be returned 101 | v, err := store.Get(ctx, "3") 102 | assert.Nil(t, err) 103 | assert.Equal(t, "3", v) 104 | } 105 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flamego/cache 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/flamego/flamego v1.9.5 7 | github.com/go-sql-driver/mysql v1.9.2 8 | github.com/jackc/pgx/v4 v4.18.3 9 | github.com/pkg/errors v0.9.1 10 | github.com/redis/go-redis/v9 v9.9.0 11 | github.com/stretchr/testify v1.10.0 12 | go.mongodb.org/mongo-driver v1.17.3 13 | modernc.org/sqlite v1.37.1 14 | ) 15 | 16 | require ( 17 | filippo.io/edwards25519 v1.1.0 // indirect 18 | github.com/alecthomas/participle/v2 v2.1.1 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/charmbracelet/lipgloss v0.10.0 // indirect 22 | github.com/charmbracelet/log v0.4.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 25 | github.com/dustin/go-humanize v1.0.1 // indirect 26 | github.com/go-logfmt/logfmt v0.6.0 // indirect 27 | github.com/golang/snappy v0.0.4 // indirect 28 | github.com/google/uuid v1.6.0 // indirect 29 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 30 | github.com/jackc/pgconn v1.14.3 // indirect 31 | github.com/jackc/pgio v1.0.0 // indirect 32 | github.com/jackc/pgpassfile v1.0.0 // indirect 33 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 34 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 35 | github.com/jackc/pgtype v1.14.0 // indirect 36 | github.com/klauspost/compress v1.16.7 // indirect 37 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-runewidth v0.0.15 // indirect 40 | github.com/montanaflynn/stats v0.7.1 // indirect 41 | github.com/muesli/reflow v0.3.0 // indirect 42 | github.com/muesli/termenv v0.15.2 // indirect 43 | github.com/ncruces/go-strftime v0.1.9 // indirect 44 | github.com/pmezard/go-difflib v1.0.0 // indirect 45 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 46 | github.com/rivo/uniseg v0.4.7 // indirect 47 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 48 | github.com/xdg-go/scram v1.1.2 // indirect 49 | github.com/xdg-go/stringprep v1.0.4 // indirect 50 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 51 | golang.org/x/crypto v0.26.0 // indirect 52 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 53 | golang.org/x/sync v0.14.0 // indirect 54 | golang.org/x/sys v0.33.0 // indirect 55 | golang.org/x/text v0.17.0 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | modernc.org/libc v1.65.7 // indirect 58 | modernc.org/mathutil v1.7.1 // indirect 59 | modernc.org/memory v1.11.0 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= 5 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 6 | github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= 7 | github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= 8 | github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= 9 | github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= 10 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 11 | github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 14 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 15 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 16 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 17 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 18 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 19 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 20 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= 21 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 22 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 23 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 24 | github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 25 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 26 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 27 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 28 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 29 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 31 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 33 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 34 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 35 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 36 | github.com/flamego/flamego v1.9.5 h1:GbUHZ58bEaI6MfiC8SAaRR96VEHDGjA1dZVWN3qtmEQ= 37 | github.com/flamego/flamego v1.9.5/go.mod h1:n1CMZUtcP30xeJJ+di9E+wrfWWzptAxjkKabIV806to= 38 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 39 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 40 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 41 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 42 | github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= 43 | github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 44 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 45 | github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 46 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 47 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 48 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 49 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 50 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 51 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 52 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 53 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 54 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 55 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 56 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 57 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 58 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 59 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 60 | github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= 61 | github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 62 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 63 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 64 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 65 | github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= 66 | github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= 67 | github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= 68 | github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= 69 | github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= 70 | github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 71 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 72 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 73 | github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= 74 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= 75 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= 76 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 77 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 78 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 79 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 80 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 81 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 82 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 83 | github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 84 | github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 85 | github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= 86 | github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 87 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 88 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 89 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 90 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 91 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 92 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 93 | github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= 94 | github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= 95 | github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= 96 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 97 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 98 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 99 | github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= 100 | github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= 101 | github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= 102 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 103 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 104 | github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 105 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 106 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 107 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 108 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 109 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 110 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 111 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 112 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 113 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 114 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 115 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 116 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 117 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 118 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 119 | github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= 120 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 121 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 122 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 123 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 124 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 125 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 126 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 127 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 128 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 129 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 130 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 131 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 132 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 133 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= 134 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 135 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 136 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 137 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 138 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 139 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 140 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 141 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 142 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 143 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 144 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 145 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 146 | github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM= 147 | github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= 148 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 149 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 150 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 151 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 152 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 153 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 154 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 155 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 156 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 157 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 158 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 159 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 160 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 161 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 162 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 163 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 164 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 165 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 166 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 167 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 168 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 169 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 170 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 171 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 172 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 173 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 174 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 175 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 176 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= 177 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 178 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 179 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 180 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 181 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 182 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 183 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 184 | go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= 185 | go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 186 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 187 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 188 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 189 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 190 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 191 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 192 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 193 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 194 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 195 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 196 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 197 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 198 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 199 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 200 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 201 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 202 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 203 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 204 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 205 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 206 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 207 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 208 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 209 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 210 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 211 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 212 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 213 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 214 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 215 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 216 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 217 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 218 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 219 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 220 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 221 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 222 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 223 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 224 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 225 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 226 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 227 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 228 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 229 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 230 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 231 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 237 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 238 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 239 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 240 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 241 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 242 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 243 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 244 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 245 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 246 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 247 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 248 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 249 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 250 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 251 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 252 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 253 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 254 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 255 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 256 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 257 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 258 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 259 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 260 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 261 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 262 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 263 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 264 | golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 265 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 266 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 267 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 268 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 269 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 270 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 271 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 272 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 273 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 274 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 275 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 276 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 277 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 278 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 279 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 280 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 281 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 282 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 283 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 284 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 285 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 286 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 287 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= 288 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 289 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 290 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 291 | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= 292 | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 293 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 294 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 295 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 296 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 297 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 298 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 299 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 300 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 301 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= 302 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= 303 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 304 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 305 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 306 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 307 | -------------------------------------------------------------------------------- /manager.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "context" 9 | "time" 10 | ) 11 | 12 | // Initer takes arbitrary number of arguments needed for initialization and 13 | // returns an initialized cache store. 14 | type Initer func(ctx context.Context, args ...interface{}) (Cache, error) 15 | 16 | // manager is wrapper for wiring HTTP request and cache stores. 17 | type manager struct { 18 | store Cache // The cache store that is being managed. 19 | } 20 | 21 | // newManager returns a new manager with given cache store. 22 | func newManager(store Cache) *manager { 23 | return &manager{ 24 | store: store, 25 | } 26 | } 27 | 28 | // startGC starts a background goroutine to trigger GC of the cache store in 29 | // given time interval. Errors are printed using the `errFunc`. It returns a 30 | // send-only channel for stopping the background goroutine. 31 | func (m *manager) startGC(ctx context.Context, interval time.Duration, errFunc func(error)) chan<- struct{} { 32 | stop := make(chan struct{}) 33 | go func() { 34 | ticker := time.NewTicker(interval) 35 | for { 36 | err := m.store.GC(ctx) 37 | if err != nil { 38 | errFunc(err) 39 | } 40 | 41 | select { 42 | case <-stop: 43 | ticker.Stop() 44 | return 45 | case <-ticker.C: 46 | } 47 | } 48 | }() 49 | return stop 50 | } 51 | -------------------------------------------------------------------------------- /manager_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestManager_startGC(t *testing.T) { 14 | m := newManager(newMemoryStore(MemoryConfig{})) 15 | stop := m.startGC( 16 | context.Background(), 17 | time.Minute, 18 | func(error) { panic("unreachable") }, 19 | ) 20 | stop <- struct{}{} 21 | } 22 | -------------------------------------------------------------------------------- /memory.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "container/heap" 9 | "context" 10 | "os" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | // memoryItem is an in-memory cache item. 16 | type memoryItem struct { 17 | key string 18 | value interface{} 19 | expiredAt time.Time // The expiration time of the cache item 20 | 21 | index int // The index in the heap 22 | } 23 | 24 | // newMemoryItem returns a new memory cache item with given key, value and 25 | // expiration time. 26 | func newMemoryItem(key string, value interface{}, expiredAt time.Time) *memoryItem { 27 | return &memoryItem{ 28 | key: key, 29 | value: value, 30 | expiredAt: expiredAt, 31 | } 32 | } 33 | 34 | var _ Cache = (*memoryStore)(nil) 35 | var _ heap.Interface = (*memoryStore)(nil) 36 | 37 | // memoryStore is an in-memory implementation of the cache store. 38 | type memoryStore struct { 39 | nowFunc func() time.Time // The function to return the current time 40 | 41 | lock sync.RWMutex // The mutex to guard accesses to the heap and index 42 | heap []*memoryItem // The heap to be managed by operations of heap.Interface 43 | index map[string]*memoryItem // The index to be managed by operations of heap.Interface 44 | } 45 | 46 | // newMemoryStore returns a new memory cache store based on given 47 | // configuration. 48 | func newMemoryStore(cfg MemoryConfig) *memoryStore { 49 | return &memoryStore{ 50 | nowFunc: cfg.nowFunc, 51 | index: make(map[string]*memoryItem), 52 | } 53 | } 54 | 55 | // Len implements `heap.Interface.Len`. It is not concurrent-safe and is the 56 | // caller's responsibility to ensure they're being guarded by a mutex during any 57 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 58 | func (s *memoryStore) Len() int { 59 | return len(s.heap) 60 | } 61 | 62 | // Less implements `heap.Interface.Less`. It is not concurrent-safe and is the 63 | // caller's responsibility to ensure they're being guarded by a mutex during any 64 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 65 | func (s *memoryStore) Less(i, j int) bool { 66 | return s.heap[i].expiredAt.Before(s.heap[j].expiredAt) 67 | } 68 | 69 | // Swap implements `heap.Interface.Swap`. It is not concurrent-safe and is the 70 | // caller's responsibility to ensure they're being guarded by a mutex during any 71 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 72 | func (s *memoryStore) Swap(i, j int) { 73 | s.heap[i], s.heap[j] = s.heap[j], s.heap[i] 74 | s.heap[i].index = i 75 | s.heap[j].index = j 76 | } 77 | 78 | // Push implements `heap.Interface.Push`. It is not concurrent-safe and is the 79 | // caller's responsibility to ensure they're being guarded by a mutex during any 80 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 81 | func (s *memoryStore) Push(x interface{}) { 82 | n := s.Len() 83 | item := x.(*memoryItem) 84 | item.index = n 85 | s.heap = append(s.heap, item) 86 | s.index[item.key] = item 87 | } 88 | 89 | // Pop implements `heap.Interface.Pop`. It is not concurrent-safe and is the 90 | // caller's responsibility to ensure they're being guarded by a mutex during any 91 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 92 | func (s *memoryStore) Pop() interface{} { 93 | n := s.Len() 94 | item := s.heap[n-1] 95 | 96 | s.heap[n-1] = nil // Avoid memory leak 97 | item.index = -1 // For safety 98 | 99 | s.heap = s.heap[:n-1] 100 | delete(s.index, item.key) 101 | return item 102 | } 103 | 104 | func (s *memoryStore) Get(ctx context.Context, key string) (interface{}, error) { 105 | s.lock.RLock() 106 | defer s.lock.RUnlock() 107 | 108 | item, ok := s.index[key] 109 | if !ok { 110 | return nil, os.ErrNotExist 111 | } 112 | 113 | if !s.nowFunc().Before(item.expiredAt) { 114 | go func() { _ = s.Delete(ctx, key) }() 115 | return nil, os.ErrNotExist 116 | } 117 | return item.value, nil 118 | } 119 | 120 | func (s *memoryStore) Set(_ context.Context, key string, value interface{}, lifetime time.Duration) error { 121 | s.lock.Lock() 122 | defer s.lock.Unlock() 123 | 124 | item := newMemoryItem(key, value, s.nowFunc().Add(lifetime)) 125 | heap.Push(s, item) 126 | return nil 127 | } 128 | 129 | func (s *memoryStore) Delete(_ context.Context, key string) error { 130 | s.lock.Lock() 131 | defer s.lock.Unlock() 132 | 133 | item, ok := s.index[key] 134 | if !ok { 135 | return nil 136 | } 137 | 138 | heap.Remove(s, item.index) 139 | return nil 140 | } 141 | 142 | func (s *memoryStore) Flush(context.Context) error { 143 | s.lock.Lock() 144 | defer s.lock.Unlock() 145 | 146 | s.heap = make([]*memoryItem, 0, len(s.heap)) 147 | s.index = make(map[string]*memoryItem, len(s.index)) 148 | return nil 149 | } 150 | 151 | func (s *memoryStore) GC(ctx context.Context) error { 152 | // Removing expired cache items from top of the heap until there is no more 153 | // expired items found. 154 | for { 155 | select { 156 | case <-ctx.Done(): 157 | return nil 158 | default: 159 | } 160 | 161 | done := func() bool { 162 | s.lock.Lock() 163 | defer s.lock.Unlock() 164 | 165 | if s.Len() == 0 { 166 | return true 167 | } 168 | 169 | c := s.heap[0] 170 | 171 | // If the oldest item is not expired, there is no need to continue 172 | if s.nowFunc().Before(c.expiredAt) { 173 | return true 174 | } 175 | 176 | heap.Remove(s, c.index) 177 | return false 178 | }() 179 | if done { 180 | break 181 | } 182 | } 183 | return nil 184 | } 185 | 186 | // MemoryConfig contains options for the memory cache store. 187 | type MemoryConfig struct { 188 | nowFunc func() time.Time // For tests only 189 | } 190 | 191 | // MemoryIniter returns the Initer for the memory cache store. 192 | func MemoryIniter() Initer { 193 | return func(_ context.Context, args ...interface{}) (Cache, error) { 194 | var cfg *MemoryConfig 195 | for i := range args { 196 | switch v := args[i].(type) { 197 | case MemoryConfig: 198 | cfg = &v 199 | } 200 | } 201 | 202 | if cfg == nil { 203 | cfg = &MemoryConfig{} 204 | } 205 | 206 | if cfg.nowFunc == nil { 207 | cfg.nowFunc = time.Now 208 | } 209 | 210 | return newMemoryStore(*cfg), nil 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /memory_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/stretchr/testify/assert" 17 | 18 | "github.com/flamego/flamego" 19 | ) 20 | 21 | func TestMemoryStore(t *testing.T) { 22 | f := flamego.NewWithLogger(&bytes.Buffer{}) 23 | f.Use(Cacher()) 24 | 25 | f.Get("/", func(c flamego.Context, cache Cache) { 26 | ctx := c.Request().Context() 27 | 28 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 29 | 30 | v, err := cache.Get(ctx, "username") 31 | assert.Nil(t, err) 32 | username, ok := v.(string) 33 | assert.True(t, ok) 34 | assert.Equal(t, "flamego", username) 35 | 36 | assert.Nil(t, cache.Delete(ctx, "username")) 37 | _, err = cache.Get(ctx, "username") 38 | assert.Equal(t, os.ErrNotExist, err) 39 | 40 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 41 | v, err = cache.Get(ctx, "timeout") 42 | assert.Nil(t, err) 43 | timeout, ok := v.(time.Duration) 44 | assert.True(t, ok) 45 | assert.Equal(t, time.Minute, timeout) 46 | 47 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 48 | assert.Nil(t, cache.Flush(ctx)) 49 | _, err = cache.Get(ctx, "random") 50 | assert.Equal(t, os.ErrNotExist, err) 51 | }) 52 | 53 | resp := httptest.NewRecorder() 54 | req, err := http.NewRequest(http.MethodGet, "/", nil) 55 | assert.Nil(t, err) 56 | 57 | f.ServeHTTP(resp, req) 58 | 59 | assert.Equal(t, http.StatusOK, resp.Code) 60 | } 61 | 62 | func TestMemoryStore_GC(t *testing.T) { 63 | ctx := context.Background() 64 | now := time.Now() 65 | store := newMemoryStore( 66 | MemoryConfig{ 67 | nowFunc: func() time.Time { return now }, 68 | }, 69 | ) 70 | 71 | assert.Nil(t, store.Set(ctx, "1", "1", time.Second)) 72 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 73 | assert.Nil(t, store.Set(ctx, "3", "3", 3*time.Second)) 74 | 75 | // Read on an expired cache item should remove it 76 | now = now.Add(2 * time.Second) 77 | _, err := store.Get(ctx, "1") 78 | assert.Equal(t, os.ErrNotExist, err) 79 | 80 | // "2" should be recycled 81 | assert.Nil(t, store.GC(ctx)) 82 | 83 | assert.Equal(t, 1, store.Len()) 84 | } 85 | -------------------------------------------------------------------------------- /mongo/mongo.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/gob" 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "github.com/pkg/errors" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/mongo" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | 16 | "github.com/flamego/cache" 17 | ) 18 | 19 | var _ cache.Cache = (*mongoStore)(nil) 20 | 21 | // mongoStore is a MongoDB implementation of the cache store. 22 | type mongoStore struct { 23 | nowFunc func() time.Time // The function to return the current time 24 | db *mongo.Database // The database connection 25 | collection string // The database collection for storing cache Data 26 | encoder cache.Encoder // The encoder to encode the cache Data before saving 27 | decoder cache.Decoder // The decoder to decode binary to cache Data after reading 28 | } 29 | 30 | // newMongoStore returns a new Mongo cache store based on given 31 | // configuration. 32 | func newMongoStore(cfg Config) *mongoStore { 33 | return &mongoStore{ 34 | nowFunc: cfg.nowFunc, 35 | db: cfg.db, 36 | collection: cfg.Collection, 37 | encoder: cfg.Encoder, 38 | decoder: cfg.Decoder, 39 | } 40 | } 41 | 42 | type item struct { 43 | Value interface{} 44 | } 45 | 46 | type cacheFields struct { 47 | Data []byte `bson:"data"` 48 | Key string `bson:"key"` 49 | ExpiredAt time.Time `bson:"expired_at"` 50 | } 51 | 52 | func (s *mongoStore) Get(ctx context.Context, key string) (interface{}, error) { 53 | var fields cacheFields 54 | err := s.db.Collection(s.collection). 55 | FindOne(ctx, bson.M{"key": key, "expired_at": bson.M{"$gt": s.nowFunc().UTC()}}).Decode(&fields) 56 | if err != nil { 57 | if err == mongo.ErrNoDocuments { 58 | return nil, os.ErrNotExist 59 | } 60 | return nil, errors.Wrap(err, "find") 61 | } 62 | 63 | v, err := s.decoder(fields.Data) 64 | if err != nil { 65 | return nil, errors.Wrap(err, "decode") 66 | } 67 | 68 | item, ok := v.(*item) 69 | if !ok { 70 | return nil, os.ErrNotExist 71 | } 72 | return item.Value, nil 73 | } 74 | 75 | func (s *mongoStore) Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error { 76 | binary, err := s.encoder(item{value}) 77 | if err != nil { 78 | return errors.Wrap(err, "encode") 79 | } 80 | 81 | fields := cacheFields{ 82 | Data: binary, 83 | Key: key, 84 | ExpiredAt: s.nowFunc().Add(lifetime).UTC(), 85 | } 86 | 87 | upsert := true 88 | _, err = s.db.Collection(s.collection). 89 | UpdateOne(ctx, bson.M{"key": key}, bson.M{"$set": fields}, &options.UpdateOptions{ 90 | Upsert: &upsert, 91 | }) 92 | if err != nil { 93 | return errors.Wrap(err, "upsert") 94 | } 95 | return nil 96 | } 97 | 98 | func (s *mongoStore) Delete(ctx context.Context, key string) error { 99 | _, err := s.db.Collection(s.collection).DeleteOne(ctx, bson.M{"key": key}) 100 | if err != nil { 101 | return errors.Wrap(err, "delete") 102 | } 103 | return nil 104 | } 105 | 106 | func (s *mongoStore) Flush(ctx context.Context) error { 107 | return s.db.Collection(s.collection).Drop(ctx) 108 | } 109 | 110 | func (s *mongoStore) GC(ctx context.Context) error { 111 | _, err := s.db.Collection(s.collection).DeleteMany(ctx, bson.M{"expired_at": bson.M{"$lte": s.nowFunc().UTC()}}) 112 | if err != nil { 113 | return errors.Wrap(err, "delete") 114 | } 115 | return nil 116 | } 117 | 118 | // Options keeps the settings to set up Mongo client connection. 119 | type Options = options.ClientOptions 120 | 121 | // Config contains options for the Mongo cache store. 122 | type Config struct { 123 | // For tests only 124 | nowFunc func() time.Time 125 | db *mongo.Database 126 | 127 | // Options is the settings to set up the MongoDB client connection. 128 | Options *Options 129 | // Database is the database name of the MongoDB. 130 | Database string 131 | // Collection is the collection name for storing cache Data. Default is "cache". 132 | Collection string 133 | // Encoder is the encoder to encode cache Data. Default is a Gob encoder. 134 | Encoder cache.Encoder 135 | // Decoder is the decoder to decode cache Data. Default is a Gob decoder. 136 | Decoder cache.Decoder 137 | } 138 | 139 | // Initer returns the cache.Initer for the Mongo cache store. 140 | func Initer() cache.Initer { 141 | return func(ctx context.Context, args ...interface{}) (cache.Cache, error) { 142 | var cfg *Config 143 | for i := range args { 144 | switch v := args[i].(type) { 145 | case Config: 146 | cfg = &v 147 | } 148 | } 149 | 150 | if cfg == nil { 151 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 152 | } else if cfg.Database == "" && cfg.db == nil { 153 | return nil, errors.New("empty Database") 154 | } 155 | 156 | if cfg.db == nil { 157 | client, err := mongo.Connect(ctx, cfg.Options) 158 | if err != nil { 159 | return nil, errors.Wrap(err, "connect database") 160 | } 161 | cfg.db = client.Database(cfg.Database) 162 | } 163 | 164 | if cfg.nowFunc == nil { 165 | cfg.nowFunc = time.Now 166 | } 167 | if cfg.Collection == "" { 168 | cfg.Collection = "cache" 169 | } 170 | if cfg.Encoder == nil { 171 | cfg.Encoder = cache.GobEncoder 172 | } 173 | if cfg.Decoder == nil { 174 | cfg.Decoder = func(binary []byte) (interface{}, error) { 175 | buf := bytes.NewBuffer(binary) 176 | var v item 177 | return &v, gob.NewDecoder(buf).Decode(&v) 178 | } 179 | } 180 | 181 | return newMongoStore(*cfg), nil 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /mongo/mongo_test.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/gob" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "go.mongodb.org/mongo-driver/mongo" 15 | "go.mongodb.org/mongo-driver/mongo/options" 16 | 17 | "github.com/flamego/flamego" 18 | 19 | "github.com/flamego/cache" 20 | ) 21 | 22 | func newTestDB(t *testing.T, ctx context.Context) (testDB *mongo.Database, cleanup func() error) { 23 | uri := os.Getenv("MONGODB_URI") 24 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) 25 | if err != nil { 26 | t.Fatalf("Failed to connect to mongo: %v", err) 27 | } 28 | 29 | dbname := "flamego-test-cache" 30 | err = client.Database(dbname).Drop(ctx) 31 | if err != nil { 32 | t.Fatalf("Failed to drop test database: %v", err) 33 | } 34 | db := client.Database(dbname) 35 | t.Cleanup(func() { 36 | if t.Failed() { 37 | t.Logf("DATABASE %s left intact for inspection", dbname) 38 | return 39 | } 40 | 41 | err = db.Drop(ctx) 42 | if err != nil { 43 | t.Fatalf("Failed to drop test database: %v", err) 44 | } 45 | }) 46 | return db, func() error { 47 | if t.Failed() { 48 | return nil 49 | } 50 | 51 | err = db.Collection("cache").Drop(ctx) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | } 58 | 59 | func init() { 60 | gob.Register(time.Duration(0)) 61 | } 62 | 63 | func TestMongoStore(t *testing.T) { 64 | ctx := context.Background() 65 | db, cleanup := newTestDB(t, ctx) 66 | t.Cleanup(func() { 67 | assert.NoError(t, cleanup()) 68 | }) 69 | 70 | f := flamego.NewWithLogger(&bytes.Buffer{}) 71 | f.Use(cache.Cacher( 72 | cache.Options{ 73 | Initer: Initer(), 74 | Config: Config{ 75 | nowFunc: time.Now, 76 | db: db, 77 | }, 78 | }, 79 | )) 80 | 81 | f.Get("/", func(c flamego.Context, cache cache.Cache) { 82 | ctx := c.Request().Context() 83 | 84 | assert.NoError(t, cache.Set(ctx, "username", "flamego", time.Minute)) 85 | 86 | v, err := cache.Get(ctx, "username") 87 | assert.NoError(t, err) 88 | username, ok := v.(string) 89 | assert.True(t, ok) 90 | assert.Equal(t, "flamego", username) 91 | 92 | assert.NoError(t, cache.Delete(ctx, "username")) 93 | _, err = cache.Get(ctx, "username") 94 | assert.Equal(t, os.ErrNotExist, err) 95 | 96 | assert.NoError(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 97 | v, err = cache.Get(ctx, "timeout") 98 | assert.NoError(t, err) 99 | timeout, ok := v.(time.Duration) 100 | assert.True(t, ok) 101 | assert.Equal(t, time.Minute, timeout) 102 | 103 | assert.NoError(t, cache.Set(ctx, "random", "value", time.Minute)) 104 | assert.NoError(t, cache.Flush(ctx)) 105 | _, err = cache.Get(ctx, "random") 106 | assert.Equal(t, os.ErrNotExist, err) 107 | }) 108 | 109 | resp := httptest.NewRecorder() 110 | req, err := http.NewRequest(http.MethodGet, "/", nil) 111 | assert.NoError(t, err) 112 | 113 | f.ServeHTTP(resp, req) 114 | 115 | assert.Equal(t, http.StatusOK, resp.Code) 116 | } 117 | 118 | func TestMongoStore_GC(t *testing.T) { 119 | ctx := context.Background() 120 | db, cleanup := newTestDB(t, ctx) 121 | t.Cleanup(func() { 122 | assert.NoError(t, cleanup()) 123 | }) 124 | 125 | now := time.Now() 126 | store, err := Initer()( 127 | ctx, 128 | Config{ 129 | nowFunc: func() time.Time { return now }, 130 | db: db, 131 | }, 132 | ) 133 | assert.NoError(t, err) 134 | 135 | assert.NoError(t, store.Set(ctx, "1", "1", time.Second)) 136 | assert.NoError(t, store.Set(ctx, "2", "2", 2*time.Second)) 137 | assert.NoError(t, store.Set(ctx, "3", "3", 3*time.Second)) 138 | 139 | // Read on an expired cache item should remove it 140 | now = now.Add(2 * time.Second) 141 | _, err = store.Get(ctx, "1") 142 | assert.Equal(t, os.ErrNotExist, err) 143 | 144 | // "2" should be recycled 145 | assert.NoError(t, store.GC(ctx)) 146 | _, err = store.Get(ctx, "2") 147 | assert.Equal(t, os.ErrNotExist, err) 148 | 149 | // "3" should be returned 150 | v, err := store.Get(ctx, "3") 151 | assert.NoError(t, err) 152 | assert.Equal(t, "3", v) 153 | } 154 | -------------------------------------------------------------------------------- /mysql/mysql.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mysql 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "fmt" 13 | "os" 14 | "time" 15 | 16 | _ "github.com/go-sql-driver/mysql" 17 | "github.com/pkg/errors" 18 | 19 | "github.com/flamego/cache" 20 | ) 21 | 22 | var _ cache.Cache = (*mysqlStore)(nil) 23 | 24 | // mysqlStore is a MySQL implementation of the cache store. 25 | type mysqlStore struct { 26 | nowFunc func() time.Time // The function to return the current time 27 | db *sql.DB // The database connection 28 | table string // The database table for storing cache data 29 | encoder cache.Encoder // The encoder to encode the cache data before saving 30 | decoder cache.Decoder // The decoder to decode binary to cache data after reading 31 | } 32 | 33 | // newMySQLStore returns a new MySQL cache store based on given 34 | // configuration. 35 | func newMySQLStore(cfg Config) *mysqlStore { 36 | return &mysqlStore{ 37 | nowFunc: cfg.nowFunc, 38 | db: cfg.db, 39 | table: cfg.Table, 40 | encoder: cfg.Encoder, 41 | decoder: cfg.Decoder, 42 | } 43 | } 44 | 45 | type item struct { 46 | Value interface{} 47 | } 48 | 49 | func (s *mysqlStore) Get(ctx context.Context, key string) (interface{}, error) { 50 | var binary []byte 51 | q := fmt.Sprintf( 52 | `SELECT data FROM %s WHERE %s = ? AND expired_at > ?`, 53 | quoteWithBackticks(s.table), 54 | quoteWithBackticks("key"), 55 | ) 56 | err := s.db.QueryRowContext(ctx, q, key, s.nowFunc()).Scan(&binary) 57 | if err != nil { 58 | if err == sql.ErrNoRows { 59 | return nil, os.ErrNotExist 60 | } 61 | return nil, errors.Wrap(err, "select") 62 | } 63 | 64 | v, err := s.decoder(binary) 65 | if err != nil { 66 | return nil, errors.Wrap(err, "decode") 67 | } 68 | 69 | item, ok := v.(*item) 70 | if !ok { 71 | return nil, os.ErrNotExist 72 | } 73 | return item.Value, nil 74 | } 75 | 76 | func quoteWithBackticks(s string) string { 77 | return "`" + s + "`" 78 | } 79 | 80 | func (s *mysqlStore) Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error { 81 | binary, err := s.encoder(item{value}) 82 | if err != nil { 83 | return errors.Wrap(err, "encode") 84 | } 85 | 86 | q := fmt.Sprintf(` 87 | INSERT INTO %s (%s, data, expired_at) 88 | VALUES (?, ?, ?) 89 | ON DUPLICATE KEY UPDATE 90 | data = VALUES(data), 91 | expired_at = VALUES(expired_at) 92 | `, 93 | quoteWithBackticks(s.table), 94 | quoteWithBackticks("key"), 95 | ) 96 | _, err = s.db.ExecContext(ctx, q, key, binary, s.nowFunc().Add(lifetime).UTC()) 97 | if err != nil { 98 | return errors.Wrap(err, "upsert") 99 | } 100 | return nil 101 | } 102 | 103 | func (s *mysqlStore) Delete(ctx context.Context, key string) error { 104 | q := fmt.Sprintf(`DELETE FROM %s WHERE %s = ?`, quoteWithBackticks(s.table), quoteWithBackticks("key")) 105 | _, err := s.db.ExecContext(ctx, q, key) 106 | return err 107 | } 108 | 109 | func (s *mysqlStore) Flush(ctx context.Context) error { 110 | q := fmt.Sprintf(`TRUNCATE TABLE %s`, quoteWithBackticks(s.table)) 111 | _, err := s.db.ExecContext(ctx, q) 112 | return err 113 | } 114 | 115 | func (s *mysqlStore) GC(ctx context.Context) error { 116 | q := fmt.Sprintf(`DELETE FROM %s WHERE expired_at <= ?`, quoteWithBackticks(s.table)) 117 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC()) 118 | return err 119 | } 120 | 121 | // Config contains options for the MySQL cache store. 122 | type Config struct { 123 | // For tests only 124 | nowFunc func() time.Time 125 | db *sql.DB 126 | 127 | // DSN is the database source name to the MySQL. 128 | DSN string 129 | // Table is the table name for storing cache data. Default is "cache". 130 | Table string 131 | // Encoder is the encoder to encode cache data. Default is a Gob encoder. 132 | Encoder cache.Encoder 133 | // Decoder is the decoder to decode cache data. Default is a Gob decoder. 134 | Decoder cache.Decoder 135 | // InitTable indicates whether to create a default cache table when not exists automatically. 136 | InitTable bool 137 | } 138 | 139 | // Initer returns the cache.Initer for the MySQL cache store. 140 | func Initer() cache.Initer { 141 | return func(ctx context.Context, args ...interface{}) (cache.Cache, error) { 142 | var cfg *Config 143 | for i := range args { 144 | switch v := args[i].(type) { 145 | case Config: 146 | cfg = &v 147 | } 148 | } 149 | 150 | if cfg == nil { 151 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 152 | } else if cfg.DSN == "" && cfg.db == nil { 153 | return nil, errors.New("empty DSN") 154 | } 155 | 156 | if cfg.db == nil { 157 | db, err := sql.Open("mysql", cfg.DSN) 158 | if err != nil { 159 | return nil, errors.Wrap(err, "open database") 160 | } 161 | cfg.db = db 162 | } 163 | 164 | if cfg.InitTable { 165 | q := fmt.Sprintf(` 166 | CREATE TABLE IF NOT EXISTS cache ( 167 | %[1]s VARCHAR(255) NOT NULL, 168 | data BLOB NOT NULL, 169 | expired_at DATETIME NOT NULL, 170 | PRIMARY KEY (%[1]s) 171 | ) DEFAULT CHARSET=utf8`, 172 | quoteWithBackticks("key"), 173 | ) 174 | if _, err := cfg.db.ExecContext(ctx, q); err != nil { 175 | return nil, errors.Wrap(err, "create table") 176 | } 177 | } 178 | 179 | if cfg.nowFunc == nil { 180 | cfg.nowFunc = time.Now 181 | } 182 | if cfg.Table == "" { 183 | cfg.Table = "cache" 184 | } 185 | if cfg.Encoder == nil { 186 | cfg.Encoder = cache.GobEncoder 187 | } 188 | if cfg.Decoder == nil { 189 | cfg.Decoder = func(binary []byte) (interface{}, error) { 190 | buf := bytes.NewBuffer(binary) 191 | var v item 192 | return &v, gob.NewDecoder(buf).Decode(&v) 193 | } 194 | } 195 | 196 | return newMySQLStore(*cfg), nil 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /mysql/mysql_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mysql 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "fmt" 13 | "net/http" 14 | "net/http/httptest" 15 | "os" 16 | "testing" 17 | "time" 18 | 19 | "github.com/go-sql-driver/mysql" 20 | "github.com/stretchr/testify/assert" 21 | 22 | "github.com/flamego/flamego" 23 | 24 | "github.com/flamego/cache" 25 | ) 26 | 27 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 28 | dsn := os.ExpandEnv("$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:$MYSQL_PORT)/?charset=utf8&parseTime=true") 29 | db, err := sql.Open("mysql", dsn) 30 | if err != nil { 31 | t.Fatalf("Failed to open database: %v", err) 32 | } 33 | 34 | dbname := "flamego-test-cache" 35 | _, err = db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", quoteWithBackticks(dbname))) 36 | if err != nil { 37 | t.Fatalf("Failed to drop test database: %v", err) 38 | } 39 | 40 | _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", quoteWithBackticks(dbname))) 41 | if err != nil { 42 | t.Fatalf("Failed to create test database: %v", err) 43 | } 44 | 45 | cfg, err := mysql.ParseDSN(dsn) 46 | if err != nil { 47 | t.Fatalf("Failed to parse DSN: %v", err) 48 | } 49 | cfg.DBName = dbname 50 | 51 | testDB, err = sql.Open("mysql", cfg.FormatDSN()) 52 | if err != nil { 53 | t.Fatalf("Failed to open test database: %v", err) 54 | } 55 | 56 | t.Cleanup(func() { 57 | defer func() { _ = db.Close() }() 58 | 59 | if t.Failed() { 60 | t.Logf("DATABASE %s left intact for inspection", dbname) 61 | return 62 | } 63 | 64 | err := testDB.Close() 65 | if err != nil { 66 | t.Fatalf("Failed to close test connection: %v", err) 67 | } 68 | 69 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE %s`, quoteWithBackticks(dbname))) 70 | if err != nil { 71 | t.Fatalf("Failed to drop test database: %v", err) 72 | } 73 | }) 74 | return testDB, func() error { 75 | if t.Failed() { 76 | return nil 77 | } 78 | 79 | _, err = testDB.ExecContext(ctx, `TRUNCATE TABLE cache`) 80 | if err != nil { 81 | return err 82 | } 83 | return nil 84 | } 85 | } 86 | 87 | func init() { 88 | gob.Register(time.Duration(0)) 89 | } 90 | 91 | func TestMySQLStore(t *testing.T) { 92 | ctx := context.Background() 93 | db, cleanup := newTestDB(t, ctx) 94 | t.Cleanup(func() { 95 | assert.Nil(t, cleanup()) 96 | }) 97 | 98 | f := flamego.NewWithLogger(&bytes.Buffer{}) 99 | f.Use(cache.Cacher( 100 | cache.Options{ 101 | Initer: Initer(), 102 | Config: Config{ 103 | nowFunc: time.Now, 104 | db: db, 105 | InitTable: true, 106 | }, 107 | }, 108 | )) 109 | 110 | f.Get("/", func(c flamego.Context, cache cache.Cache) { 111 | ctx := c.Request().Context() 112 | 113 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 114 | 115 | v, err := cache.Get(ctx, "username") 116 | assert.Nil(t, err) 117 | username, ok := v.(string) 118 | assert.True(t, ok) 119 | assert.Equal(t, "flamego", username) 120 | 121 | assert.Nil(t, cache.Delete(ctx, "username")) 122 | _, err = cache.Get(ctx, "username") 123 | assert.Equal(t, os.ErrNotExist, err) 124 | 125 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 126 | v, err = cache.Get(ctx, "timeout") 127 | assert.Nil(t, err) 128 | timeout, ok := v.(time.Duration) 129 | assert.True(t, ok) 130 | assert.Equal(t, time.Minute, timeout) 131 | 132 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 133 | assert.Nil(t, cache.Flush(ctx)) 134 | _, err = cache.Get(ctx, "random") 135 | assert.Equal(t, os.ErrNotExist, err) 136 | }) 137 | 138 | resp := httptest.NewRecorder() 139 | req, err := http.NewRequest(http.MethodGet, "/", nil) 140 | assert.Nil(t, err) 141 | 142 | f.ServeHTTP(resp, req) 143 | 144 | assert.Equal(t, http.StatusOK, resp.Code) 145 | } 146 | 147 | func TestMySQLStore_GC(t *testing.T) { 148 | ctx := context.Background() 149 | db, cleanup := newTestDB(t, ctx) 150 | t.Cleanup(func() { 151 | assert.Nil(t, cleanup()) 152 | }) 153 | 154 | now := time.Now() 155 | store, err := Initer()( 156 | ctx, 157 | Config{ 158 | nowFunc: func() time.Time { return now }, 159 | db: db, 160 | InitTable: true, 161 | }, 162 | ) 163 | assert.Nil(t, err) 164 | 165 | assert.Nil(t, store.Set(ctx, "1", "1", time.Second)) 166 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 167 | assert.Nil(t, store.Set(ctx, "3", "3", 4*time.Second)) 168 | 169 | // Read on an expired cache item should remove it. 170 | // NOTE: MySQL is behaving flaky on exact the seconds, so let's wait one more 171 | // second. 172 | now = now.Add(3 * time.Second) 173 | _, err = store.Get(ctx, "1") 174 | assert.Equal(t, os.ErrNotExist, err) 175 | 176 | // "2" should be recycled 177 | assert.Nil(t, store.GC(ctx)) 178 | _, err = store.Get(ctx, "2") 179 | assert.Equal(t, os.ErrNotExist, err) 180 | 181 | // "3" should be returned 182 | v, err := store.Get(ctx, "3") 183 | assert.Nil(t, err) 184 | assert.Equal(t, "3", v) 185 | } 186 | -------------------------------------------------------------------------------- /postgres/postgres.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package postgres 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "fmt" 13 | "os" 14 | "time" 15 | 16 | "github.com/jackc/pgx/v4" 17 | "github.com/jackc/pgx/v4/stdlib" 18 | "github.com/pkg/errors" 19 | 20 | "github.com/flamego/cache" 21 | ) 22 | 23 | var _ cache.Cache = (*postgresStore)(nil) 24 | 25 | // postgresStore is a Postgres implementation of the cache store. 26 | type postgresStore struct { 27 | nowFunc func() time.Time // The function to return the current time 28 | db *sql.DB // The database connection 29 | table string // The database table for storing cache data 30 | encoder cache.Encoder // The encoder to encode the cache data before saving 31 | decoder cache.Decoder // The decoder to decode binary to cache data after reading 32 | } 33 | 34 | // newPostgresStore returns a new Postgres cache store based on given 35 | // configuration. 36 | func newPostgresStore(cfg Config) *postgresStore { 37 | return &postgresStore{ 38 | nowFunc: cfg.nowFunc, 39 | db: cfg.db, 40 | table: cfg.Table, 41 | encoder: cfg.Encoder, 42 | decoder: cfg.Decoder, 43 | } 44 | } 45 | 46 | type item struct { 47 | Value interface{} 48 | } 49 | 50 | func (s *postgresStore) Get(ctx context.Context, key string) (interface{}, error) { 51 | var binary []byte 52 | q := fmt.Sprintf(`SELECT data FROM %q WHERE key = $1 AND expired_at > $2`, s.table) 53 | err := s.db.QueryRowContext(ctx, q, key, s.nowFunc()).Scan(&binary) 54 | if err != nil { 55 | if err == sql.ErrNoRows { 56 | return nil, os.ErrNotExist 57 | } 58 | return nil, errors.Wrap(err, "select") 59 | } 60 | 61 | v, err := s.decoder(binary) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "decode") 64 | } 65 | 66 | item, ok := v.(*item) 67 | if !ok { 68 | return nil, os.ErrNotExist 69 | } 70 | return item.Value, nil 71 | } 72 | 73 | func (s *postgresStore) Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error { 74 | binary, err := s.encoder(item{value}) 75 | if err != nil { 76 | return errors.Wrap(err, "encode") 77 | } 78 | 79 | q := fmt.Sprintf(` 80 | INSERT INTO %q (key, data, expired_at) 81 | VALUES ($1, $2, $3) 82 | ON CONFLICT (key) 83 | DO UPDATE SET 84 | data = excluded.data, 85 | expired_at = excluded.expired_at 86 | `, s.table) 87 | _, err = s.db.ExecContext(ctx, q, key, binary, s.nowFunc().Add(lifetime).UTC()) 88 | if err != nil { 89 | return errors.Wrap(err, "upsert") 90 | } 91 | return nil 92 | } 93 | 94 | func (s *postgresStore) Delete(ctx context.Context, key string) error { 95 | q := fmt.Sprintf(`DELETE FROM %q WHERE key = $1`, s.table) 96 | _, err := s.db.ExecContext(ctx, q, key) 97 | return err 98 | } 99 | 100 | func (s *postgresStore) Flush(ctx context.Context) error { 101 | q := fmt.Sprintf(`TRUNCATE TABLE %q`, s.table) 102 | _, err := s.db.ExecContext(ctx, q) 103 | return err 104 | } 105 | 106 | func (s *postgresStore) GC(ctx context.Context) error { 107 | q := fmt.Sprintf(`DELETE FROM %q WHERE expired_at <= $1`, s.table) 108 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC()) 109 | return err 110 | } 111 | 112 | // Config contains options for the Postgres cache store. 113 | type Config struct { 114 | // For tests only 115 | nowFunc func() time.Time 116 | db *sql.DB 117 | 118 | // DSN is the database source name to the Postgres. 119 | DSN string 120 | // Table is the table name for storing cache data. Default is "cache". 121 | Table string 122 | // Encoder is the encoder to encode cache data. Default is a Gob encoder. 123 | Encoder cache.Encoder 124 | // Decoder is the decoder to decode cache data. Default is a Gob decoder. 125 | Decoder cache.Decoder 126 | // InitTable indicates whether to create a default cache table when not exists automatically. 127 | InitTable bool 128 | } 129 | 130 | func openDB(dsn string) (*sql.DB, error) { 131 | config, err := pgx.ParseConfig(dsn) 132 | if err != nil { 133 | return nil, errors.Wrap(err, "parse config") 134 | } 135 | return stdlib.OpenDB(*config), nil 136 | } 137 | 138 | // Initer returns the cache.Initer for the Postgres cache store. 139 | func Initer() cache.Initer { 140 | return func(ctx context.Context, args ...interface{}) (cache.Cache, error) { 141 | var cfg *Config 142 | for i := range args { 143 | switch v := args[i].(type) { 144 | case Config: 145 | cfg = &v 146 | } 147 | } 148 | 149 | if cfg == nil { 150 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 151 | } else if cfg.DSN == "" && cfg.db == nil { 152 | return nil, errors.New("empty DSN") 153 | } 154 | 155 | if cfg.db == nil { 156 | db, err := openDB(cfg.DSN) 157 | if err != nil { 158 | return nil, errors.Wrap(err, "open database") 159 | } 160 | cfg.db = db 161 | } 162 | 163 | if cfg.InitTable { 164 | q := ` 165 | CREATE TABLE IF NOT EXISTS cache ( 166 | key TEXT PRIMARY KEY, 167 | data BYTEA NOT NULL, 168 | expired_at TIMESTAMP WITH TIME ZONE NOT NULL 169 | )` 170 | if _, err := cfg.db.ExecContext(ctx, q); err != nil { 171 | return nil, errors.Wrap(err, "create table") 172 | } 173 | } 174 | 175 | if cfg.nowFunc == nil { 176 | cfg.nowFunc = time.Now 177 | } 178 | if cfg.Table == "" { 179 | cfg.Table = "cache" 180 | } 181 | if cfg.Encoder == nil { 182 | cfg.Encoder = cache.GobEncoder 183 | } 184 | if cfg.Decoder == nil { 185 | cfg.Decoder = func(binary []byte) (interface{}, error) { 186 | buf := bytes.NewBuffer(binary) 187 | var v item 188 | return &v, gob.NewDecoder(buf).Decode(&v) 189 | } 190 | } 191 | 192 | return newPostgresStore(*cfg), nil 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /postgres/postgres_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package postgres 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "flag" 13 | "fmt" 14 | "net/http" 15 | "net/http/httptest" 16 | "net/url" 17 | "os" 18 | "sync" 19 | "testing" 20 | "time" 21 | 22 | "github.com/jackc/pgx/v4" 23 | "github.com/jackc/pgx/v4/log/testingadapter" 24 | "github.com/jackc/pgx/v4/stdlib" 25 | "github.com/stretchr/testify/assert" 26 | 27 | "github.com/flamego/flamego" 28 | 29 | "github.com/flamego/cache" 30 | ) 31 | 32 | var flagParseOnce sync.Once 33 | 34 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 35 | dsn := os.ExpandEnv("postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/?sslmode=$PGSSLMODE") 36 | db, err := openDB(dsn) 37 | if err != nil { 38 | t.Fatalf("Failed to open database: %v", err) 39 | } 40 | 41 | dbname := "flamego-test-cache" 42 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbname)) 43 | if err != nil { 44 | t.Fatalf("Failed to drop test database: %v", err) 45 | } 46 | 47 | _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbname)) 48 | if err != nil { 49 | t.Fatalf("Failed to create test database: %v", err) 50 | } 51 | 52 | cfg, err := url.Parse(dsn) 53 | if err != nil { 54 | t.Fatalf("Failed to parse DSN: %v", err) 55 | } 56 | cfg.Path = "/" + dbname 57 | 58 | flagParseOnce.Do(flag.Parse) 59 | 60 | connConfig, err := pgx.ParseConfig(cfg.String()) 61 | if err != nil { 62 | t.Fatalf("Failed to parse test database config: %v", err) 63 | } 64 | if testing.Verbose() { 65 | connConfig.Logger = testingadapter.NewLogger(t) 66 | connConfig.LogLevel = pgx.LogLevelTrace 67 | } 68 | 69 | testDB = stdlib.OpenDB(*connConfig) 70 | 71 | t.Cleanup(func() { 72 | defer func() { _ = db.Close() }() 73 | 74 | if t.Failed() { 75 | t.Logf("DATABASE %s left intact for inspection", dbname) 76 | return 77 | } 78 | 79 | err := testDB.Close() 80 | if err != nil { 81 | t.Fatalf("Failed to close test connection: %v", err) 82 | } 83 | 84 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE %q`, dbname)) 85 | if err != nil { 86 | t.Fatalf("Failed to drop test database: %v", err) 87 | } 88 | }) 89 | return testDB, func() error { 90 | if t.Failed() { 91 | return nil 92 | } 93 | 94 | _, err = testDB.ExecContext(ctx, `TRUNCATE cache RESTART IDENTITY CASCADE`) 95 | if err != nil { 96 | return err 97 | } 98 | return nil 99 | } 100 | } 101 | 102 | func init() { 103 | gob.Register(time.Duration(0)) 104 | } 105 | 106 | func TestPostgresStore(t *testing.T) { 107 | ctx := context.Background() 108 | db, cleanup := newTestDB(t, ctx) 109 | t.Cleanup(func() { 110 | assert.Nil(t, cleanup()) 111 | }) 112 | 113 | f := flamego.NewWithLogger(&bytes.Buffer{}) 114 | f.Use(cache.Cacher( 115 | cache.Options{ 116 | Initer: Initer(), 117 | Config: Config{ 118 | nowFunc: time.Now, 119 | db: db, 120 | InitTable: true, 121 | }, 122 | }, 123 | )) 124 | 125 | f.Get("/", func(c flamego.Context, cache cache.Cache) { 126 | ctx := c.Request().Context() 127 | 128 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 129 | 130 | v, err := cache.Get(ctx, "username") 131 | assert.Nil(t, err) 132 | username, ok := v.(string) 133 | assert.True(t, ok) 134 | assert.Equal(t, "flamego", username) 135 | 136 | assert.Nil(t, cache.Delete(ctx, "username")) 137 | _, err = cache.Get(ctx, "username") 138 | assert.Equal(t, os.ErrNotExist, err) 139 | 140 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 141 | v, err = cache.Get(ctx, "timeout") 142 | assert.Nil(t, err) 143 | timeout, ok := v.(time.Duration) 144 | assert.True(t, ok) 145 | assert.Equal(t, time.Minute, timeout) 146 | 147 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 148 | assert.Nil(t, cache.Flush(ctx)) 149 | _, err = cache.Get(ctx, "random") 150 | assert.Equal(t, os.ErrNotExist, err) 151 | }) 152 | 153 | resp := httptest.NewRecorder() 154 | req, err := http.NewRequest(http.MethodGet, "/", nil) 155 | assert.Nil(t, err) 156 | 157 | f.ServeHTTP(resp, req) 158 | 159 | assert.Equal(t, http.StatusOK, resp.Code) 160 | } 161 | 162 | func TestPostgresStore_GC(t *testing.T) { 163 | ctx := context.Background() 164 | db, cleanup := newTestDB(t, ctx) 165 | t.Cleanup(func() { 166 | assert.Nil(t, cleanup()) 167 | }) 168 | 169 | now := time.Now() 170 | store, err := Initer()( 171 | ctx, 172 | Config{ 173 | nowFunc: func() time.Time { return now }, 174 | db: db, 175 | InitTable: true, 176 | }, 177 | ) 178 | assert.Nil(t, err) 179 | 180 | assert.Nil(t, store.Set(ctx, "1", "1", time.Second)) 181 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 182 | assert.Nil(t, store.Set(ctx, "3", "3", 3*time.Second)) 183 | 184 | // Read on an expired cache item should remove it 185 | now = now.Add(2 * time.Second) 186 | _, err = store.Get(ctx, "1") 187 | assert.Equal(t, os.ErrNotExist, err) 188 | 189 | // "2" should be recycled 190 | assert.Nil(t, store.GC(ctx)) 191 | _, err = store.Get(ctx, "2") 192 | assert.Equal(t, os.ErrNotExist, err) 193 | 194 | // "3" should be returned 195 | v, err := store.Get(ctx, "3") 196 | assert.Nil(t, err) 197 | assert.Equal(t, "3", v) 198 | } 199 | -------------------------------------------------------------------------------- /redis/redis.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package redis 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/gob" 11 | "fmt" 12 | "os" 13 | "time" 14 | 15 | "github.com/pkg/errors" 16 | "github.com/redis/go-redis/v9" 17 | 18 | "github.com/flamego/cache" 19 | ) 20 | 21 | var _ cache.Cache = (*redisStore)(nil) 22 | 23 | // redisStore is a Redis implementation of the cache store. 24 | type redisStore struct { 25 | client *redis.Client // The client connection 26 | keyPrefix string // The prefix to use for keys 27 | encoder cache.Encoder // The encoder to encode the cache data before saving 28 | decoder cache.Decoder // The decoder to decode binary to cache data after reading 29 | } 30 | 31 | // newRedisStore returns a new Redis cache store based on given configuration. 32 | func newRedisStore(cfg Config) *redisStore { 33 | return &redisStore{ 34 | client: cfg.client, 35 | keyPrefix: cfg.KeyPrefix, 36 | encoder: cfg.Encoder, 37 | decoder: cfg.Decoder, 38 | } 39 | } 40 | 41 | type item struct { 42 | Value interface{} 43 | } 44 | 45 | func (s *redisStore) Get(ctx context.Context, key string) (interface{}, error) { 46 | binary, err := s.client.Get(ctx, s.keyPrefix+key).Result() 47 | if err != nil { 48 | if err == redis.Nil { 49 | return nil, os.ErrNotExist 50 | } 51 | return nil, errors.Wrap(err, "get") 52 | } 53 | 54 | v, err := s.decoder([]byte(binary)) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "decode") 57 | } 58 | 59 | item, ok := v.(*item) 60 | if !ok { 61 | return nil, os.ErrNotExist 62 | } 63 | return item.Value, nil 64 | } 65 | 66 | func (s *redisStore) Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error { 67 | binary, err := s.encoder(item{value}) 68 | if err != nil { 69 | return errors.Wrap(err, "encode") 70 | } 71 | 72 | err = s.client.SetEx(ctx, s.keyPrefix+key, string(binary), lifetime).Err() 73 | if err != nil { 74 | return errors.Wrap(err, "set") 75 | } 76 | return nil 77 | } 78 | 79 | func (s *redisStore) Delete(ctx context.Context, key string) error { 80 | return s.client.Del(ctx, s.keyPrefix+key).Err() 81 | } 82 | 83 | func (s *redisStore) Flush(ctx context.Context) error { 84 | return s.client.FlushDBAsync(ctx).Err() 85 | } 86 | 87 | func (s *redisStore) GC(ctx context.Context) error { 88 | return nil 89 | } 90 | 91 | // Options keeps the settings to set up Redis client connection. 92 | type Options = redis.Options 93 | 94 | // Config contains options for the Redis cache store. 95 | type Config struct { 96 | // For tests only 97 | client *redis.Client 98 | 99 | // Options is the settings to set up Redis client connection. 100 | Options *Options 101 | // KeyPrefix is the prefix to use for keys in Redis. Default is "cache:". 102 | KeyPrefix string 103 | // Encoder is the encoder to encode cache data. Default is a Gob encoder. 104 | Encoder cache.Encoder 105 | // Decoder is the decoder to decode cache data. Default is a Gob decoder. 106 | Decoder cache.Decoder 107 | } 108 | 109 | // Initer returns the cache.Initer for the Redis cache store. 110 | func Initer() cache.Initer { 111 | return func(ctx context.Context, args ...interface{}) (cache.Cache, error) { 112 | var cfg *Config 113 | for i := range args { 114 | switch v := args[i].(type) { 115 | case Config: 116 | cfg = &v 117 | } 118 | } 119 | 120 | if cfg == nil { 121 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 122 | } else if cfg.Options == nil && cfg.client == nil { 123 | return nil, errors.New("empty Options") 124 | } 125 | 126 | if cfg.client == nil { 127 | cfg.client = redis.NewClient(cfg.Options) 128 | } 129 | 130 | if cfg.KeyPrefix == "" { 131 | cfg.KeyPrefix = "cache:" 132 | } 133 | if cfg.Encoder == nil { 134 | cfg.Encoder = cache.GobEncoder 135 | } 136 | if cfg.Decoder == nil { 137 | cfg.Decoder = func(binary []byte) (interface{}, error) { 138 | buf := bytes.NewBuffer(binary) 139 | var v item 140 | return &v, gob.NewDecoder(buf).Decode(&v) 141 | } 142 | } 143 | 144 | return newRedisStore(*cfg), nil 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /redis/redis_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package redis 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/gob" 11 | "net/http" 12 | "net/http/httptest" 13 | "os" 14 | "testing" 15 | "time" 16 | 17 | "github.com/redis/go-redis/v9" 18 | "github.com/stretchr/testify/assert" 19 | 20 | "github.com/flamego/flamego" 21 | 22 | "github.com/flamego/cache" 23 | ) 24 | 25 | func newTestClient(t *testing.T, ctx context.Context) (testClient *redis.Client, cleanup func() error) { 26 | const db = 15 27 | testClient = redis.NewClient( 28 | &redis.Options{ 29 | Addr: os.ExpandEnv("$REDIS_HOST:$REDIS_PORT"), 30 | DB: db, 31 | }, 32 | ) 33 | 34 | err := testClient.FlushDB(ctx).Err() 35 | if err != nil { 36 | t.Fatalf("Failed to flush test database: %v", err) 37 | } 38 | 39 | t.Cleanup(func() { 40 | defer func() { _ = testClient.Close() }() 41 | 42 | if t.Failed() { 43 | t.Logf("DATABASE %d left intact for inspection", db) 44 | return 45 | } 46 | 47 | err := testClient.FlushDB(ctx).Err() 48 | if err != nil { 49 | t.Fatalf("Failed to flush test database: %v", err) 50 | } 51 | }) 52 | return testClient, func() error { 53 | if t.Failed() { 54 | return nil 55 | } 56 | 57 | err := testClient.FlushDB(ctx).Err() 58 | if err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | } 64 | 65 | func init() { 66 | gob.Register(time.Duration(0)) 67 | } 68 | 69 | func TestRedisStore(t *testing.T) { 70 | ctx := context.Background() 71 | client, cleanup := newTestClient(t, ctx) 72 | t.Cleanup(func() { 73 | assert.Nil(t, cleanup()) 74 | }) 75 | 76 | f := flamego.NewWithLogger(&bytes.Buffer{}) 77 | f.Use(cache.Cacher( 78 | cache.Options{ 79 | Initer: Initer(), 80 | Config: Config{ 81 | client: client, 82 | }, 83 | }, 84 | )) 85 | 86 | f.Get("/", func(c flamego.Context, cache cache.Cache) { 87 | ctx := c.Request().Context() 88 | 89 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 90 | 91 | v, err := cache.Get(ctx, "username") 92 | assert.Nil(t, err) 93 | username, ok := v.(string) 94 | assert.True(t, ok) 95 | assert.Equal(t, "flamego", username) 96 | 97 | assert.Nil(t, cache.Delete(ctx, "username")) 98 | _, err = cache.Get(ctx, "username") 99 | assert.Equal(t, os.ErrNotExist, err) 100 | 101 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 102 | v, err = cache.Get(ctx, "timeout") 103 | assert.Nil(t, err) 104 | timeout, ok := v.(time.Duration) 105 | assert.True(t, ok) 106 | assert.Equal(t, time.Minute, timeout) 107 | 108 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 109 | assert.Nil(t, cache.Flush(ctx)) 110 | _, err = cache.Get(ctx, "random") 111 | assert.Equal(t, os.ErrNotExist, err) 112 | }) 113 | 114 | resp := httptest.NewRecorder() 115 | req, err := http.NewRequest(http.MethodGet, "/", nil) 116 | assert.Nil(t, err) 117 | 118 | f.ServeHTTP(resp, req) 119 | 120 | assert.Equal(t, http.StatusOK, resp.Code) 121 | } 122 | 123 | func TestRedisStore_GC(t *testing.T) { 124 | ctx := context.Background() 125 | client, cleanup := newTestClient(t, ctx) 126 | t.Cleanup(func() { 127 | assert.Nil(t, cleanup()) 128 | }) 129 | 130 | store, err := Initer()( 131 | ctx, 132 | Config{ 133 | client: client, 134 | }, 135 | ) 136 | assert.Nil(t, err) 137 | 138 | assert.Nil(t, store.Set(ctx, "1", "1", 1*time.Second)) 139 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 140 | assert.Nil(t, store.Set(ctx, "3", "3", 3*time.Second)) 141 | 142 | // Read on an expired cache item should remove it 143 | time.Sleep(2 * time.Second) 144 | _, err = store.Get(ctx, "1") 145 | assert.Equal(t, os.ErrNotExist, err) 146 | 147 | // "2" should be recycled 148 | assert.Nil(t, store.GC(ctx)) 149 | _, err = store.Get(ctx, "2") 150 | assert.Equal(t, os.ErrNotExist, err) 151 | 152 | // "3" should be returned 153 | v, err := store.Get(ctx, "3") 154 | assert.Nil(t, err) 155 | assert.Equal(t, "3", v) 156 | } 157 | -------------------------------------------------------------------------------- /sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sqlite 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "fmt" 13 | "os" 14 | "time" 15 | 16 | "github.com/pkg/errors" 17 | _ "modernc.org/sqlite" 18 | 19 | "github.com/flamego/cache" 20 | ) 21 | 22 | var _ cache.Cache = (*sqliteStore)(nil) 23 | 24 | // sqliteStore is a SQLite implementation of the cache store. 25 | type sqliteStore struct { 26 | nowFunc func() time.Time // The function to return the current time 27 | db *sql.DB // The database connection 28 | table string // The database table for storing cache data 29 | encoder cache.Encoder // The encoder to encode the cache data before saving 30 | decoder cache.Decoder // The decoder to decode binary to cache data after reading 31 | } 32 | 33 | // newSQLiteStore returns a new SQLite cache store based on given 34 | // configuration. 35 | func newSQLiteStore(cfg Config) *sqliteStore { 36 | return &sqliteStore{ 37 | nowFunc: cfg.nowFunc, 38 | db: cfg.db, 39 | table: cfg.Table, 40 | encoder: cfg.Encoder, 41 | decoder: cfg.Decoder, 42 | } 43 | } 44 | 45 | type item struct { 46 | Value interface{} 47 | } 48 | 49 | func (s *sqliteStore) Get(ctx context.Context, key string) (interface{}, error) { 50 | var binary []byte 51 | q := fmt.Sprintf(`SELECT data FROM %q WHERE key = $1 AND datetime(expired_at) > datetime($2)`, s.table) 52 | err := s.db.QueryRowContext(ctx, q, key, s.nowFunc().UTC().Format(time.DateTime)).Scan(&binary) 53 | if err != nil { 54 | if err == sql.ErrNoRows { 55 | return nil, os.ErrNotExist 56 | } 57 | return nil, errors.Wrap(err, "select") 58 | } 59 | 60 | v, err := s.decoder(binary) 61 | if err != nil { 62 | return nil, errors.Wrap(err, "decode") 63 | } 64 | 65 | item, ok := v.(*item) 66 | if !ok { 67 | return nil, os.ErrNotExist 68 | } 69 | return item.Value, nil 70 | } 71 | 72 | func (s *sqliteStore) Set(ctx context.Context, key string, value interface{}, lifetime time.Duration) error { 73 | binary, err := s.encoder(item{value}) 74 | if err != nil { 75 | return errors.Wrap(err, "encode") 76 | } 77 | 78 | q := fmt.Sprintf(` 79 | INSERT INTO %q (key, data, expired_at) 80 | VALUES ($1, $2, $3) 81 | ON CONFLICT (key) 82 | DO UPDATE SET 83 | data = excluded.data, 84 | expired_at = excluded.expired_at 85 | `, s.table) 86 | _, err = s.db.ExecContext(ctx, q, key, binary, s.nowFunc().Add(lifetime).UTC().Format(time.DateTime)) 87 | if err != nil { 88 | return errors.Wrap(err, "upsert") 89 | } 90 | return nil 91 | } 92 | 93 | func (s *sqliteStore) Delete(ctx context.Context, key string) error { 94 | q := fmt.Sprintf(`DELETE FROM %q WHERE key = $1`, s.table) 95 | _, err := s.db.ExecContext(ctx, q, key) 96 | return err 97 | } 98 | 99 | func (s *sqliteStore) Flush(ctx context.Context) error { 100 | q := fmt.Sprintf(`DELETE FROM %q`, s.table) 101 | _, err := s.db.ExecContext(ctx, q) 102 | return err 103 | } 104 | 105 | func (s *sqliteStore) GC(ctx context.Context) error { 106 | q := fmt.Sprintf(`DELETE FROM %q WHERE datetime(expired_at) <= datetime($1)`, s.table) 107 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC().Format(time.DateTime)) 108 | return err 109 | } 110 | 111 | // Config contains options for the SQLite cache store. 112 | type Config struct { 113 | // For tests only 114 | nowFunc func() time.Time 115 | db *sql.DB 116 | 117 | // DSN is the database source name to the SQLite. 118 | DSN string 119 | // Table is the table name for storing cache data. Default is "cache". 120 | Table string 121 | // Encoder is the encoder to encode cache data. Default is a Gob encoder. 122 | Encoder cache.Encoder 123 | // Decoder is the decoder to decode cache data. Default is a Gob decoder. 124 | Decoder cache.Decoder 125 | // InitTable indicates whether to create a default cache table when not exists automatically. 126 | InitTable bool 127 | } 128 | 129 | // Initer returns the cache.Initer for the SQLite cache store. 130 | func Initer() cache.Initer { 131 | return func(ctx context.Context, args ...interface{}) (cache.Cache, error) { 132 | var cfg *Config 133 | for i := range args { 134 | switch v := args[i].(type) { 135 | case Config: 136 | cfg = &v 137 | } 138 | } 139 | 140 | if cfg == nil { 141 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 142 | } else if cfg.DSN == "" && cfg.db == nil { 143 | return nil, errors.New("empty DSN") 144 | } 145 | 146 | if cfg.db == nil { 147 | db, err := sql.Open("sqlite", cfg.DSN) 148 | if err != nil { 149 | return nil, errors.Wrap(err, "open database") 150 | } 151 | cfg.db = db 152 | } 153 | 154 | if cfg.InitTable { 155 | q := ` 156 | CREATE TABLE IF NOT EXISTS cache ( 157 | key TEXT PRIMARY KEY, 158 | data BLOB NOT NULL, 159 | expired_at TEXT NOT NULL 160 | )` 161 | if _, err := cfg.db.ExecContext(ctx, q); err != nil { 162 | return nil, errors.Wrap(err, "create table") 163 | } 164 | } 165 | 166 | if cfg.nowFunc == nil { 167 | cfg.nowFunc = time.Now 168 | } 169 | if cfg.Table == "" { 170 | cfg.Table = "cache" 171 | } 172 | if cfg.Encoder == nil { 173 | cfg.Encoder = cache.GobEncoder 174 | } 175 | if cfg.Decoder == nil { 176 | cfg.Decoder = func(binary []byte) (interface{}, error) { 177 | buf := bytes.NewBuffer(binary) 178 | var v item 179 | return &v, gob.NewDecoder(buf).Decode(&v) 180 | } 181 | } 182 | 183 | return newSQLiteStore(*cfg), nil 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /sqlite/sqlite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sqlite 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "encoding/gob" 12 | "fmt" 13 | "net/http" 14 | "net/http/httptest" 15 | "os" 16 | "path/filepath" 17 | "testing" 18 | "time" 19 | 20 | "github.com/flamego/flamego" 21 | "github.com/stretchr/testify/assert" 22 | 23 | "github.com/flamego/cache" 24 | ) 25 | 26 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 27 | dbname := filepath.Join(os.TempDir(), fmt.Sprintf("flamego-test-cache-%d.db", time.Now().Unix())) 28 | testDB, err := sql.Open("sqlite", dbname) 29 | if err != nil { 30 | t.Fatalf("Failed to open test database: %v", err) 31 | } 32 | 33 | t.Cleanup(func() { 34 | defer func() { _ = testDB.Close() }() 35 | 36 | if t.Failed() { 37 | t.Logf("DATABASE %s left intact for inspection", dbname) 38 | return 39 | } 40 | 41 | err := testDB.Close() 42 | if err != nil { 43 | t.Fatalf("Failed to close test connection: %v", err) 44 | } 45 | err = os.Remove(dbname) 46 | if err != nil { 47 | t.Fatalf("Failed to delete test database: %v", err) 48 | } 49 | }) 50 | return testDB, func() error { 51 | if t.Failed() { 52 | return nil 53 | } 54 | 55 | _, err = testDB.ExecContext(ctx, `DELETE FROM cache`) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | } 62 | 63 | func init() { 64 | gob.Register(time.Duration(0)) 65 | } 66 | 67 | func TestSQLiteStore(t *testing.T) { 68 | ctx := context.Background() 69 | db, cleanup := newTestDB(t, ctx) 70 | t.Cleanup(func() { 71 | assert.Nil(t, cleanup()) 72 | }) 73 | 74 | f := flamego.NewWithLogger(&bytes.Buffer{}) 75 | f.Use(cache.Cacher( 76 | cache.Options{ 77 | Initer: Initer(), 78 | Config: Config{ 79 | nowFunc: time.Now, 80 | db: db, 81 | InitTable: true, 82 | }, 83 | }, 84 | )) 85 | 86 | f.Get("/", func(c flamego.Context, cache cache.Cache) { 87 | ctx := c.Request().Context() 88 | 89 | assert.Nil(t, cache.Set(ctx, "username", "flamego", time.Minute)) 90 | 91 | v, err := cache.Get(ctx, "username") 92 | assert.Nil(t, err) 93 | username, ok := v.(string) 94 | assert.True(t, ok) 95 | assert.Equal(t, "flamego", username) 96 | 97 | assert.Nil(t, cache.Delete(ctx, "username")) 98 | _, err = cache.Get(ctx, "username") 99 | assert.Equal(t, os.ErrNotExist, err) 100 | 101 | assert.Nil(t, cache.Set(ctx, "timeout", time.Minute, time.Hour)) 102 | v, err = cache.Get(ctx, "timeout") 103 | assert.Nil(t, err) 104 | timeout, ok := v.(time.Duration) 105 | assert.True(t, ok) 106 | assert.Equal(t, time.Minute, timeout) 107 | 108 | assert.Nil(t, cache.Set(ctx, "random", "value", time.Minute)) 109 | assert.Nil(t, cache.Flush(ctx)) 110 | _, err = cache.Get(ctx, "random") 111 | assert.Equal(t, os.ErrNotExist, err) 112 | }) 113 | 114 | resp := httptest.NewRecorder() 115 | req, err := http.NewRequest(http.MethodGet, "/", nil) 116 | assert.Nil(t, err) 117 | 118 | f.ServeHTTP(resp, req) 119 | 120 | assert.Equal(t, http.StatusOK, resp.Code) 121 | } 122 | 123 | func TestSQLiteStore_GC(t *testing.T) { 124 | ctx := context.Background() 125 | db, cleanup := newTestDB(t, ctx) 126 | t.Cleanup(func() { 127 | assert.Nil(t, cleanup()) 128 | }) 129 | 130 | now := time.Now() 131 | store, err := Initer()( 132 | ctx, 133 | Config{ 134 | nowFunc: func() time.Time { return now }, 135 | db: db, 136 | InitTable: true, 137 | }, 138 | ) 139 | assert.Nil(t, err) 140 | 141 | assert.Nil(t, store.Set(ctx, "1", "1", time.Second)) 142 | assert.Nil(t, store.Set(ctx, "2", "2", 2*time.Second)) 143 | assert.Nil(t, store.Set(ctx, "3", "3", 4*time.Second)) 144 | 145 | // Read on an expired cache item should remove it. 146 | now = now.Add(2 * time.Second) 147 | _, err = store.Get(ctx, "1") 148 | assert.Equal(t, os.ErrNotExist, err) 149 | 150 | // "2" should be recycled 151 | assert.Nil(t, store.GC(ctx)) 152 | _, err = store.Get(ctx, "2") 153 | assert.Equal(t, os.ErrNotExist, err) 154 | 155 | // "3" should be returned 156 | v, err := store.Get(ctx, "3") 157 | assert.Nil(t, err) 158 | assert.Equal(t, "3", v) 159 | } 160 | -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "bytes" 9 | "encoding/gob" 10 | ) 11 | 12 | // Encoder is an encoder to encode cache data to binary. 13 | type Encoder func(interface{}) ([]byte, error) 14 | 15 | // Decoder is a decoder to decode binary to cache data. 16 | type Decoder func([]byte) (interface{}, error) 17 | 18 | // GobEncoder is a cache data encoder using Gob. 19 | func GobEncoder(v interface{}) ([]byte, error) { 20 | var buf bytes.Buffer 21 | err := gob.NewEncoder(&buf).Encode(v) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return buf.Bytes(), nil 26 | } 27 | --------------------------------------------------------------------------------