From 2edcffc75159c0d099fc1edad468279f5aef2e11 Mon Sep 17 00:00:00 2001 From: adlerhurst <27845747+adlerhurst@users.noreply.github.com> Date: Sun, 16 Mar 2025 14:12:40 +0100 Subject: [PATCH] use current cache implementation --- backend/repository/cache/instance.go | 56 --- backend/repository/cache/user.go | 38 -- backend/repository/instance.go | 27 ++ .../repository/orchestrate/handler/handle.go | 28 +- backend/repository/orchestrate/instance.go | 27 +- .../repository/orchestrate/instance_test.go | 7 +- backend/repository/orchestrate/option.go | 16 +- backend/repository/orchestrate/user.go | 21 +- backend/repository/user.go | 27 ++ backend/storage/cache/cache.go | 114 +++++- backend/storage/cache/connector/connector.go | 49 +++ .../cache/connector/gomap/connector.go | 23 ++ .../storage/cache/connector/gomap/gomap.go | 200 +++++++++++ .../cache/connector/gomap/gomap_test.go | 329 ++++++++++++++++++ backend/storage/cache/connector/noop/noop.go | 21 ++ backend/storage/cache/connector_enumer.go | 98 ++++++ backend/storage/cache/error.go | 29 ++ backend/storage/cache/gomap/map.go | 54 --- backend/storage/cache/pruner.go | 76 ++++ backend/storage/cache/pruner_test.go | 43 +++ backend/storage/cache/purpose_enumer.go | 90 +++++ 21 files changed, 1183 insertions(+), 190 deletions(-) delete mode 100644 backend/repository/cache/instance.go delete mode 100644 backend/repository/cache/user.go create mode 100644 backend/storage/cache/connector/connector.go create mode 100644 backend/storage/cache/connector/gomap/connector.go create mode 100644 backend/storage/cache/connector/gomap/gomap.go create mode 100644 backend/storage/cache/connector/gomap/gomap_test.go create mode 100644 backend/storage/cache/connector/noop/noop.go create mode 100644 backend/storage/cache/connector_enumer.go create mode 100644 backend/storage/cache/error.go delete mode 100644 backend/storage/cache/gomap/map.go create mode 100644 backend/storage/cache/pruner.go create mode 100644 backend/storage/cache/pruner_test.go create mode 100644 backend/storage/cache/purpose_enumer.go diff --git a/backend/repository/cache/instance.go b/backend/repository/cache/instance.go deleted file mode 100644 index 4aad24b2dc..0000000000 --- a/backend/repository/cache/instance.go +++ /dev/null @@ -1,56 +0,0 @@ -package cache - -import ( - "context" - "log" - "sync" - - "github.com/zitadel/zitadel/backend/repository" - "github.com/zitadel/zitadel/backend/storage/cache" - "github.com/zitadel/zitadel/backend/storage/cache/gomap" -) - -type Instance struct { - mu *sync.RWMutex - byID cache.Cache[string, *repository.Instance] - byDomain cache.Cache[string, *repository.Instance] -} - -func NewInstance() *Instance { - return &Instance{ - mu: &sync.RWMutex{}, - byID: gomap.New[string, *repository.Instance](), - byDomain: gomap.New[string, *repository.Instance](), - } -} - -func (i *Instance) Set(ctx context.Context, instance *repository.Instance) (*repository.Instance, error) { - log.Println("cache.instance.set") - i.set(instance, "") - return instance, nil -} - -func (i *Instance) ByID(ctx context.Context, id string) (*repository.Instance, error) { - i.mu.RLock() - defer i.mu.RUnlock() - log.Println("cache.instance.byID") - instance, _ := i.byID.Get(id) - return instance, nil -} - -func (i *Instance) ByDomain(ctx context.Context, domain string) (*repository.Instance, error) { - i.mu.RLock() - defer i.mu.RUnlock() - log.Println("cache.instance.byDomain") - instance, _ := i.byDomain.Get(domain) - return instance, nil -} - -func (i *Instance) set(instance *repository.Instance, domain string) { - i.mu.Lock() - defer i.mu.Unlock() - if domain != "" { - i.byDomain.Set(domain, instance) - } - i.byID.Set(instance.ID, instance) -} diff --git a/backend/repository/cache/user.go b/backend/repository/cache/user.go deleted file mode 100644 index 8f0803a053..0000000000 --- a/backend/repository/cache/user.go +++ /dev/null @@ -1,38 +0,0 @@ -package cache - -import ( - "context" - "log" - - "github.com/zitadel/zitadel/backend/repository" - "github.com/zitadel/zitadel/backend/storage/cache" - "github.com/zitadel/zitadel/backend/storage/cache/gomap" -) - -type User struct { - cache.Cache[string, *repository.User] -} - -func NewUser() *User { - return &User{ - Cache: gomap.New[string, *repository.User](), - } -} - -// ByID implements repository.UserRepository. -func (u *User) ByID(ctx context.Context, id string) (*repository.User, error) { - log.Println("cache.user.byid") - user, _ := u.Get(id) - return user, nil - -} - -func (u *User) Set(ctx context.Context, user *repository.User) (*repository.User, error) { - log.Println("cache.user.set") - u.set(user) - return user, nil -} - -func (u *User) set(user *repository.User) { - u.Cache.Set(user.ID, user) -} diff --git a/backend/repository/instance.go b/backend/repository/instance.go index 9729cf3161..16ba2bea96 100644 --- a/backend/repository/instance.go +++ b/backend/repository/instance.go @@ -1,10 +1,37 @@ package repository +import "github.com/zitadel/zitadel/backend/storage/cache" + type Instance struct { ID string Name string } +type InstanceIndex uint8 + +var InstanceIndices = []InstanceIndex{ + InstanceByID, + InstanceByDomain, +} + +const ( + InstanceByID InstanceIndex = iota + InstanceByDomain +) + +var _ cache.Entry[InstanceIndex, string] = (*Instance)(nil) + +// Keys implements [cache.Entry]. +func (i *Instance) Keys(index InstanceIndex) (key []string) { + switch index { + case InstanceByID: + return []string{i.ID} + case InstanceByDomain: + return []string{i.Name} + } + return nil +} + type ListRequest struct { Limit uint16 } diff --git a/backend/repository/orchestrate/handler/handle.go b/backend/repository/orchestrate/handler/handle.go index ef61a83b16..3fb3f23a26 100644 --- a/backend/repository/orchestrate/handler/handle.go +++ b/backend/repository/orchestrate/handler/handle.go @@ -2,6 +2,8 @@ package handler import ( "context" + + "github.com/zitadel/zitadel/backend/storage/cache" ) // Handler is a function that handles the request. @@ -65,7 +67,7 @@ func SkipNext[Req, Res any](handle Handler[Req, Res], next Handler[Req, Res]) Ha // SkipNilHandler skips the handle function if the handler is nil. // The function is safe to call with nil handler. -func SkipNilHandler[O, R any](handler *O, handle Handler[R, R]) Handler[R, R] { +func SkipNilHandler[R any](handler any, handle Handler[R, R]) Handler[R, R] { return func(ctx context.Context, request R) (res R, err error) { if handler == nil { return request, nil @@ -73,3 +75,27 @@ func SkipNilHandler[O, R any](handler *O, handle Handler[R, R]) Handler[R, R] { return handle(ctx, request) } } + +func ErrFuncToHandle[R any](fn func(context.Context, R) error) Handler[R, R] { + return func(ctx context.Context, request R) (res R, err error) { + err = fn(ctx, request) + if err != nil { + return res, err + } + return request, nil + } +} + +func NoReturnToHandle[R any](fn func(context.Context, R)) Handler[R, R] { + return func(ctx context.Context, request R) (res R, err error) { + fn(ctx, request) + return request, nil + } +} + +func CacheGetToHandle[I, K comparable, E cache.Entry[I, K]](fn func(context.Context, I, K) (E, bool), index I) Handler[K, E] { + return func(ctx context.Context, request K) (res E, err error) { + res, _ = fn(ctx, index, request) + return res, nil + } +} diff --git a/backend/repository/orchestrate/instance.go b/backend/repository/orchestrate/instance.go index 9c87a609c5..0f156e3f9c 100644 --- a/backend/repository/orchestrate/instance.go +++ b/backend/repository/orchestrate/instance.go @@ -4,18 +4,19 @@ import ( "context" "github.com/zitadel/zitadel/backend/repository" - "github.com/zitadel/zitadel/backend/repository/cache" "github.com/zitadel/zitadel/backend/repository/event" "github.com/zitadel/zitadel/backend/repository/orchestrate/handler" "github.com/zitadel/zitadel/backend/repository/sql" "github.com/zitadel/zitadel/backend/repository/telemetry/logged" "github.com/zitadel/zitadel/backend/repository/telemetry/traced" + "github.com/zitadel/zitadel/backend/storage/cache" + "github.com/zitadel/zitadel/backend/storage/cache/connector/noop" "github.com/zitadel/zitadel/backend/storage/database" "github.com/zitadel/zitadel/backend/telemetry/tracing" ) type InstanceOptions struct { - cache *cache.Instance + cache cache.Cache[repository.InstanceIndex, string, *repository.Instance] } type instance struct { @@ -24,17 +25,17 @@ type instance struct { } func Instance(opts ...Option[InstanceOptions]) *instance { - i := instance{ - options: newOptions[InstanceOptions](), - } - i.InstanceOptions = i.options.custom + i := new(instance) + i.InstanceOptions = &i.options.custom + i.cache = noop.NewCache[repository.InstanceIndex, string, *repository.Instance]() + for _, opt := range opts { opt.apply(&i.options) } - return &i + return i } -func WithInstanceCache(cache *cache.Instance) Option[InstanceOptions] { +func WithInstanceCache(cache cache.Cache[repository.InstanceIndex, string, *repository.Instance]) Option[InstanceOptions] { return func(opts *options[InstanceOptions]) { opts.custom.cache = cache } @@ -55,7 +56,7 @@ func (i *instance) Create(ctx context.Context, tx database.Transaction, instance ), handler.SkipNilHandler(i.cache, handler.Decorates( - i.cache.Set, + handler.NoReturnToHandle(i.cache.Set), traced.Decorate[*repository.Instance, *repository.Instance](i.tracer, tracing.WithSpanName("instance.cache.SetUp")), logged.Decorate[*repository.Instance, *repository.Instance](i.logger, "instance.cache.SetUp"), ), @@ -66,26 +67,26 @@ func (i *instance) Create(ctx context.Context, tx database.Transaction, instance func (i *instance) ByID(ctx context.Context, querier database.Querier, id string) (*repository.Instance, error) { return handler.SkipNext( - i.cache.ByID, + handler.CacheGetToHandle(i.cache.Get, repository.InstanceByID), handler.Chain( handler.Decorate( sql.Query(querier).InstanceByID, traced.Decorate[string, *repository.Instance](i.tracer, tracing.WithSpanName("instance.sql.ByID")), ), - handler.SkipNilHandler(i.cache, i.cache.Set), + handler.SkipNilHandler(i.cache, handler.NoReturnToHandle(i.cache.Set)), ), )(ctx, id) } func (i *instance) ByDomain(ctx context.Context, querier database.Querier, domain string) (*repository.Instance, error) { return handler.SkipNext( - i.cache.ByDomain, + handler.CacheGetToHandle(i.cache.Get, repository.InstanceByDomain), handler.Chain( handler.Decorate( sql.Query(querier).InstanceByDomain, traced.Decorate[string, *repository.Instance](i.tracer, tracing.WithSpanName("instance.sql.ByDomain")), ), - handler.SkipNilHandler(i.cache, i.cache.Set), + handler.SkipNilHandler(i.cache, handler.NoReturnToHandle(i.cache.Set)), ), )(ctx, domain) } diff --git a/backend/repository/orchestrate/instance_test.go b/backend/repository/orchestrate/instance_test.go index d33fef89b3..cab67e63a5 100644 --- a/backend/repository/orchestrate/instance_test.go +++ b/backend/repository/orchestrate/instance_test.go @@ -8,8 +8,9 @@ import ( "testing" "github.com/zitadel/zitadel/backend/repository" - "github.com/zitadel/zitadel/backend/repository/cache" "github.com/zitadel/zitadel/backend/repository/orchestrate" + "github.com/zitadel/zitadel/backend/storage/cache" + "github.com/zitadel/zitadel/backend/storage/cache/connector/gomap" "github.com/zitadel/zitadel/backend/storage/database" "github.com/zitadel/zitadel/backend/storage/database/mock" "github.com/zitadel/zitadel/backend/telemetry/logging" @@ -34,7 +35,9 @@ func Test_instance_SetUp(t *testing.T) { opts: []orchestrate.Option[orchestrate.InstanceOptions]{ orchestrate.WithTracer[orchestrate.InstanceOptions](tracing.NewTracer("test")), orchestrate.WithLogger[orchestrate.InstanceOptions](logging.New(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))), - orchestrate.WithInstanceCache(cache.NewInstance()), + orchestrate.WithInstanceCache( + gomap.NewCache[repository.InstanceIndex, string, *repository.Instance](context.Background(), repository.InstanceIndices, cache.Config{}), + ), }, args: args{ ctx: context.Background(), diff --git a/backend/repository/orchestrate/option.go b/backend/repository/orchestrate/option.go index d8bef009e9..efad690a47 100644 --- a/backend/repository/orchestrate/option.go +++ b/backend/repository/orchestrate/option.go @@ -7,21 +7,15 @@ import ( // options are the default options for orchestrators. type options[T any] struct { - custom *T + custom T + defaultOptions +} + +type defaultOptions struct { tracer *tracing.Tracer logger *logging.Logger } -func newOptions[T any]() options[T] { - return options[T]{ - custom: new(T), - } -} - -type applier interface { - apply() -} - type Option[T any] func(*options[T]) func WithTracer[T any](tracer *tracing.Tracer) Option[T] { diff --git a/backend/repository/orchestrate/user.go b/backend/repository/orchestrate/user.go index a9fb48ba77..81cc1b0849 100644 --- a/backend/repository/orchestrate/user.go +++ b/backend/repository/orchestrate/user.go @@ -4,17 +4,18 @@ import ( "context" "github.com/zitadel/zitadel/backend/repository" - "github.com/zitadel/zitadel/backend/repository/cache" "github.com/zitadel/zitadel/backend/repository/event" "github.com/zitadel/zitadel/backend/repository/orchestrate/handler" "github.com/zitadel/zitadel/backend/repository/sql" "github.com/zitadel/zitadel/backend/repository/telemetry/traced" + "github.com/zitadel/zitadel/backend/storage/cache" + "github.com/zitadel/zitadel/backend/storage/cache/connector/noop" "github.com/zitadel/zitadel/backend/storage/database" "github.com/zitadel/zitadel/backend/telemetry/tracing" ) type UserOptions struct { - cache *cache.User + cache cache.Cache[repository.UserIndex, string, *repository.User] } type user struct { @@ -23,17 +24,17 @@ type user struct { } func User(opts ...Option[UserOptions]) *user { - i := user{ - options: newOptions[UserOptions](), - } - i.UserOptions = i.options.custom + i := new(user) + i.UserOptions = &i.options.custom + i.cache = noop.NewCache[repository.UserIndex, string, *repository.User]() + for _, opt := range opts { opt(&i.options) } - return &i + return i } -func WithUserCache(cache *cache.User) Option[UserOptions] { +func WithUserCache(cache cache.Cache[repository.UserIndex, string, *repository.User]) Option[UserOptions] { return func(i *options[UserOptions]) { i.custom.cache = cache } @@ -56,13 +57,13 @@ func (i *user) Create(ctx context.Context, tx database.Transaction, user *reposi func (i *user) ByID(ctx context.Context, querier database.Querier, id string) (*repository.User, error) { return handler.SkipNext( - i.custom.cache.ByID, + handler.CacheGetToHandle(i.cache.Get, repository.UserByID), handler.Chain( handler.Decorate( sql.Query(querier).UserByID, traced.Decorate[string, *repository.User](i.tracer, tracing.WithSpanName("user.sql.ByID")), ), - handler.SkipNilHandler(i.custom.cache, i.custom.cache.Set), + handler.SkipNilHandler(i.custom.cache, handler.NoReturnToHandle(i.cache.Set)), ), )(ctx, id) } diff --git a/backend/repository/user.go b/backend/repository/user.go index 3a5f10d4c3..6ffe767a26 100644 --- a/backend/repository/user.go +++ b/backend/repository/user.go @@ -1,6 +1,33 @@ package repository +import "github.com/zitadel/zitadel/backend/storage/cache" + type User struct { ID string Username string } + +type UserIndex uint8 + +var UserIndices = []UserIndex{ + UserByID, + UserByUsername, +} + +const ( + UserByID UserIndex = iota + UserByUsername +) + +var _ cache.Entry[UserIndex, string] = (*User)(nil) + +// Keys implements [cache.Entry]. +func (u *User) Keys(index UserIndex) (key []string) { + switch index { + case UserByID: + return []string{u.ID} + case UserByUsername: + return []string{u.Username} + } + return nil +} diff --git a/backend/storage/cache/cache.go b/backend/storage/cache/cache.go index e0d46ec39f..dc05208caa 100644 --- a/backend/storage/cache/cache.go +++ b/backend/storage/cache/cache.go @@ -1,8 +1,112 @@ +// Package cache provides abstraction of cache implementations that can be used by zitadel. package cache -type Cache[K comparable, V any] interface { - Get(key K) (V, bool) - Set(key K, value V) - Delete(key K) - Clear() +import ( + "context" + "time" + + "github.com/zitadel/logging" +) + +// Purpose describes which object types are stored by a cache. +type Purpose int + +//go:generate enumer -type Purpose -transform snake -trimprefix Purpose +const ( + PurposeUnspecified Purpose = iota + PurposeAuthzInstance + PurposeMilestones + PurposeOrganization + PurposeIdPFormCallback +) + +// Cache stores objects with a value of type `V`. +// Objects may be referred to by one or more indices. +// Implementations may encode the value for storage. +// This means non-exported fields may be lost and objects +// with function values may fail to encode. +// See https://pkg.go.dev/encoding/json#Marshal for example. +// +// `I` is the type by which indices are identified, +// typically an enum for type-safe access. +// Indices are defined when calling the constructor of an implementation of this interface. +// It is illegal to refer to an idex not defined during construction. +// +// `K` is the type used as key in each index. +// Due to the limitations in type constraints, all indices use the same key type. +// +// Implementations are free to use stricter type constraints or fixed typing. +type Cache[I, K comparable, V Entry[I, K]] interface { + // Get an object through specified index. + // An [IndexUnknownError] may be returned if the index is unknown. + // [ErrCacheMiss] is returned if the key was not found in the index, + // or the object is not valid. + Get(ctx context.Context, index I, key K) (V, bool) + + // Set an object. + // Keys are created on each index based in the [Entry.Keys] method. + // If any key maps to an existing object, the object is invalidated, + // regardless if the object has other keys defined in the new entry. + // This to prevent ghost objects when an entry reduces the amount of keys + // for a given index. + Set(ctx context.Context, value V) + + // Invalidate an object through specified index. + // Implementations may choose to instantly delete the object, + // defer until prune or a separate cleanup routine. + // Invalidated object are no longer returned from Get. + // It is safe to call Invalidate multiple times or on non-existing entries. + Invalidate(ctx context.Context, index I, key ...K) error + + // Delete one or more keys from a specific index. + // An [IndexUnknownError] may be returned if the index is unknown. + // The referred object is not invalidated and may still be accessible though + // other indices and keys. + // It is safe to call Delete multiple times or on non-existing entries + Delete(ctx context.Context, index I, key ...K) error + + // Truncate deletes all cached objects. + Truncate(ctx context.Context) error +} + +// Entry contains a value of type `V` to be cached. +// +// `I` is the type by which indices are identified, +// typically an enum for type-safe access. +// +// `K` is the type used as key in an index. +// Due to the limitations in type constraints, all indices use the same key type. +type Entry[I, K comparable] interface { + // Keys returns which keys map to the object in a specified index. + // May return nil if the index in unknown or when there are no keys. + Keys(index I) (key []K) +} + +type Connector int + +//go:generate enumer -type Connector -transform snake -trimprefix Connector -linecomment -text +const ( + // Empty line comment ensures empty string for unspecified value + ConnectorUnspecified Connector = iota // + ConnectorMemory + ConnectorPostgres + ConnectorRedis +) + +type Config struct { + Connector Connector + + // Age since an object was added to the cache, + // after which the object is considered invalid. + // 0 disables max age checks. + MaxAge time.Duration + + // Age since last use (Get) of an object, + // after which the object is considered invalid. + // 0 disables last use age checks. + LastUseAge time.Duration + + // Log allows logging of the specific cache. + // By default only errors are logged to stdout. + Log *logging.Config } diff --git a/backend/storage/cache/connector/connector.go b/backend/storage/cache/connector/connector.go new file mode 100644 index 0000000000..3cc5e852a6 --- /dev/null +++ b/backend/storage/cache/connector/connector.go @@ -0,0 +1,49 @@ +// Package connector provides glue between the [cache.Cache] interface and implementations from the connector sub-packages. +package connector + +import ( + "context" + "fmt" + + "github.com/zitadel/zitadel/backend/storage/cache" + "github.com/zitadel/zitadel/backend/storage/cache/connector/gomap" + "github.com/zitadel/zitadel/backend/storage/cache/connector/noop" +) + +type CachesConfig struct { + Connectors struct { + Memory gomap.Config + } + Instance *cache.Config + Milestones *cache.Config + Organization *cache.Config + IdPFormCallbacks *cache.Config +} + +type Connectors struct { + Config CachesConfig + Memory *gomap.Connector +} + +func StartConnectors(conf *CachesConfig) (Connectors, error) { + if conf == nil { + return Connectors{}, nil + } + return Connectors{ + Config: *conf, + Memory: gomap.NewConnector(conf.Connectors.Memory), + }, nil +} + +func StartCache[I ~int, K ~string, V cache.Entry[I, K]](background context.Context, indices []I, purpose cache.Purpose, conf *cache.Config, connectors Connectors) (cache.Cache[I, K, V], error) { + if conf == nil || conf.Connector == cache.ConnectorUnspecified { + return noop.NewCache[I, K, V](), nil + } + if conf.Connector == cache.ConnectorMemory && connectors.Memory != nil { + c := gomap.NewCache[I, K, V](background, indices, *conf) + connectors.Memory.Config.StartAutoPrune(background, c, purpose) + return c, nil + } + + return nil, fmt.Errorf("cache connector %q not enabled", conf.Connector) +} diff --git a/backend/storage/cache/connector/gomap/connector.go b/backend/storage/cache/connector/gomap/connector.go new file mode 100644 index 0000000000..a37055bd73 --- /dev/null +++ b/backend/storage/cache/connector/gomap/connector.go @@ -0,0 +1,23 @@ +package gomap + +import ( + "github.com/zitadel/zitadel/backend/storage/cache" +) + +type Config struct { + Enabled bool + AutoPrune cache.AutoPruneConfig +} + +type Connector struct { + Config cache.AutoPruneConfig +} + +func NewConnector(config Config) *Connector { + if !config.Enabled { + return nil + } + return &Connector{ + Config: config.AutoPrune, + } +} diff --git a/backend/storage/cache/connector/gomap/gomap.go b/backend/storage/cache/connector/gomap/gomap.go new file mode 100644 index 0000000000..d79e323801 --- /dev/null +++ b/backend/storage/cache/connector/gomap/gomap.go @@ -0,0 +1,200 @@ +package gomap + +import ( + "context" + "errors" + "log/slog" + "maps" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/zitadel/zitadel/backend/storage/cache" +) + +type mapCache[I, K comparable, V cache.Entry[I, K]] struct { + config *cache.Config + indexMap map[I]*index[K, V] + logger *slog.Logger +} + +// NewCache returns an in-memory Cache implementation based on the builtin go map type. +// Object values are stored as-is and there is no encoding or decoding involved. +func NewCache[I, K comparable, V cache.Entry[I, K]](background context.Context, indices []I, config cache.Config) cache.PrunerCache[I, K, V] { + m := &mapCache[I, K, V]{ + config: &config, + indexMap: make(map[I]*index[K, V], len(indices)), + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelError, + })), + } + if config.Log != nil { + m.logger = config.Log.Slog() + } + m.logger.InfoContext(background, "map cache logging enabled") + + for _, name := range indices { + m.indexMap[name] = &index[K, V]{ + config: m.config, + entries: make(map[K]*entry[V]), + } + } + return m +} + +func (c *mapCache[I, K, V]) Get(ctx context.Context, index I, key K) (value V, ok bool) { + i, ok := c.indexMap[index] + if !ok { + c.logger.ErrorContext(ctx, "map cache get", "err", cache.NewIndexUnknownErr(index), "index", index, "key", key) + return value, false + } + entry, err := i.Get(key) + if err == nil { + c.logger.DebugContext(ctx, "map cache get", "index", index, "key", key) + return entry.value, true + } + if errors.Is(err, cache.ErrCacheMiss) { + c.logger.InfoContext(ctx, "map cache get", "err", err, "index", index, "key", key) + return value, false + } + c.logger.ErrorContext(ctx, "map cache get", "err", cache.NewIndexUnknownErr(index), "index", index, "key", key) + return value, false +} + +func (c *mapCache[I, K, V]) Set(ctx context.Context, value V) { + now := time.Now() + entry := &entry[V]{ + value: value, + created: now, + } + entry.lastUse.Store(now.UnixMicro()) + + for name, i := range c.indexMap { + keys := value.Keys(name) + i.Set(keys, entry) + c.logger.DebugContext(ctx, "map cache set", "index", name, "keys", keys) + } +} + +func (c *mapCache[I, K, V]) Invalidate(ctx context.Context, index I, keys ...K) error { + i, ok := c.indexMap[index] + if !ok { + return cache.NewIndexUnknownErr(index) + } + i.Invalidate(keys) + c.logger.DebugContext(ctx, "map cache invalidate", "index", index, "keys", keys) + return nil +} + +func (c *mapCache[I, K, V]) Delete(ctx context.Context, index I, keys ...K) error { + i, ok := c.indexMap[index] + if !ok { + return cache.NewIndexUnknownErr(index) + } + i.Delete(keys) + c.logger.DebugContext(ctx, "map cache delete", "index", index, "keys", keys) + return nil +} + +func (c *mapCache[I, K, V]) Prune(ctx context.Context) error { + for name, index := range c.indexMap { + index.Prune() + c.logger.DebugContext(ctx, "map cache prune", "index", name) + } + return nil +} + +func (c *mapCache[I, K, V]) Truncate(ctx context.Context) error { + for name, index := range c.indexMap { + index.Truncate() + c.logger.DebugContext(ctx, "map cache truncate", "index", name) + } + return nil +} + +type index[K comparable, V any] struct { + mutex sync.RWMutex + config *cache.Config + entries map[K]*entry[V] +} + +func (i *index[K, V]) Get(key K) (*entry[V], error) { + i.mutex.RLock() + entry, ok := i.entries[key] + i.mutex.RUnlock() + if ok && entry.isValid(i.config) { + return entry, nil + } + return nil, cache.ErrCacheMiss +} + +func (c *index[K, V]) Set(keys []K, entry *entry[V]) { + c.mutex.Lock() + for _, key := range keys { + c.entries[key] = entry + } + c.mutex.Unlock() +} + +func (i *index[K, V]) Invalidate(keys []K) { + i.mutex.RLock() + for _, key := range keys { + if entry, ok := i.entries[key]; ok { + entry.invalid.Store(true) + } + } + i.mutex.RUnlock() +} + +func (c *index[K, V]) Delete(keys []K) { + c.mutex.Lock() + for _, key := range keys { + delete(c.entries, key) + } + c.mutex.Unlock() +} + +func (c *index[K, V]) Prune() { + c.mutex.Lock() + maps.DeleteFunc(c.entries, func(_ K, entry *entry[V]) bool { + return !entry.isValid(c.config) + }) + c.mutex.Unlock() +} + +func (c *index[K, V]) Truncate() { + c.mutex.Lock() + c.entries = make(map[K]*entry[V]) + c.mutex.Unlock() +} + +type entry[V any] struct { + value V + created time.Time + invalid atomic.Bool + lastUse atomic.Int64 // UnixMicro time +} + +func (e *entry[V]) isValid(c *cache.Config) bool { + if e.invalid.Load() { + return false + } + now := time.Now() + if c.MaxAge > 0 { + if e.created.Add(c.MaxAge).Before(now) { + e.invalid.Store(true) + return false + } + } + if c.LastUseAge > 0 { + lastUse := e.lastUse.Load() + if time.UnixMicro(lastUse).Add(c.LastUseAge).Before(now) { + e.invalid.Store(true) + return false + } + e.lastUse.CompareAndSwap(lastUse, now.UnixMicro()) + } + return true +} diff --git a/backend/storage/cache/connector/gomap/gomap_test.go b/backend/storage/cache/connector/gomap/gomap_test.go new file mode 100644 index 0000000000..8ed4f0f30a --- /dev/null +++ b/backend/storage/cache/connector/gomap/gomap_test.go @@ -0,0 +1,329 @@ +package gomap + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/backend/storage/cache" +) + +type testIndex int + +const ( + testIndexID testIndex = iota + testIndexName +) + +var testIndices = []testIndex{ + testIndexID, + testIndexName, +} + +type testObject struct { + id string + names []string +} + +func (o *testObject) Keys(index testIndex) []string { + switch index { + case testIndexID: + return []string{o.id} + case testIndexName: + return o.names + default: + return nil + } +} + +func Test_mapCache_Get(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.Config{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + + type args struct { + index testIndex + key string + } + tests := []struct { + name string + args args + want *testObject + wantOk bool + }{ + { + name: "ok", + args: args{ + index: testIndexID, + key: "id", + }, + want: obj, + wantOk: true, + }, + { + name: "miss", + args: args{ + index: testIndexID, + key: "spanac", + }, + want: nil, + wantOk: false, + }, + { + name: "unknown index", + args: args{ + index: 99, + key: "id", + }, + want: nil, + wantOk: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := c.Get(context.Background(), tt.args.index, tt.args.key) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantOk, ok) + }) + } +} + +func Test_mapCache_Invalidate(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.Config{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + err := c.Invalidate(context.Background(), testIndexName, "bar") + require.NoError(t, err) + got, ok := c.Get(context.Background(), testIndexID, "id") + assert.Nil(t, got) + assert.False(t, ok) +} + +func Test_mapCache_Delete(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.Config{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + err := c.Delete(context.Background(), testIndexName, "bar") + require.NoError(t, err) + + // Shouldn't find object by deleted name + got, ok := c.Get(context.Background(), testIndexName, "bar") + assert.Nil(t, got) + assert.False(t, ok) + + // Should find object by other name + got, ok = c.Get(context.Background(), testIndexName, "foo") + assert.Equal(t, obj, got) + assert.True(t, ok) + + // Should find object by id + got, ok = c.Get(context.Background(), testIndexID, "id") + assert.Equal(t, obj, got) + assert.True(t, ok) +} + +func Test_mapCache_Prune(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.Config{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + + objects := []*testObject{ + { + id: "id1", + names: []string{"foo", "bar"}, + }, + { + id: "id2", + names: []string{"hello"}, + }, + } + for _, obj := range objects { + c.Set(context.Background(), obj) + } + // invalidate one entry + err := c.Invalidate(context.Background(), testIndexName, "bar") + require.NoError(t, err) + + err = c.(cache.Pruner).Prune(context.Background()) + require.NoError(t, err) + + // Other object should still be found + got, ok := c.Get(context.Background(), testIndexID, "id2") + assert.Equal(t, objects[1], got) + assert.True(t, ok) +} + +func Test_mapCache_Truncate(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.Config{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + objects := []*testObject{ + { + id: "id1", + names: []string{"foo", "bar"}, + }, + { + id: "id2", + names: []string{"hello"}, + }, + } + for _, obj := range objects { + c.Set(context.Background(), obj) + } + + err := c.Truncate(context.Background()) + require.NoError(t, err) + + mc := c.(*mapCache[testIndex, string, *testObject]) + for _, index := range mc.indexMap { + index.mutex.RLock() + assert.Len(t, index.entries, 0) + index.mutex.RUnlock() + } +} + +func Test_entry_isValid(t *testing.T) { + type fields struct { + created time.Time + invalid bool + lastUse time.Time + } + tests := []struct { + name string + fields fields + config *cache.Config + want bool + }{ + { + name: "invalid", + fields: fields{ + created: time.Now(), + invalid: true, + lastUse: time.Now(), + }, + config: &cache.Config{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "max age exceeded", + fields: fields{ + created: time.Now().Add(-(time.Minute + time.Second)), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.Config{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "max age disabled", + fields: fields{ + created: time.Now().Add(-(time.Minute + time.Second)), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.Config{ + LastUseAge: time.Second, + }, + want: true, + }, + { + name: "last use age exceeded", + fields: fields{ + created: time.Now().Add(-(time.Minute / 2)), + invalid: false, + lastUse: time.Now().Add(-(time.Second * 2)), + }, + config: &cache.Config{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "last use age disabled", + fields: fields{ + created: time.Now().Add(-(time.Minute / 2)), + invalid: false, + lastUse: time.Now().Add(-(time.Second * 2)), + }, + config: &cache.Config{ + MaxAge: time.Minute, + }, + want: true, + }, + { + name: "valid", + fields: fields{ + created: time.Now(), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.Config{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &entry[any]{ + created: tt.fields.created, + } + e.invalid.Store(tt.fields.invalid) + e.lastUse.Store(tt.fields.lastUse.UnixMicro()) + got := e.isValid(tt.config) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/storage/cache/connector/noop/noop.go b/backend/storage/cache/connector/noop/noop.go new file mode 100644 index 0000000000..e3cf69c8ec --- /dev/null +++ b/backend/storage/cache/connector/noop/noop.go @@ -0,0 +1,21 @@ +package noop + +import ( + "context" + + "github.com/zitadel/zitadel/backend/storage/cache" +) + +type noop[I, K comparable, V cache.Entry[I, K]] struct{} + +// NewCache returns a cache that does nothing +func NewCache[I, K comparable, V cache.Entry[I, K]]() cache.Cache[I, K, V] { + return noop[I, K, V]{} +} + +func (noop[I, K, V]) Set(context.Context, V) {} +func (noop[I, K, V]) Get(context.Context, I, K) (value V, ok bool) { return } +func (noop[I, K, V]) Invalidate(context.Context, I, ...K) (err error) { return } +func (noop[I, K, V]) Delete(context.Context, I, ...K) (err error) { return } +func (noop[I, K, V]) Prune(context.Context) (err error) { return } +func (noop[I, K, V]) Truncate(context.Context) (err error) { return } diff --git a/backend/storage/cache/connector_enumer.go b/backend/storage/cache/connector_enumer.go new file mode 100644 index 0000000000..7ea014db16 --- /dev/null +++ b/backend/storage/cache/connector_enumer.go @@ -0,0 +1,98 @@ +// Code generated by "enumer -type Connector -transform snake -trimprefix Connector -linecomment -text"; DO NOT EDIT. + +package cache + +import ( + "fmt" + "strings" +) + +const _ConnectorName = "memorypostgresredis" + +var _ConnectorIndex = [...]uint8{0, 0, 6, 14, 19} + +const _ConnectorLowerName = "memorypostgresredis" + +func (i Connector) String() string { + if i < 0 || i >= Connector(len(_ConnectorIndex)-1) { + return fmt.Sprintf("Connector(%d)", i) + } + return _ConnectorName[_ConnectorIndex[i]:_ConnectorIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _ConnectorNoOp() { + var x [1]struct{} + _ = x[ConnectorUnspecified-(0)] + _ = x[ConnectorMemory-(1)] + _ = x[ConnectorPostgres-(2)] + _ = x[ConnectorRedis-(3)] +} + +var _ConnectorValues = []Connector{ConnectorUnspecified, ConnectorMemory, ConnectorPostgres, ConnectorRedis} + +var _ConnectorNameToValueMap = map[string]Connector{ + _ConnectorName[0:0]: ConnectorUnspecified, + _ConnectorLowerName[0:0]: ConnectorUnspecified, + _ConnectorName[0:6]: ConnectorMemory, + _ConnectorLowerName[0:6]: ConnectorMemory, + _ConnectorName[6:14]: ConnectorPostgres, + _ConnectorLowerName[6:14]: ConnectorPostgres, + _ConnectorName[14:19]: ConnectorRedis, + _ConnectorLowerName[14:19]: ConnectorRedis, +} + +var _ConnectorNames = []string{ + _ConnectorName[0:0], + _ConnectorName[0:6], + _ConnectorName[6:14], + _ConnectorName[14:19], +} + +// ConnectorString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func ConnectorString(s string) (Connector, error) { + if val, ok := _ConnectorNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _ConnectorNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Connector values", s) +} + +// ConnectorValues returns all values of the enum +func ConnectorValues() []Connector { + return _ConnectorValues +} + +// ConnectorStrings returns a slice of all String values of the enum +func ConnectorStrings() []string { + strs := make([]string, len(_ConnectorNames)) + copy(strs, _ConnectorNames) + return strs +} + +// IsAConnector returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Connector) IsAConnector() bool { + for _, v := range _ConnectorValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for Connector +func (i Connector) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for Connector +func (i *Connector) UnmarshalText(text []byte) error { + var err error + *i, err = ConnectorString(string(text)) + return err +} diff --git a/backend/storage/cache/error.go b/backend/storage/cache/error.go new file mode 100644 index 0000000000..b66b9447bf --- /dev/null +++ b/backend/storage/cache/error.go @@ -0,0 +1,29 @@ +package cache + +import ( + "errors" + "fmt" +) + +type IndexUnknownError[I comparable] struct { + index I +} + +func NewIndexUnknownErr[I comparable](index I) error { + return IndexUnknownError[I]{index} +} + +func (i IndexUnknownError[I]) Error() string { + return fmt.Sprintf("index %v unknown", i.index) +} + +func (a IndexUnknownError[I]) Is(err error) bool { + if b, ok := err.(IndexUnknownError[I]); ok { + return a.index == b.index + } + return false +} + +var ( + ErrCacheMiss = errors.New("cache miss") +) diff --git a/backend/storage/cache/gomap/map.go b/backend/storage/cache/gomap/map.go deleted file mode 100644 index 608e636bd1..0000000000 --- a/backend/storage/cache/gomap/map.go +++ /dev/null @@ -1,54 +0,0 @@ -package gomap - -import ( - "sync" - - "github.com/zitadel/zitadel/backend/storage/cache" -) - -type Map[K comparable, V any] struct { - mu sync.RWMutex - items map[K]V -} - -func New[K comparable, V any]() *Map[K, V] { - return &Map[K, V]{ - items: make(map[K]V), - mu: sync.RWMutex{}, - } -} - -// Clear implements cache.Cache. -func (m *Map[K, V]) Clear() { - m.mu.Lock() - defer m.mu.Unlock() - - m.items = make(map[K]V, len(m.items)) -} - -// Delete implements cache.Cache. -func (m *Map[K, V]) Delete(key K) { - m.mu.Lock() - defer m.mu.Unlock() - - delete(m.items, key) -} - -// Get implements cache.Cache. -func (m *Map[K, V]) Get(key K) (V, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - - value, exists := m.items[key] - return value, exists -} - -// Set implements cache.Cache. -func (m *Map[K, V]) Set(key K, value V) { - m.mu.Lock() - defer m.mu.Unlock() - - m.items[key] = value -} - -var _ cache.Cache[string, string] = &Map[string, string]{} diff --git a/backend/storage/cache/pruner.go b/backend/storage/cache/pruner.go new file mode 100644 index 0000000000..959762d410 --- /dev/null +++ b/backend/storage/cache/pruner.go @@ -0,0 +1,76 @@ +package cache + +import ( + "context" + "math/rand" + "time" + + "github.com/jonboulle/clockwork" + "github.com/zitadel/logging" +) + +// Pruner is an optional [Cache] interface. +type Pruner interface { + // Prune deletes all invalidated or expired objects. + Prune(ctx context.Context) error +} + +type PrunerCache[I, K comparable, V Entry[I, K]] interface { + Cache[I, K, V] + Pruner +} + +type AutoPruneConfig struct { + // Interval at which the cache is automatically pruned. + // 0 or lower disables automatic pruning. + Interval time.Duration + + // Timeout for an automatic prune. + // It is recommended to keep the value shorter than AutoPruneInterval + // 0 or lower disables automatic pruning. + Timeout time.Duration +} + +func (c AutoPruneConfig) StartAutoPrune(background context.Context, pruner Pruner, purpose Purpose) (close func()) { + return c.startAutoPrune(background, pruner, purpose, clockwork.NewRealClock()) +} + +func (c *AutoPruneConfig) startAutoPrune(background context.Context, pruner Pruner, purpose Purpose, clock clockwork.Clock) (close func()) { + if c.Interval <= 0 { + return func() {} + } + background, cancel := context.WithCancel(background) + // randomize the first interval + timer := clock.NewTimer(time.Duration(rand.Int63n(int64(c.Interval)))) + go c.pruneTimer(background, pruner, purpose, timer) + return cancel +} + +func (c *AutoPruneConfig) pruneTimer(background context.Context, pruner Pruner, purpose Purpose, timer clockwork.Timer) { + defer func() { + if !timer.Stop() { + <-timer.Chan() + } + }() + + for { + select { + case <-background.Done(): + return + case <-timer.Chan(): + err := c.doPrune(background, pruner) + logging.OnError(err).WithField("purpose", purpose).Error("cache auto prune") + timer.Reset(c.Interval) + } + } +} + +func (c *AutoPruneConfig) doPrune(background context.Context, pruner Pruner) error { + ctx, cancel := context.WithCancel(background) + defer cancel() + if c.Timeout > 0 { + ctx, cancel = context.WithTimeout(background, c.Timeout) + defer cancel() + } + return pruner.Prune(ctx) +} diff --git a/backend/storage/cache/pruner_test.go b/backend/storage/cache/pruner_test.go new file mode 100644 index 0000000000..faaedeb88c --- /dev/null +++ b/backend/storage/cache/pruner_test.go @@ -0,0 +1,43 @@ +package cache + +import ( + "context" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" +) + +type testPruner struct { + called chan struct{} +} + +func (p *testPruner) Prune(context.Context) error { + p.called <- struct{}{} + return nil +} + +func TestAutoPruneConfig_startAutoPrune(t *testing.T) { + c := AutoPruneConfig{ + Interval: time.Second, + Timeout: time.Millisecond, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + pruner := testPruner{ + called: make(chan struct{}), + } + clock := clockwork.NewFakeClock() + close := c.startAutoPrune(ctx, &pruner, PurposeAuthzInstance, clock) + defer close() + clock.Advance(time.Second) + + select { + case _, ok := <-pruner.called: + assert.True(t, ok) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} diff --git a/backend/storage/cache/purpose_enumer.go b/backend/storage/cache/purpose_enumer.go new file mode 100644 index 0000000000..a93a978efb --- /dev/null +++ b/backend/storage/cache/purpose_enumer.go @@ -0,0 +1,90 @@ +// Code generated by "enumer -type Purpose -transform snake -trimprefix Purpose"; DO NOT EDIT. + +package cache + +import ( + "fmt" + "strings" +) + +const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback" + +var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65} + +const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback" + +func (i Purpose) String() string { + if i < 0 || i >= Purpose(len(_PurposeIndex)-1) { + return fmt.Sprintf("Purpose(%d)", i) + } + return _PurposeName[_PurposeIndex[i]:_PurposeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PurposeNoOp() { + var x [1]struct{} + _ = x[PurposeUnspecified-(0)] + _ = x[PurposeAuthzInstance-(1)] + _ = x[PurposeMilestones-(2)] + _ = x[PurposeOrganization-(3)] + _ = x[PurposeIdPFormCallback-(4)] +} + +var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback} + +var _PurposeNameToValueMap = map[string]Purpose{ + _PurposeName[0:11]: PurposeUnspecified, + _PurposeLowerName[0:11]: PurposeUnspecified, + _PurposeName[11:25]: PurposeAuthzInstance, + _PurposeLowerName[11:25]: PurposeAuthzInstance, + _PurposeName[25:35]: PurposeMilestones, + _PurposeLowerName[25:35]: PurposeMilestones, + _PurposeName[35:47]: PurposeOrganization, + _PurposeLowerName[35:47]: PurposeOrganization, + _PurposeName[47:65]: PurposeIdPFormCallback, + _PurposeLowerName[47:65]: PurposeIdPFormCallback, +} + +var _PurposeNames = []string{ + _PurposeName[0:11], + _PurposeName[11:25], + _PurposeName[25:35], + _PurposeName[35:47], + _PurposeName[47:65], +} + +// PurposeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PurposeString(s string) (Purpose, error) { + if val, ok := _PurposeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PurposeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Purpose values", s) +} + +// PurposeValues returns all values of the enum +func PurposeValues() []Purpose { + return _PurposeValues +} + +// PurposeStrings returns a slice of all String values of the enum +func PurposeStrings() []string { + strs := make([]string, len(_PurposeNames)) + copy(strs, _PurposeNames) + return strs +} + +// IsAPurpose returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Purpose) IsAPurpose() bool { + for _, v := range _PurposeValues { + if i == v { + return true + } + } + return false +}