diff --git a/go.mod b/go.mod index 3bc6ace4..810c49d6 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 + github.com/mailgun/groupcache/v2 v2.5.0 github.com/maypok86/otter v1.2.1 github.com/mitchellh/mapstructure v1.5.0 github.com/redis/go-redis/v9 v9.5.3 + github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 @@ -26,15 +28,21 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gammazero/deque v0.2.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/klauspost/compress v1.17.2 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/fasthash v1.0.3 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/smarty/assertions v1.15.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -48,5 +56,6 @@ require ( golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 402e448a..3e13b1c7 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -58,6 +60,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -70,6 +74,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -83,6 +89,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailgun/groupcache/v2 v2.5.0 h1:FoNR52GyTQ4jLoliSuyXDANMEoxts6M8ql9jW3htvq8= +github.com/mailgun/groupcache/v2 v2.5.0/go.mod h1:7+O6vXEKAhloSTOJOmkhyksS8l/gIs15fv0ER1ZuhPA= github.com/maypok86/otter v1.2.1 h1:xyvMW+t0vE1sKt/++GTkznLitEl7D/msqXkAbLwiC1M= github.com/maypok86/otter v1.2.1/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -129,6 +137,14 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= +github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -222,6 +238,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -264,6 +281,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/caches/group.go b/internal/caches/group.go new file mode 100644 index 00000000..072a76ab --- /dev/null +++ b/internal/caches/group.go @@ -0,0 +1,59 @@ +package caches + +import ( + "fmt" + + "github.com/Michad/tilegroxy/internal" + "github.com/Michad/tilegroxy/internal/caches/group" + "github.com/Michad/tilegroxy/internal/config" +) + +// GroupConfig is a local type so callers needn't import caches/group directly. +type GroupConfig group.Config + +// GroupCache is a caches.Cache to use a groupcache as a cache. +type GroupCache struct { + *group.Cache + conf group.Config +} + +// Lookup takes a TileRequest and returns an Image or an error. +func (g *GroupCache) Lookup(t internal.TileRequest) (*internal.Image, error) { + file := fmt.Sprintf("%d/%d/%d", t.Z, t.X, t.Y) // TODO + if v, ok := g.Cache.Get(t.LayerName, file); ok { + i := v.(internal.Image) + return &i, nil + } + return nil, group.ItemNotFoundError +} + +// Save takes a TileRequest and an Image, and returns an error if it cannot be set. +func (g *GroupCache) Save(t internal.TileRequest, img *internal.Image) error { + file := fmt.Sprintf("%d/%d/%d", t.Z, t.X, t.Y) // TODO + if !g.Exists(t.LayerName) { + // Add the cache if it doesn't exist + conf := g.conf + conf.Name = t.LayerName + g.Add(conf, nil) + } + return g.Cache.Set(t.LayerName, file, *img) +} + +// NewGroupCache creates a new GroupCache from the specified config and returns it, or returns an error. +func ConstructGroupCache(conf GroupConfig, errorMessages *config.ErrorMessages) (*GroupCache, error) { + + // GroupCache has the concept of a backfill, whereby it will pull from another source on cache miss, + // helping reduce hotspots and distributing the pull load. + // + // This feature isn't compatible with the []Cache concept, as is, so we set the backfill to nil, and + // instead will manually "Save()" + gconf := group.Config(conf) + gc, err := group.NewCache(gconf, nil) + if err != nil { + return nil, err + } + return &GroupCache{ + Cache: gc, + conf: gconf, + }, nil +} diff --git a/internal/caches/group/Readme.md b/internal/caches/group/Readme.md new file mode 100644 index 00000000..68a05c45 --- /dev/null +++ b/internal/caches/group/Readme.md @@ -0,0 +1,282 @@ + + +# group +`import "github.com/Michad/tilegroxy/internal/caches/group"` + +* [Overview](#pkg-overview) +* [Index](#pkg-index) + +## Overview + + + +## Index +* [Constants](#pkg-constants) +* [type BackFillFunc](#BackFillFunc) +* [type Cache](#Cache) + * [func NewCache(config Config, fillfunc BackFillFunc) (*Cache, error)](#NewCache) + * [func (gc *Cache) Add(config Config, fillfunc BackFillFunc) error](#Cache.Add) + * [func (gc *Cache) Close() error](#Cache.Close) + * [func (gc *Cache) Exists(name string) bool](#Cache.Exists) + * [func (gc *Cache) Get(cacheName, key string) (value interface{}, ok bool)](#Cache.Get) + * [func (gc *Cache) GetContext(ctx context.Context, cacheName, key string) (value interface{}, ok bool)](#Cache.GetContext) + * [func (gc *Cache) Names() []string](#Cache.Names) + * [func (gc *Cache) Remove(cacheName, key string) error](#Cache.Remove) + * [func (gc *Cache) RemoveContext(ctx context.Context, cacheName, key string) error](#Cache.RemoveContext) + * [func (gc *Cache) Set(cacheName, key string, value []byte) error](#Cache.Set) + * [func (gc *Cache) SetContext(ctx context.Context, cacheName, key string, value []byte, expiration time.Time) error](#Cache.SetContext) + * [func (gc *Cache) SetDebugOut(logger *log.Logger)](#Cache.SetDebugOut) + * [func (gc *Cache) SetPeers(peers ...string)](#Cache.SetPeers) + * [func (gc *Cache) SetToExpireAt(cacheName, key string, expireAt time.Time, value []byte) error](#Cache.SetToExpireAt) + * [func (gc *Cache) Stats(w http.ResponseWriter, req *http.Request)](#Cache.Stats) +* [type Config](#Config) +* [type Error](#Error) + * [func (e Error) Error() string](#Error.Error) + + +#### Package files +[group.go](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go) [misc.go](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/misc.go) + + +## Constants +``` go +const ( + // NilBackfillError is returned by the Getter if there there is no backfill func, in lieu of panicing + NilBackfillError = Error("item not in cache and backfill func is nil") + // ItemNotFoundError is a generic error returned by a BackFillFunc if the item is not found or findable + ItemNotFoundError = Error("item not found") + // CacheNotFoundError is an error returned if the cache requested is not found + CacheNotFoundError = Error("cache not found") + // NameRequiredError is returned when creating or adding a cache, and the Config.Name field is empty + NameRequiredError = Error("name is required") +) +``` + + + + +## type [BackFillFunc](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=831:881#L27) +``` go +type BackFillFunc func(key string) ([]byte, error) +``` +BackFillFunc is a function that can retrieve an uncached item to go into the cache + + + + + + + + + + +## type [Cache](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=1090:1293#L31) +``` go +type Cache struct { + // contains filtered or unexported fields +} + +``` +Cache (group) is a distributed LRU cache where consistent hashing on keynames is used to cut out +"who's on first" nonsense, and backfills are linearly distributed to mitigate multiple-member requests. + + + + + + + +### func [NewCache](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=1479:1546#L44) +``` go +func NewCache(config Config, fillfunc BackFillFunc) (*Cache, error) +``` +NewCache creates a ache from the Config. Only call this once. If you need +more caches use the .Add() function. fillfunc may be nil if caches will be added later +using .Add(). + + + + + +### func (\*Cache) [Add](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=2316:2380#L81) +``` go +func (gc *Cache) Add(config Config, fillfunc BackFillFunc) error +``` +Add creates new caches in the cluster. Config.ListenAddress and Config.PeerList are ignored. + + + + +### func (\*Cache) [Close](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=3450:3480#L135) +``` go +func (gc *Cache) Close() error +``` +Close calls the listener close function + + + + +### func (\*Cache) [Exists](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=3247:3288#L124) +``` go +func (gc *Cache) Exists(name string) bool +``` +Exists returns true if the named cache exists. + + + + +### func (\*Cache) [Get](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=3617:3689#L141) +``` go +func (gc *Cache) Get(cacheName, key string) (value interface{}, ok bool) +``` +Get will return the value of the cacheName'd key, asking other cache members or +backfilling as necessary. + + + + +### func (\*Cache) [GetContext](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=3905:4005#L147) +``` go +func (gc *Cache) GetContext(ctx context.Context, cacheName, key string) (value interface{}, ok bool) +``` +GetContext will return the value of the cacheName'd key, asking other cache members or +backfilling as necessary, honoring the provided context. + + + + +### func (\*Cache) [Names](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=2999:3032#L110) +``` go +func (gc *Cache) Names() []string +``` +Names returns the names of the current caches + + + + +### func (\*Cache) [Remove](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=6289:6341#L198) +``` go +func (gc *Cache) Remove(cacheName, key string) error +``` +Remove makes a best effort to remove an item from the cache + + + + +### func (\*Cache) [RemoveContext](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=6512:6592#L203) +``` go +func (gc *Cache) RemoveContext(ctx context.Context, cacheName, key string) error +``` +RemoveContext makes a best effort to remove an item from the cache, honoring the provided context. + + + + +### func (\*Cache) [Set](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=4518:4581#L166) +``` go +func (gc *Cache) Set(cacheName, key string, value []byte) error +``` +Set forces an item into the cache, following the configured expiration policy + + + + +### func (\*Cache) [SetContext](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=4868:4981#L172) +``` go +func (gc *Cache) SetContext(ctx context.Context, cacheName, key string, value []byte, expiration time.Time) error +``` +SetContext forces an item into the cache, following the specified expiration (unless a zero Time is provided +then falling back to the configured expiration policy) honoring the provided context. + + + + +### func (\*Cache) [SetDebugOut](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=6826:6874#L212) +``` go +func (gc *Cache) SetDebugOut(logger *log.Logger) +``` +SetDebugOut wires in the debug logger to the specified logger + + + + +### func (\*Cache) [SetPeers](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=6961:7003#L217) +``` go +func (gc *Cache) SetPeers(peers ...string) +``` +SetPeers allows the dynamic [re]setting of the peerlist + + + + +### func (\*Cache) [SetToExpireAt](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=5978:6071#L192) +``` go +func (gc *Cache) SetToExpireAt(cacheName, key string, expireAt time.Time, value []byte) error +``` +SetToExpireAt forces an item into the cache, to expire at a specific time regardless of the cache configuration. Use +SetContext if you need to set the expiration and a context. + + + + +### func (\*Cache) [Stats](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/group.go?s=7100:7164#L222) +``` go +func (gc *Cache) Stats(w http.ResponseWriter, req *http.Request) +``` +Stats is a request finisher that outputs the Cache stats as JSON + + + + +## type [Config](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/misc.go?s=261:771#L16) +``` go +type Config struct { + Name string // For New and Add. Pass as ``cacheName`` to differentiate caches + ListenAddress string // Only for New to set the listener + PeerList []string // Only for New to establish the initial PeerList. May be reset with GroupCache.SetPeers() + CacheSize int64 // For New and Add to set the size in bytes of the cache + ItemExpiration time.Duration // For New and Add to set the default expiration duration. Leave as empty for infinite. +} + +``` +Config is used to store configuration information to pass to a GroupCache. + + + + + + + + + + +## type [Error](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/misc.go?s=61:78#L8) +``` go +type Error string +``` +Error is an error type + + + + + + + + + + +### func (Error) [Error](https://github.com/Michad/tilegroxy/tree/master/internal/caches/group/misc.go?s=130:159#L11) +``` go +func (e Error) Error() string +``` +Error returns the stringified version of Error + + + + + + + + +- - - +Generated by [godoc2md](http://github.com/cognusion/godoc2md) diff --git a/internal/caches/group/group.go b/internal/caches/group/group.go new file mode 100644 index 00000000..76dac153 --- /dev/null +++ b/internal/caches/group/group.go @@ -0,0 +1,268 @@ +package group + +import ( + "github.com/mailgun/groupcache/v2" + + "context" + "encoding/json" + "io" + "log" + "net/http" + "sync" + "time" +) + +const ( + // NilBackfillError is returned by the Getter if there there is no backfill func, in lieu of panicing + NilBackfillError = Error("item not in cache and backfill func is nil") + // ItemNotFoundError is a generic error returned by a BackFillFunc if the item is not found or findable + ItemNotFoundError = Error("item not found") + // CacheNotFoundError is an error returned if the cache requested is not found + CacheNotFoundError = Error("cache not found") + // NameRequiredError is returned when creating or adding a cache, and the Config.Name field is empty + NameRequiredError = Error("name is required") +) + +// BackFillFunc is a function that can retrieve an uncached item to go into the cache +type BackFillFunc func(key string) ([]byte, error) + +// Cache (group) is a distributed LRU cache where consistent hashing on keynames is used to cut out +// "who's on first" nonsense, and backfills are linearly distributed to mitigate multiple-member requests. +type Cache struct { + addr string + caches map[string]*groupcache.Group + pool *groupcache.HTTPPool + configs map[string]*Config + close func() error + debugOut *log.Logger + regLock sync.Mutex +} + +// NewCache creates a ache from the Config. Only call this once. If you need +// more caches use the .Add() function. fillfunc may be nil if caches will be added later +// using .Add(). +func NewCache(config Config, fillfunc BackFillFunc) (*Cache, error) { + + srv := http.Server{} + mux := http.NewServeMux() + + pool := groupcache.NewHTTPPoolOpts(config.PeerList[0], nil) + pool.Set(config.PeerList...) + mux.Handle("/", pool) + + srv.Handler = mux + srv.Addr = config.ListenAddress + + gc := Cache{ + addr: config.ListenAddress, + debugOut: log.New(io.Discard, "[DEBUG] ", 0), + pool: pool, + configs: make(map[string]*Config), + caches: make(map[string]*groupcache.Group), + close: srv.Close, + } + + if fillfunc != nil { + if err := gc.Add(config, fillfunc); err != nil { + return nil, err + } + } + + mux.HandleFunc("/stats", gc.Stats) + + go func(server *http.Server) { + server.ListenAndServe() + }(&srv) + + return &gc, nil +} + +// Add creates new caches in the cluster. Config.ListenAddress and Config.PeerList are ignored. +func (gc *Cache) Add(config Config, fillfunc BackFillFunc) error { + + var gf groupcache.GetterFunc = func(ctx context.Context, key string, dest groupcache.Sink) error { + + if fillfunc == nil { + return NilBackfillError + } + + value, err := fillfunc(key) + if err != nil { + return err + } + if config.ItemExpiration == 0 { + dest.SetBytes(value, time.Time{}) + } else { + dest.SetBytes(value, time.Now().Add(config.ItemExpiration)) + } + return nil + } + + gc.regLock.Lock() + defer gc.regLock.Unlock() + + gc.caches[config.Name] = groupcache.NewGroup(config.Name, config.CacheSize, gf) + gc.configs[config.Name] = &config + return nil +} + +// Names returns the names of the current caches +func (gc *Cache) Names() []string { + gc.regLock.Lock() + defer gc.regLock.Unlock() + + list := make([]string, len(gc.caches)) + i := 0 + for k := range gc.caches { + list[i] = k + i++ + } + return list +} + +// Exists returns true if the named cache exists. +func (gc *Cache) Exists(name string) bool { + gc.regLock.Lock() + defer gc.regLock.Unlock() + + if _, ok := gc.caches[name]; ok { + return true + } + return false +} + +// Close calls the listener close function +func (gc *Cache) Close() error { + return gc.close() +} + +// Get will return the value of the cacheName'd key, asking other cache members or +// backfilling as necessary. +func (gc *Cache) Get(cacheName, key string) (value interface{}, ok bool) { + return gc.GetContext(context.Background(), cacheName, key) +} + +// GetContext will return the value of the cacheName'd key, asking other cache members or +// backfilling as necessary, honoring the provided context. +func (gc *Cache) GetContext(ctx context.Context, cacheName, key string) (value interface{}, ok bool) { + gc.debugOut.Printf("Getting %s %s\n", cacheName, key) + return gc.get(ctx, cacheName, key) +} + +func (gc *Cache) get(ctx context.Context, cacheName, key string) (value interface{}, ok bool) { + if cache, ok := gc.caches[cacheName]; ok { + var b []byte + err := cache.Get(ctx, key, groupcache.AllocatingByteSliceSink(&b)) + if err != nil { + // crap + return err, false + } + return b, true + } + return CacheNotFoundError, false +} + +// Set forces an item into the cache, following the configured expiration policy +func (gc *Cache) Set(cacheName, key string, value []byte) error { + return gc.SetContext(context.Background(), cacheName, key, value, time.Time{}) +} + +// SetContext forces an item into the cache, following the specified expiration (unless a zero Time is provided +// then falling back to the configured expiration policy) honoring the provided context. +func (gc *Cache) SetContext(ctx context.Context, cacheName, key string, value []byte, expiration time.Time) error { + gc.debugOut.Printf("Setting %s %s @ %s\n", cacheName, key, expiration.String()) + return gc.set(ctx, cacheName, key, value, expiration) +} + +// set is an internal function for all of the Set* funcs. expirationOption is either “true“ (follow policy), +// “false“ (no expiration), or a time.Time specifying when to expire the item +func (gc *Cache) set(ctx context.Context, cacheName, key string, value []byte, expiration time.Time) error { + if cache, ok := gc.caches[cacheName]; ok { + if expiration.IsZero() && gc.configs[cacheName].ItemExpiration != 0 { + // Local expiration is zero, but the cache has an expiration + return cache.Set(ctx, key, value, time.Now().Add(gc.configs[cacheName].ItemExpiration), true) + } + return cache.Set(ctx, key, value, expiration, true) + } + return CacheNotFoundError +} + +// SetToExpireAt forces an item into the cache, to expire at a specific time regardless of the cache configuration. Use +// SetContext if you need to set the expiration and a context. +func (gc *Cache) SetToExpireAt(cacheName, key string, expireAt time.Time, value []byte) error { + gc.debugOut.Printf("Setting %s %s @ %s\n", cacheName, key, expireAt.String()) + return gc.set(context.Background(), cacheName, key, value, expireAt) +} + +// Remove makes a best effort to remove an item from the cache +func (gc *Cache) Remove(cacheName, key string) error { + return gc.RemoveContext(context.Background(), cacheName, key) +} + +// RemoveContext makes a best effort to remove an item from the cache, honoring the provided context. +func (gc *Cache) RemoveContext(ctx context.Context, cacheName, key string) error { + if cache, ok := gc.caches[cacheName]; ok { + gc.debugOut.Printf("Removing %s %s\n", cacheName, key) + return cache.Remove(ctx, key) + } + return CacheNotFoundError +} + +// SetDebugOut wires in the debug logger to the specified logger +func (gc *Cache) SetDebugOut(logger *log.Logger) { + gc.debugOut = logger +} + +// SetPeers allows the dynamic [re]setting of the peerlist +func (gc *Cache) SetPeers(peers ...string) { + gc.pool.Set(peers...) +} + +// Stats is a request finisher that outputs the Cache stats as JSON +func (gc *Cache) Stats(w http.ResponseWriter, req *http.Request) { + + stb, err := gc.stats() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Write(stb) + w.Write([]byte{10}) +} + +func (gc *Cache) stats() ([]byte, error) { + type cachesStats struct { + Main groupcache.CacheStats + Hot groupcache.CacheStats + } + type stats struct { + Cache string + Group groupcache.Stats + Caches cachesStats + } + + gc.regLock.Lock() + defer gc.regLock.Unlock() + + statList := make([]stats, 0) + + for name, gp := range gc.caches { + statList = append(statList, + stats{ + Cache: name, + Group: gp.Stats, + Caches: cachesStats{ + Main: gp.CacheStats(groupcache.MainCache), + Hot: gp.CacheStats(groupcache.HotCache), + }, + }) + } + + data, err := json.MarshalIndent(statList, "", " ") + if err != nil { + return nil, err + } + return data, nil + +} diff --git a/internal/caches/group/group_test.go b/internal/caches/group/group_test.go new file mode 100644 index 00000000..01ed5e24 --- /dev/null +++ b/internal/caches/group/group_test.go @@ -0,0 +1,276 @@ +package group + +import ( + . "github.com/smartystreets/goconvey/convey" + + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +func Test_GroupCache(t *testing.T) { + + var Store = map[string][]byte{ + "red": []byte("#FF0000"), + "green": []byte("#00FF00"), + "blue": []byte("#0000FF"), + } + + // TIL you can't have multiple groupcache instances in the same running code + ports := []string{"http://127.0.0.1:8080"} //, "http://127.0.0.1:8081", "http://127.0.0.1:8082"} + f := func(key string) ([]byte, error) { + v, ok := Store[key] + if !ok { + return []byte{}, ItemNotFoundError + } + return v, nil + } + + fp := func(key string) ([]byte, error) { + var pStore = map[string][]byte{ + "black": []byte("#FFFFFF"), + } + + v, ok := pStore[key] + if !ok { + return []byte{}, ItemNotFoundError + } + return v, nil + } + + fg := func(key string) ([]byte, error) { + var gStore = map[string][]byte{ + "white": []byte("#000000"), + } + + v, ok := gStore[key] + if !ok { + return []byte{}, ItemNotFoundError + } + return v, nil + } + + port := ports[0] + conf := Config{ + CacheSize: 128 << 20, + Name: "default", + ListenAddress: port, + PeerList: ports, + //ItemExpiration: 1 * time.Second, + } + + c, err := NewCache(conf, f) + if err != nil { + panic(err) + } + defer c.Close() + + conf.Name = "passwd" + c.Add(conf, fp) + conf.Name = "group" + conf.ItemExpiration = 3 * time.Millisecond + c.Add(conf, fg) + + Convey("When a new GroupCache is created it looks correct", t, func() { + + names := c.Names() + So(names, ShouldContain, "passwd") + So(names, ShouldContain, "group") + So(c.Exists("passwd"), ShouldBeTrue) + So(c.Exists("group"), ShouldBeTrue) + So(c.Exists("nope"), ShouldBeFalse) + + Convey("... Asking for existing and non-existing items works as expected.", func() { + + for name, code := range Store { + color, ok := c.Get("default", name) + So(ok, ShouldBeTrue) + So(color, ShouldResemble, code) + } + + _, ok := c.Get("default", "brown") + So(ok, ShouldBeFalse) + }) + + Convey("... Adding a new item, and then retrieving it works as expected.", func() { + black := []byte("#FFFFFF") + err := c.Set("default", "black", black) + So(err, ShouldBeNil) + + color, ok := c.Get("default", "black") + So(ok, ShouldBeTrue) + So(color, ShouldResemble, black) + + Convey("... Removing that, and then retrieving it works as expected.", func() { + c.Remove("default", "black") + + _, ok = c.Get("default", "black") + So(ok, ShouldBeFalse) + }) + }) + + Convey("... Adding a new item with an explicit expiration, and then retrieving it after expiration works as expected.", func() { + black := []byte("#FFFFFF") + err := c.SetToExpireAt("default", "black", time.Now().Add(5*time.Millisecond), black) + So(err, ShouldBeNil) + + time.Sleep(10 * time.Millisecond) + + color, ok := c.Get("default", "black") + So(ok, ShouldBeFalse) + So(color, ShouldEqual, ItemNotFoundError) + + Convey("... Removing that, and then retrieving it works as expected.", func() { + c.Remove("default", "black") + + _, ok = c.Get("default", "black") + So(ok, ShouldBeFalse) + }) + }) + + Convey("... Asking for prefixed items, hits the prefix-specific BackFillFuncs", func() { + // Get passwd + pline, ok := c.Get("passwd", "black") + So(ok, ShouldBeTrue) + So(pline, ShouldResemble, []byte("#FFFFFF")) + + // Get group + gline, ok := c.Get("group", "white") + So(ok, ShouldBeTrue) + So(gline, ShouldResemble, []byte("#000000")) + + // Get a non-registered prefix + err, ok := c.Get("bogus", "white") + So(ok, ShouldBeFalse) + So(err, ShouldEqual, CacheNotFoundError) + + Convey("... Overriding prefixed items works as expected, as does removing the override", func() { + // Override a group item + err := c.Set("group", "white", []byte("#FFFFFF")) + So(err, ShouldBeNil) + sline, ok := c.Get("group", "white") + So(ok, ShouldBeTrue) + So(sline, ShouldResemble, []byte("#FFFFFF")) + + // Wait for the overridden group item to expire + time.Sleep(5 * time.Millisecond) + sline, ok = c.Get("group", "white") + So(ok, ShouldBeTrue) + So(sline, ShouldResemble, []byte("#000000")) // back to normal + + }) + + Convey("... Setting and removing items from non-existent caches is as-expected", func() { + + err := c.Set("nope", "white", []byte("#FFFFFF")) + So(err, ShouldEqual, CacheNotFoundError) + + err = c.Remove("nope", "white") + So(err, ShouldEqual, CacheNotFoundError) + }) + }) + + Convey("... Checking stats works as-expected", func() { + stb, err := c.stats() + So(err, ShouldBeNil) + x := make([]interface{}, 0) + jerr := json.Unmarshal(stb, &x) + So(jerr, ShouldBeNil) + So(x, ShouldNotBeEmpty) + + }) + }) +} + +func concatPrefixKey(prefix, sep, key string) string { + return prefix + sep + key +} + +type ctxKey string + +const prefixKey = ctxKey("prefix") + +func Benchmark_Sprintf(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = fmt.Sprintf("%s%s%s", "stuff", "\t", "words") + } +} + +func Benchmark_Concat(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = "stuff" + "\t" + "words" + } +} + +func Benchmark_ConcatFunc(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = concatPrefixKey("stuff", "\t", "words") + } +} + +func Benchmark_SplitNo(b *testing.B) { + key := "123" + sep := "\t" + for i := 0; i < b.N; i++ { + if p := strings.SplitN(key, sep, 2); len(p) > 1 { + _ = p + } + } +} + +func Benchmark_SplitYes(b *testing.B) { + key := "456\t123" + sep := "\t" + for i := 0; i < b.N; i++ { + if p := strings.SplitN(key, sep, 2); len(p) > 1 { + _ = p + } + } +} + +func Benchmark_SplitHasNo(b *testing.B) { + key := "123" + sep := "\t" + for i := 0; i < b.N; i++ { + if strings.Contains(key, sep) { + _ = strings.SplitN(key, sep, 2) + } + } +} + +func Benchmark_SplitHasYes(b *testing.B) { + key := "456\t123" + sep := "\t" + for i := 0; i < b.N; i++ { + if strings.Contains(key, sep) { + _ = strings.SplitN(key, sep, 2) + } + } +} + +func Benchmark_SplitContextNo(b *testing.B) { + key := "123" + sep := "\t" + ctx := context.Background() + for i := 0; i < b.N; i++ { + if x := ctx.Value(prefixKey); x != nil { + prefix := x.(string) + key = strings.TrimPrefix(key, prefix+sep) + } + } +} + +func Benchmark_SplitContextYes(b *testing.B) { + key := "456\t123" + sep := "\t" + ctx := context.WithValue(context.Background(), prefixKey, "456") + for i := 0; i < b.N; i++ { + if x := ctx.Value("prefix"); x != nil { + prefix := x.(string) + key = strings.TrimPrefix(key, prefix+sep) + } + } +} diff --git a/internal/caches/group/misc.go b/internal/caches/group/misc.go new file mode 100644 index 00000000..934eedb2 --- /dev/null +++ b/internal/caches/group/misc.go @@ -0,0 +1,22 @@ +package group + +import ( + "time" +) + +// Error is an error type +type Error string + +// Error returns the stringified version of Error +func (e Error) Error() string { + return string(e) +} + +// Config is used to store configuration information to pass to a GroupCache. +type Config struct { + Name string // For New and Add. Pass as ``cacheName`` to differentiate caches + ListenAddress string // Only for New to set the listener + PeerList []string // Only for New to establish the initial PeerList. May be reset with GroupCache.SetPeers() + CacheSize int64 // For New and Add to set the size in bytes of the cache + ItemExpiration time.Duration // For New and Add to set the default expiration duration. Leave as empty for infinite. +}