switch title to lcw cache, optimize error hits

This commit is contained in:
Umputun
2019-02-02 18:25:57 -06:00
parent 0af3eda684
commit 588af7fc9a
13 changed files with 568 additions and 8 deletions
+9
View File
@@ -126,6 +126,14 @@
revision = "7597c083287c33ba5362687a7815a032fd92c418"
version = "v0.4.1"
[[projects]]
digest = "1:c4cb8b521f8e55dd02e1ed0952953adb35dbcc8eca8aeac9d707abc7ed625843"
name = "github.com/go-pkgz/lcw"
packages = ["."]
pruneopts = "UT"
revision = "4f4492679c5c96714bc4b25d195036534bb01f72"
version = "v0.2.0"
[[projects]]
digest = "1:7b1f422f560b103f435f8501f854f238f1ce00d0f8146e76eacdfa71f2c8d8c0"
name = "github.com/go-pkgz/lgr"
@@ -413,6 +421,7 @@
"github.com/go-pkgz/auth/avatar",
"github.com/go-pkgz/auth/provider",
"github.com/go-pkgz/auth/token",
"github.com/go-pkgz/lcw",
"github.com/go-pkgz/lgr",
"github.com/go-pkgz/mongo",
"github.com/go-pkgz/repeater",
+11 -8
View File
@@ -3,9 +3,10 @@ package service
import (
"io"
"net/http"
"time"
"github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/rest/cache"
"github.com/pkg/errors"
"golang.org/x/net/html"
)
@@ -15,7 +16,7 @@ const teMaxCachedRecs = 1000
// TitleExtractor gets html title from remote page, cached
type TitleExtractor struct {
client http.Client
cache cache.LoadingCache
cache lcw.LoadingCache
}
// NewTitleExtractor makes extractor with cache. If memory cache failed, switching to no-cache
@@ -24,10 +25,10 @@ func NewTitleExtractor(client http.Client) *TitleExtractor {
client: client,
}
var err error
res.cache, err = cache.NewMemoryCache(cache.MaxKeys(teMaxCachedRecs))
res.cache, err = lcw.NewExpirableCache(lcw.TTL(15*time.Minute), lcw.MaxKeySize(teMaxCachedRecs))
if err != nil {
log.Printf("[WARN] failed to make cache, %v", err)
res.cache = &cache.Nop{}
log.Printf("[WARN] failed to make cache, caching disabled for titles, %v", err)
res.cache = &lcw.Nop{}
}
return &res
}
@@ -35,7 +36,7 @@ func NewTitleExtractor(client http.Client) *TitleExtractor {
// Get page for url and return title
func (t *TitleExtractor) Get(url string) (string, error) {
client := http.Client{Timeout: t.client.Timeout, Transport: t.client.Transport}
b, err := t.cache.Get(cache.NewKey("site").ID(url), func() ([]byte, error) {
b, err := t.cache.Get(url, func() (lcw.Value, error) {
resp, err := client.Get(url)
if err != nil {
return nil, errors.Wrapf(err, "failed to load page %s", url)
@@ -49,14 +50,16 @@ func (t *TitleExtractor) Get(url string) (string, error) {
if !ok {
return nil, errors.Errorf("can't get title for %s", url)
}
return []byte(title), nil
return title, nil
})
// on error save result (empty strung) to cache too but
if err != nil {
_, _ = t.cache.Get(url, func() (lcw.Value, error) { return "", nil })
return "", err
}
return string(b), nil
return b.(string), nil
}
// get title from body reader, traverse recursively
+19
View File
@@ -93,3 +93,22 @@ func TestTitle_GetConcurrent(t *testing.T) {
g.Wait()
assert.Equal(t, int32(100), atomic.LoadInt32(&hits))
}
func TestTitle_GetFailed(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second})
var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(404)
}))
_, err := ex.Get(ts.URL + "/bad")
require.NotNil(t, err)
for i := 0; i < 100; i++ {
title, err := ex.Get(ts.URL + "/bad")
require.Nil(t, err)
assert.Equal(t, "", title)
}
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached")
}
+12
View File
@@ -0,0 +1,12 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
+21
View File
@@ -0,0 +1,21 @@
language: go
go:
- "1.11.x"
install: true
go_import_path: github.com/go-pkgz/lcw
before_install:
- export TZ=America/Chicago
- curl -L https://git.io/vp6lP | sh
- go get github.com/mattn/goveralls
- export PATH=$(pwd)/bin:$PATH
script:
- GO111MODULE=on go get ./...
- GO111MODULE=on go mod vendor
- GO111MODULE=on go test -v -mod=vendor -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1;
- ./bin/gometalinter --deadline=120s --exclude=test --exclude=mock --exclude=vendor --exclude=_example --disable-all --enable=errcheck --enable=vet --enable=vetshadow --enable=megacheck --enable=ineffassign --enable=varcheck --enable=unconvert --enable=deadcode --enable=interfacer --enable=gotype ./... || travis_terminate 1;
- $GOPATH/bin/goveralls -coverprofile=profile.cov -service=travis-ci
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+52
View File
@@ -0,0 +1,52 @@
# Loading Cache Wrapper [![Build Status](https://travis-ci.org/go-pkgz/lcw.svg?branch=master)](https://travis-ci.org/go-pkgz/lcw) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/lcw/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/lcw?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/lcw?status.svg)](https://godoc.org/github.com/go-pkgz/lcw)
The library adds a thin layer on top of [lru cache](https://github.com/hashicorp/golang-lru) and [patrickmn/go-cache](https://github.com/patrickmn/go-cache).
| Cache name | Constructor | Defaults | Description |
| -------------- | --------------------- | ----------------- | --------------------- |
| LruCache | lcw.NewLruCache | keys=1000 | LRU cache with limits |
| ExpirableCache | lcw.NewExpirableCache | keys=1000, ttl=5m | TTL cache with limits |
| Nop | lcw.NewNopCache | | Do-nothing cache |
Main features:
- LoadingCache (guava style)
- Limit maximum cache size (in bytes)
- Limit maximum key size
- Limit maximum size of a value
- Limit number of keys
- TTL support (`ExpirableCache` only)
- Functional style invalidation
- Functional options
- Sane defaults
## Install and update
`go get -u github.com/go-pkgz/lcw`
## Usage
```
cache := lcw.NewLruCache(lcw.MaxKeys(500), lcw.MaxCacheSize(65536), lcw.MaxValSize(200), lcw.MaxKeySize(32))
val, err := cache.Get("key123", func() (lcw.Value, error) {
res, err := getDataFromSomeSource(params) // returns string
return res, err
})
if err != nil {
panic("failed to get data")
}
s := val.(string) // cached value
```
## Details
- All byte-size limits (MaxCacheSize and MaxValSize) only work for values implementing `lcw.Sizer` interface.
- Negative limits (max options) rejected
- `lgr.Value` wraps `interface{}` and should be converted back to the concrete type.
- The implementation started as a part of [remark42](https://github.com/umputun/remark) and later on moved to [go-pkgz/rest](https://github.com/go-pkgz/rest/tree/master/cache) library and finaly generalized to become `lcw`.
+133
View File
@@ -0,0 +1,133 @@
package lcw
import (
"sync/atomic"
"time"
cache "github.com/patrickmn/go-cache"
"github.com/pkg/errors"
)
// ExpirableCache implements LoadingCache with TTL.
type ExpirableCache struct {
options
CacheStat
currentSize int64
currKeys int64
backend *cache.Cache
}
// NewExpirableCache makes expirable LoadingCache implementation, 1000 max keys by default and 5s TTL
func NewExpirableCache(opts ...Option) (*ExpirableCache, error) {
res := ExpirableCache{
options: options{
maxKeys: 1000,
maxValueSize: 0,
ttl: 5 * time.Minute,
},
}
for _, opt := range opts {
if err := opt(&res.options); err != nil {
return nil, errors.Wrap(err, "failed to set cache option")
}
}
res.backend = cache.New(res.ttl, res.ttl/2)
// OnEvicted called automatically for expired and manually deleted
res.backend.OnEvicted(func(key string, value interface{}) {
atomic.AddInt64(&res.currKeys, -1)
if s, ok := value.(Sizer); ok {
size := s.Size()
atomic.AddInt64(&res.currentSize, -1*int64(size))
}
})
return &res, nil
}
// Get gets value by key or load with fn if not found in cache
func (c *ExpirableCache) Get(key string, fn func() (Value, error)) (data Value, err error) {
if v, ok := c.backend.Get(key); ok {
atomic.AddInt64(&c.Hits, 1)
return v, nil
}
if data, err = fn(); err != nil {
atomic.AddInt64(&c.Errors, 1)
return data, err
}
atomic.AddInt64(&c.Misses, 1)
if c.allowed(key, data) {
if s, ok := data.(Sizer); ok {
if c.maxCacheSize > 0 && atomic.LoadInt64(&c.currentSize)+int64(s.Size()) >= c.maxCacheSize {
c.backend.DeleteExpired()
return data, nil
}
atomic.AddInt64(&c.currentSize, int64(s.Size()))
}
atomic.AddInt64(&c.currKeys, 1)
_ = c.backend.Add(key, data, time.Second)
}
return data, nil
}
// Invalidate removes keys with passed predicate fn, i.e. fn(key) should be true to get evicted
func (c *ExpirableCache) Invalidate(fn func(key string) bool) {
for key := range c.backend.Items() { // Keys() returns copy of cache's key, safe to remove directly
if fn(key) {
c.backend.Delete(key)
}
}
}
// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key.
func (c *ExpirableCache) Peek(key string) (Value, bool) {
return c.backend.Get(key)
}
// Purge clears the cache completely.
func (c *ExpirableCache) Purge() {
c.backend.Flush()
atomic.StoreInt64(&c.currentSize, 0)
atomic.StoreInt64(&c.currKeys, 0)
}
// Stat returns cache statistics
func (c *ExpirableCache) Stat() CacheStat {
return CacheStat{
Hits: c.Hits,
Misses: c.Misses,
Size: c.size(),
Keys: c.keys(),
Errors: c.Errors,
}
}
func (c *ExpirableCache) size() int64 {
return atomic.LoadInt64(&c.currentSize)
}
func (c *ExpirableCache) keys() int {
return int(atomic.LoadInt64(&c.currKeys))
}
func (c *ExpirableCache) allowed(key string, data Value) bool {
if atomic.LoadInt64(&c.currKeys) >= int64(c.maxKeys) {
return false
}
if c.maxKeySize > 0 && len(key) > c.maxKeySize {
return false
}
if s, ok := data.(Sizer); ok {
if c.maxValueSize > 0 && s.Size() >= c.maxValueSize {
return false
}
}
return true
}
+9
View File
@@ -0,0 +1,9 @@
module github.com/go-pkgz/lcw
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.8.1
github.com/stretchr/testify v1.3.0
)
+15
View File
@@ -0,0 +1,15 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+61
View File
@@ -0,0 +1,61 @@
package lcw
import "fmt"
// Value type wraps interface{}
type Value interface{}
// Sizer allows to perform size-based restrictions, optional.
// If not defined both maxValueSize and maxCacheSize checks will be ignored
type Sizer interface {
Size() int
}
// LoadingCache defines guava-like cache with Get method returning cached value ao retriving it if not in cache
type LoadingCache interface {
Get(key string, fn func() (Value, error)) (val Value, err error) // load or get from cache
Peek(key string) (Value, bool) // get from cache by key
Invalidate(fn func(key string) bool) // invalidate items for func(key) == true
Purge() // clear cache
Stat() CacheStat // cache stats
}
// CacheStat represent stats values
type CacheStat struct {
Hits int64
Misses int64
Keys int
Size int64
Errors int64
}
// String fromats cache stats
func (s *CacheStat) String() string {
return fmt.Sprintf("{hits:%d, misses:%d, ratio:%.1f%%, keys:%d, size:%d, errors:%d}",
s.Hits, s.Misses, 100*(float64(s.Hits)/float64(s.Hits+s.Misses)), s.Keys, s.Size, s.Errors)
}
// Nop is do-nothing implementation of LoadingCache
type Nop struct{}
// NewNopCache makes new do-nothing cache
func NewNopCache() *Nop {
return &Nop{}
}
// Get calls fn without any caching
func (n *Nop) Get(key string, fn func() (Value, error)) (Value, error) { return fn() }
// Peek does nothing and always returns false
func (n *Nop) Peek(key string) (Value, bool) { return nil, false }
// Invalidate does nothing for nop cache
func (n *Nop) Invalidate(fn func(key string) bool) {}
// Purge does nothing for nop cache
func (n *Nop) Purge() {}
// Stat always 0s for nop cache
func (n *Nop) Stat() CacheStat {
return CacheStat{}
}
+128
View File
@@ -0,0 +1,128 @@
package lcw
import (
"sync/atomic"
lru "github.com/hashicorp/golang-lru"
"github.com/pkg/errors"
)
// LruCache wraps lru.LruCache with laoding cache Get and size limits
type LruCache struct {
options
CacheStat
backend *lru.Cache
currentSize int64
}
// NewLruCache makes LRU LoadingCache implementation, 1000 max keys by default
func NewLruCache(opts ...Option) (*LruCache, error) {
res := LruCache{
options: options{
maxKeys: 1000,
maxValueSize: 0,
},
}
for _, opt := range opts {
if err := opt(&res.options); err != nil {
return nil, errors.Wrap(err, "failed to set cache option")
}
}
onEvicted := func(key interface{}, value interface{}) {
if s, ok := value.(Sizer); ok {
size := s.Size()
atomic.AddInt64(&res.currentSize, -1*int64(size))
}
}
var err error
// OnEvicted called automatically for expired and manually deleted
if res.backend, err = lru.NewWithEvict(res.maxKeys, onEvicted); err != nil {
return nil, errors.Wrap(err, "failed to make lru cache backend")
}
return &res, nil
}
// Get gets value by key or load with fn if not found in cache
func (c *LruCache) Get(key string, fn func() (Value, error)) (data Value, err error) {
if v, ok := c.backend.Get(key); ok {
atomic.AddInt64(&c.Hits, 1)
return v, nil
}
if data, err = fn(); err != nil {
atomic.AddInt64(&c.Errors, 1)
return data, err
}
atomic.AddInt64(&c.Misses, 1)
if c.allowed(key, data) {
c.backend.Add(key, data)
if s, ok := data.(Sizer); ok {
atomic.AddInt64(&c.currentSize, int64(s.Size()))
if c.maxCacheSize > 0 && atomic.LoadInt64(&c.currentSize) > c.maxCacheSize {
for atomic.LoadInt64(&c.currentSize) > c.maxCacheSize {
c.backend.RemoveOldest()
}
}
}
}
return data, nil
}
// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key.
func (c *LruCache) Peek(key string) (Value, bool) {
return c.backend.Peek(key)
}
// Purge clears the cache completely.
func (c *LruCache) Purge() {
c.backend.Purge()
atomic.StoreInt64(&c.currentSize, 0)
}
// Invalidate removes keys with passed predicate fn, i.e. fn(key) should be true to get evicted
func (c *LruCache) Invalidate(fn func(key string) bool) {
for _, k := range c.backend.Keys() { // Keys() returns copy of cache's key, safe to remove directly
if key, ok := k.(string); ok && fn(key) {
c.backend.Remove(key)
}
}
}
// Stat returns cache statistics
func (c *LruCache) Stat() CacheStat {
return CacheStat{
Hits: c.Hits,
Misses: c.Misses,
Size: c.size(),
Keys: c.keys(),
Errors: c.Errors,
}
}
func (c *LruCache) size() int64 {
return atomic.LoadInt64(&c.currentSize)
}
func (c *LruCache) keys() int {
return c.backend.Len()
}
func (c *LruCache) allowed(key string, data Value) bool {
if c.maxKeySize > 0 && len(key) > c.maxKeySize {
return false
}
if s, ok := data.(Sizer); ok {
if c.maxValueSize > 0 && s.Size() >= c.maxValueSize {
return false
}
}
return true
}
+77
View File
@@ -0,0 +1,77 @@
package lcw
import (
"errors"
"time"
)
type options struct {
maxKeys int
maxValueSize int
maxKeySize int
maxCacheSize int64
ttl time.Duration
}
// Option func type
type Option func(o *options) error
// MaxValSize functional option defines the largest value's size allowed to be cached
// By default it is 0, which means unlimited.
func MaxValSize(max int) Option {
return func(o *options) error {
if max < 0 {
return errors.New("negative max value size")
}
o.maxValueSize = max
return nil
}
}
// MaxKeySize functional option defines the largest key's size allowed to be used in cache
// By default it is 0, which means unlimited.
func MaxKeySize(max int) Option {
return func(o *options) error {
if max < 0 {
return errors.New("negative max key size")
}
o.maxKeySize = max
return nil
}
}
// MaxKeys functional option defines how many keys to keep.
// By default it is 0, which means unlimited.
func MaxKeys(max int) Option {
return func(o *options) error {
if max < 0 {
return errors.New("negative max keys")
}
o.maxKeys = max
return nil
}
}
// MaxCacheSize functional option defines the total size of cached data.
// By default it is 0, which means unlimited.
func MaxCacheSize(max int64) Option {
return func(o *options) error {
if max < 0 {
return errors.New("negative max cache size")
}
o.maxCacheSize = max
return nil
}
}
// TTL functional option defines duration.
// Works for ExpirableCache only
func TTL(ttl time.Duration) Option {
return func(o *options) error {
if ttl < 0 {
return errors.New("negative ttl")
}
o.ttl = ttl
return nil
}
}