diff --git a/.changeset/warm-cats-cache.md b/.changeset/warm-cats-cache.md new file mode 100644 index 0000000..50fbc12 --- /dev/null +++ b/.changeset/warm-cats-cache.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/flagship-go": minor +--- + +Add opt-in TTL and LRU response caching to the Go provider. diff --git a/sdks/go/README.md b/sdks/go/README.md index d45ab71..fbf0b1c 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -18,6 +18,7 @@ package main import ( "context" "log" + "time" flagship "github.com/cloudflare/flagship/sdks/go" "github.com/open-feature/go-sdk/openfeature" @@ -30,6 +31,7 @@ func main() { AppID: "your-app-id", AccountID: "your-account-id", AuthToken: "your-token", + CacheTTL: 30 * time.Second, // cache evaluations per context for 30s (off by default) }) if err != nil { log.Fatal(err) @@ -74,9 +76,11 @@ provider, err := flagship.NewProvider(flagship.Options{ return http.Header{"Authorization": []string{"Bearer rotated-token"}}, nil }, - Timeout: 5 * time.Second, - Retries: 1, - RetryDelay: time.Second, + Timeout: 5 * time.Second, + Retries: 1, + RetryDelay: time.Second, + CacheTTL: 30 * time.Second, + CacheMaxSize: 1000, Logging: true, }) @@ -96,10 +100,30 @@ provider, err := flagship.NewProvider(flagship.Options{ | `Retries` | Retry attempts on transient errors; defaults to 1 and is capped at 10. | | `DisableRetries` | Disables retries when set to true. | | `RetryDelay` | Delay between retries; defaults to 1 second and is capped at 30 seconds. | +| `CacheTTL` | Cache TTL; enables response caching when greater than 0. | +| `CacheMaxSize` | Maximum cached entries; defaults to 1000 when `CacheTTL` is set. | | `Logging` | Enables provider debug/error logs; off by default. | | `Logger` | Optional `slog`-compatible logger. | | `Hooks` | Provider-level OpenFeature hooks. | +## Response Caching + +The provider can cache evaluations to avoid a network round-trip for repeated flag/context pairs. Caching is **off by default** and enabled by setting `CacheTTL`: + +```go +provider, err := flagship.NewProvider(flagship.Options{ + AppID: "your-app-id", + AccountID: "your-account-id", + AuthToken: "your-token", + CacheTTL: 30 * time.Second, // values may be up to this stale + CacheMaxSize: 1000, // LRU-evicted beyond this many entries +}) +``` + +Each entry is keyed by flag key, flag type, and the full evaluation context, so distinct contexts never share a value. Cache hits resolve with `reason == openfeature.CachedReason`. Disabled flags, errors, and type mismatches are never cached. Because freshness is TTL-based, a flag change in Flagship takes effect after the entry expires. + +The cache is per-provider instance, guarded by a mutex for concurrent use, and cleared on `Shutdown`. + ## Evaluation Context Context attributes are sent as URL query parameters. Supported values are `string`, numeric types, `bool`, and `time.Time`. `nil` values are skipped. Maps, slices, structs, and other complex values return `INVALID_CONTEXT` through OpenFeature and do not trigger an HTTP request. diff --git a/sdks/go/cache.go b/sdks/go/cache.go new file mode 100644 index 0000000..f38a69b --- /dev/null +++ b/sdks/go/cache.go @@ -0,0 +1,91 @@ +package flagship + +import ( + "container/list" + "sync" + "time" +) + +type responseCache struct { + mu sync.Mutex + ttl time.Duration + maxSize int + items map[string]*list.Element + order *list.List +} + +type responseCacheEntry struct { + key string + response EvaluationResponse + expiresAt time.Time +} + +func newResponseCache(ttl time.Duration, maxSize int) *responseCache { + if maxSize <= 0 { + maxSize = defaultCacheSize + } + return &responseCache{ + ttl: ttl, + maxSize: maxSize, + items: make(map[string]*list.Element), + order: list.New(), + } +} + +func (c *responseCache) get(key string) (EvaluationResponse, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + element, ok := c.items[key] + if !ok { + return EvaluationResponse{}, false + } + + entry := element.Value.(*responseCacheEntry) + if !entry.expiresAt.After(time.Now()) { + c.removeElement(element) + return EvaluationResponse{}, false + } + + c.order.MoveToFront(element) + return entry.response, true +} + +func (c *responseCache) set(key string, response EvaluationResponse) { + c.mu.Lock() + defer c.mu.Unlock() + + if element, ok := c.items[key]; ok { + entry := element.Value.(*responseCacheEntry) + entry.response = response + entry.expiresAt = time.Now().Add(c.ttl) + c.order.MoveToFront(element) + return + } + + entry := &responseCacheEntry{key: key, response: response, expiresAt: time.Now().Add(c.ttl)} + element := c.order.PushFront(entry) + c.items[key] = element + + for len(c.items) > c.maxSize { + oldest := c.order.Back() + if oldest == nil { + return + } + c.removeElement(oldest) + } +} + +func (c *responseCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]*list.Element) + c.order.Init() +} + +func (c *responseCache) removeElement(element *list.Element) { + c.order.Remove(element) + entry := element.Value.(*responseCacheEntry) + delete(c.items, entry.key) +} diff --git a/sdks/go/examples/server/main.go b/sdks/go/examples/server/main.go index 8de8407..d99dfdc 100644 --- a/sdks/go/examples/server/main.go +++ b/sdks/go/examples/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "time" flagship "github.com/cloudflare/flagship/sdks/go" "github.com/open-feature/go-sdk/openfeature" @@ -15,6 +16,7 @@ func main() { AppID: "your-app-id", AccountID: "your-account-id", AuthToken: "your-token", + CacheTTL: 30 * time.Second, // cache evaluations per context for 30s (off by default) }) if err != nil { log.Fatal(err) diff --git a/sdks/go/provider.go b/sdks/go/provider.go index 7ae923b..bb1e880 100644 --- a/sdks/go/provider.go +++ b/sdks/go/provider.go @@ -21,20 +21,36 @@ type FlagshipServerProvider = ServerProvider type ServerProvider struct { client *FlagshipClient hooks []openfeature.Hook + cache *responseCache logging bool logger Logger } +const ( + flagTypeBoolean = "boolean" + flagTypeString = "string" + flagTypeFloat = "float" + flagTypeInt = "integer" + flagTypeObject = "object" +) + // NewProvider constructs a Flagship OpenFeature provider. func NewProvider(options Options) (*ServerProvider, error) { client, err := NewClient(options) if err != nil { return nil, err } + + var cache *responseCache + if options.CacheTTL > 0 { + cache = newResponseCache(options.CacheTTL, options.CacheMaxSize) + } + return &ServerProvider{ client: client, hooks: append([]openfeature.Hook(nil), options.Hooks...), + cache: cache, logging: options.Logging, logger: resolveLogger(options.Logger), }, nil @@ -68,6 +84,9 @@ func (p *ServerProvider) InitWithContext(context.Context, openfeature.Evaluation // Shutdown releases provider resources. func (p *ServerProvider) Shutdown() { + if p.cache != nil { + p.cache.clear() + } } // ShutdownWithContext releases provider resources. @@ -78,31 +97,31 @@ func (p *ServerProvider) ShutdownWithContext(context.Context) error { // BooleanEvaluation evaluates a boolean flag. func (p *ServerProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, flatCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { - value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toBool) + value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeBoolean, toBool) return openfeature.BoolResolutionDetail{Value: value, ProviderResolutionDetail: detail} } // StringEvaluation evaluates a string flag. func (p *ServerProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, flatCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { - value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toString) + value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeString, toString) return openfeature.StringResolutionDetail{Value: value, ProviderResolutionDetail: detail} } // FloatEvaluation evaluates a float flag. func (p *ServerProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, flatCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { - value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toFloat64) + value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeFloat, toFloat64) return openfeature.FloatResolutionDetail{Value: value, ProviderResolutionDetail: detail} } // IntEvaluation evaluates an integer flag. func (p *ServerProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, flatCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { - value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toInt64) + value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeInt, toInt64) return openfeature.IntResolutionDetail{Value: value, ProviderResolutionDetail: detail} } // ObjectEvaluation evaluates an object flag. func (p *ServerProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, flatCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { - value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toObject) + value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeObject, toObject) return openfeature.InterfaceResolutionDetail{Value: value, ProviderResolutionDetail: detail} } @@ -112,12 +131,31 @@ func resolveTyped[T any]( flag string, defaultValue T, flatCtx openfeature.FlattenedContext, + expectedType string, convert func(any) (T, error), ) (T, openfeature.ProviderResolutionDetail) { if p.logging { p.logger.DebugContext(ctx, "Evaluating Flagship flag", "flag", flag) } + var cacheKey string + if p.cache != nil { + key, err := buildCacheKey(flag, expectedType, flatCtx) + if err == nil { + if cached, ok := p.cache.get(key); ok { + value, err := convert(cached.Value) + if err == nil { + return value, openfeature.ProviderResolutionDetail{ + Reason: openfeature.CachedReason, + Variant: cached.Variant, + FlagMetadata: openfeature.FlagMetadata{}, + } + } + } + cacheKey = key + } + } + result, err := p.client.EvaluateFlat(ctx, flag, flatCtx) if err != nil { if p.logging { @@ -152,6 +190,9 @@ func resolveTyped[T any]( if p.logging { p.logger.DebugContext(ctx, "Flagship flag resolved", "flag", flag, "value", value, "reason", result.Reason, "variant", result.Variant) } + if p.cache != nil && cacheKey != "" { + p.cache.set(cacheKey, result) + } return value, openfeature.ProviderResolutionDetail{ Reason: mapReason(result.Reason), @@ -160,6 +201,18 @@ func resolveTyped[T any]( } } +func buildCacheKey(flagKey string, expectedType string, flatCtx openfeature.FlattenedContext) (string, error) { + params, err := contextToQueryParams(flatCtx) + if err != nil { + return "", err + } + encoded, err := json.Marshal([]string{flagKey, expectedType, params.Encode()}) + if err != nil { + return "", err + } + return string(encoded), nil +} + func mapReason(reason EvaluationReason) openfeature.Reason { switch reason { case ReasonTargetingMatch: diff --git a/sdks/go/provider_cache_test.go b/sdks/go/provider_cache_test.go new file mode 100644 index 0000000..7285a34 --- /dev/null +++ b/sdks/go/provider_cache_test.go @@ -0,0 +1,236 @@ +package flagship + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/open-feature/go-sdk/openfeature" +) + +func TestProviderCacheDisabledByDefault(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "TARGETING_MATCH") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{}) + _ = provider.BooleanEvaluation(context.Background(), "k", false, nil) + _ = provider.BooleanEvaluation(context.Background(), "k", false, nil) + + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheHitServesWithoutRequestAndMarksCached(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "TARGETING_MATCH") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + first := provider.BooleanEvaluation(context.Background(), "k", false, nil) + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + if first.Value != true || first.Reason != openfeature.TargetingMatchReason { + t.Fatalf("first = %#v", first) + } + if second.Value != true || second.Reason != openfeature.CachedReason || second.Variant != "on" { + t.Fatalf("second = %#v", second) + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want 1", calls.Load()) + } +} + +func TestProviderCacheKeyIncludesContext(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "TARGETING_MATCH") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + free := openfeature.FlattenedContext{"plan": "free"} + enterprise := openfeature.FlattenedContext{"plan": "enterprise"} + + _ = provider.BooleanEvaluation(context.Background(), "k", false, free) + _ = provider.BooleanEvaluation(context.Background(), "k", false, enterprise) + third := provider.BooleanEvaluation(context.Background(), "k", false, free) + + if third.Reason != openfeature.CachedReason { + t.Fatalf("third reason = %s, want CACHED", third.Reason) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheKeyIncludesType(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, 7, "seven", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + _ = provider.IntEvaluation(context.Background(), "k", 0, nil) + _ = provider.FloatEvaluation(context.Background(), "k", 0, nil) + third := provider.IntEvaluation(context.Background(), "k", 0, nil) + + if third.Reason != openfeature.CachedReason { + t.Fatalf("third reason = %s, want CACHED", third.Reason) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheExpiresAfterTTL(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: 25 * time.Millisecond}) + _ = provider.BooleanEvaluation(context.Background(), "k", false, nil) + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + time.Sleep(50 * time.Millisecond) + third := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + if second.Reason != openfeature.CachedReason { + t.Fatalf("second reason = %s, want CACHED", second.Reason) + } + if third.Reason == openfeature.CachedReason { + t.Fatalf("third reason = %s, want non-cached", third.Reason) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheEvictsLeastRecentlyUsed(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute, CacheMaxSize: 1}) + _ = provider.BooleanEvaluation(context.Background(), "a", false, nil) + _ = provider.BooleanEvaluation(context.Background(), "b", false, nil) + third := provider.BooleanEvaluation(context.Background(), "b", false, nil) + fourth := provider.BooleanEvaluation(context.Background(), "a", false, nil) + + if third.Reason != openfeature.CachedReason { + t.Fatalf("third reason = %s, want CACHED", third.Reason) + } + if fourth.Reason == openfeature.CachedReason { + t.Fatalf("fourth reason = %s, want non-cached", fourth.Reason) + } + if calls.Load() != 3 { + t.Fatalf("calls = %d, want 3", calls.Load()) + } +} + +func TestProviderCacheDoesNotStoreDisabledResults(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "DISABLED") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + first := provider.BooleanEvaluation(context.Background(), "k", false, nil) + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + if first.Reason != openfeature.DisabledReason || second.Reason != openfeature.DisabledReason { + t.Fatalf("first = %#v second = %#v", first, second) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheDoesNotStoreErrors(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + http.Error(w, "temporary", http.StatusInternalServerError) + return + } + writeEvaluationResponse(w, true, "on", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + first := provider.BooleanEvaluation(context.Background(), "k", false, nil) + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + requireResolutionErrorCode(t, first.ResolutionDetail(), openfeature.GeneralCode) + if second.Value != true || second.ResolutionDetail().ErrorCode != "" { + t.Fatalf("second = %#v", second) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderCacheDoesNotStoreTypeMismatches(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + writeEvaluationResponse(w, "not a bool", "bad", "DEFAULT") + return + } + writeEvaluationResponse(w, true, "on", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + first := provider.BooleanEvaluation(context.Background(), "k", false, nil) + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + requireResolutionErrorCode(t, first.ResolutionDetail(), openfeature.TypeMismatchCode) + if second.Value != true || second.ResolutionDetail().ErrorCode != "" { + t.Fatalf("second = %#v", second) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} + +func TestProviderShutdownClearsCache(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeEvaluationResponse(w, true, "on", "DEFAULT") + })) + defer server.Close() + + provider := newTestProvider(t, server.URL, Options{CacheTTL: time.Minute}) + _ = provider.BooleanEvaluation(context.Background(), "k", false, nil) + provider.Shutdown() + second := provider.BooleanEvaluation(context.Background(), "k", false, nil) + + if second.Reason == openfeature.CachedReason { + t.Fatalf("second reason = %s, want non-cached", second.Reason) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want 2", calls.Load()) + } +} diff --git a/sdks/go/provider_test.go b/sdks/go/provider_test.go index c12fd60..04b9337 100644 --- a/sdks/go/provider_test.go +++ b/sdks/go/provider_test.go @@ -322,6 +322,12 @@ func mergeOptions(options ...Options) Options { if option.DisableRetries { merged.DisableRetries = true } + if option.CacheTTL != 0 { + merged.CacheTTL = option.CacheTTL + } + if option.CacheMaxSize != 0 { + merged.CacheMaxSize = option.CacheMaxSize + } } return merged } diff --git a/sdks/go/types.go b/sdks/go/types.go index 1f94e75..465cada 100644 --- a/sdks/go/types.go +++ b/sdks/go/types.go @@ -18,6 +18,7 @@ const ( defaultTimeout = 5 * time.Second defaultRetries = 1 defaultRetryDelay = time.Second + defaultCacheSize = 1000 maxRetries = 10 maxRetryDelay = 30 * time.Second ) @@ -52,6 +53,12 @@ type Options struct { Logging bool Logger Logger Hooks []openfeature.Hook + + // CacheTTL enables an in-memory TTL + LRU response cache when greater than 0. + // Cached values may be up to this duration stale. + CacheTTL time.Duration + // CacheMaxSize limits cached entries. Defaults to 1000 when CacheTTL is set. + CacheMaxSize int } // EvaluationReason is the reason returned by the Flagship evaluation API.