diff --git a/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml index 14befb93..2c806088 100644 --- a/providers/openfeature-provider-flagd/pyproject.toml +++ b/providers/openfeature-provider-flagd/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "panzi-json-logic>=1.0.1", "semver>=3,<4", "pyyaml>=6.0.1", + "cachebox" ] requires-python = ">=3.8" diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py index a95c3153..b5f21fc0 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py @@ -2,6 +2,15 @@ import typing from enum import Enum +ENV_VAR_MAX_CACHE_SIZE = "FLAGD_MAX_CACHE_SIZE" +ENV_VAR_CACHE_TYPE = "FLAGD_CACHE_TYPE" +ENV_VAR_OFFLINE_POLL_INTERVAL_SECONDS = "FLAGD_OFFLINE_POLL_INTERVAL_SECONDS" +ENV_VAR_OFFLINE_FLAG_SOURCE_PATH = "FLAGD_OFFLINE_FLAG_SOURCE_PATH" +ENV_VAR_PORT = "FLAGD_PORT" +ENV_VAR_RESOLVER_TYPE = "FLAGD_RESOLVER_TYPE" +ENV_VAR_TLS = "FLAGD_TLS" +ENV_VAR_HOST = "FLAGD_HOST" + T = typing.TypeVar("T") @@ -23,6 +32,11 @@ class ResolverType(Enum): IN_PROCESS = "in-process" +class CacheType(Enum): + LRU = "lru" + DISABLED = "disabled" + + class Config: def __init__( # noqa: PLR0913 self, @@ -33,27 +47,45 @@ def __init__( # noqa: PLR0913 resolver_type: typing.Optional[ResolverType] = None, offline_flag_source_path: typing.Optional[str] = None, offline_poll_interval_seconds: typing.Optional[float] = None, + cache_type: typing.Optional[CacheType] = None, + max_cache_size: typing.Optional[int] = None, ): - self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host - self.port = ( - env_or_default("FLAGD_PORT", 8013, cast=int) if port is None else port - ) + self.host = env_or_default(ENV_VAR_HOST, "localhost") if host is None else host self.tls = ( - env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls + env_or_default(ENV_VAR_TLS, False, cast=str_to_bool) if tls is None else tls ) self.timeout = 5 if timeout is None else timeout self.resolver_type = ( - ResolverType(env_or_default("FLAGD_RESOLVER_TYPE", "grpc")) + ResolverType(env_or_default(ENV_VAR_RESOLVER_TYPE, "grpc")) if resolver_type is None else resolver_type ) + + default_port = 8013 if self.resolver_type is ResolverType.GRPC else 8015 + self.port = ( + env_or_default(ENV_VAR_PORT, default_port, cast=int) + if port is None + else port + ) self.offline_flag_source_path = ( - env_or_default("FLAGD_OFFLINE_FLAG_SOURCE_PATH", None) + env_or_default(ENV_VAR_OFFLINE_FLAG_SOURCE_PATH, None) if offline_flag_source_path is None else offline_flag_source_path ) self.offline_poll_interval_seconds = ( - float(env_or_default("FLAGD_OFFLINE_POLL_INTERVAL_SECONDS", 1.0)) + float(env_or_default(ENV_VAR_OFFLINE_POLL_INTERVAL_SECONDS, 1.0)) if offline_poll_interval_seconds is None else offline_poll_interval_seconds ) + + self.cache_type = ( + CacheType(env_or_default(ENV_VAR_CACHE_TYPE, CacheType.DISABLED)) + if cache_type is None + else cache_type + ) + + self.max_cache_size = ( + env_or_default(ENV_VAR_MAX_CACHE_SIZE, 16, cast=int) + if max_cache_size is None + else max_cache_size + ) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py index b569a077..758f6c4f 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py @@ -28,7 +28,7 @@ from openfeature.provider.metadata import Metadata from openfeature.provider.provider import AbstractProvider -from .config import Config, ResolverType +from .config import CacheType, Config, ResolverType from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver T = typing.TypeVar("T") @@ -46,6 +46,8 @@ def __init__( # noqa: PLR0913 resolver_type: typing.Optional[ResolverType] = None, offline_flag_source_path: typing.Optional[str] = None, offline_poll_interval_seconds: typing.Optional[float] = None, + cache_type: typing.Optional[CacheType] = None, + max_cache_size: typing.Optional[int] = None, ): """ Create an instance of the FlagdProvider @@ -63,6 +65,8 @@ def __init__( # noqa: PLR0913 resolver_type=resolver_type, offline_flag_source_path=offline_flag_source_path, offline_poll_interval_seconds=offline_poll_interval_seconds, + cache_type=cache_type, + max_cache_size=max_cache_size, ) self.resolver = self.setup_resolver() diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py index 5f7679cc..22158dbb 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -4,6 +4,7 @@ import typing import grpc +from cachebox import LRUCache # type:ignore[import-not-found] from google.protobuf.json_format import MessageToDict from google.protobuf.struct_pb2 import Struct from schemas.protobuf.flagd.evaluation.v1 import ( # type:ignore[import-not-found] @@ -21,9 +22,9 @@ ParseError, TypeMismatchError, ) -from openfeature.flag_evaluation import FlagResolutionDetails +from openfeature.flag_evaluation import FlagResolutionDetails, Reason -from ..config import Config +from ..config import CacheType, Config from ..flag_type import FlagType T = typing.TypeVar("T") @@ -55,12 +56,20 @@ def __init__( self.retry_backoff_seconds = 0.1 self.connected = False + self._cache = ( + LRUCache(maxsize=self.config.max_cache_size) + if self.config.cache_type == CacheType.LRU + else None + ) + def initialize(self, evaluation_context: EvaluationContext) -> None: self.connect() def shutdown(self) -> None: self.active = False self.channel.close() + if self._cache: + self._cache.clear() def connect(self) -> None: self.active = True @@ -72,7 +81,7 @@ def connect(self) -> None: def listen(self) -> None: retry_delay = self.retry_backoff_seconds while self.active: - request = evaluation_pb2.EventStreamRequest() # type:ignore[attr-defined] + request = evaluation_pb2.EventStreamRequest() try: logger.debug("Setting up gRPC sync flags connection") for message in self.stub.EventStream(request): @@ -115,6 +124,10 @@ def listen(self) -> None: def handle_changed_flags(self, data: typing.Any) -> None: changed_flags = list(data["flags"].keys()) + if self._cache: + for flag in changed_flags: + self._cache.pop(flag) + self.emit_provider_configuration_changed(ProviderEventDetails(changed_flags)) def resolve_boolean_details( @@ -157,13 +170,18 @@ def resolve_object_details( ) -> FlagResolutionDetails[typing.Union[dict, list]]: return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context) - def _resolve( # noqa: PLR0915 + def _resolve( # noqa: PLR0915 C901 self, flag_key: str, flag_type: FlagType, default_value: T, evaluation_context: typing.Optional[EvaluationContext], ) -> FlagResolutionDetails[T]: + if self._cache is not None and flag_key in self._cache: + cached_flag: FlagResolutionDetails[T] = self._cache[flag_key] + cached_flag.reason = Reason.CACHED + return cached_flag + context = self._convert_context(evaluation_context) call_args = {"timeout": self.config.timeout} try: @@ -215,12 +233,17 @@ def _resolve( # noqa: PLR0915 raise GeneralError(message) from e # Got a valid flag and valid type. Return it. - return FlagResolutionDetails( + result = FlagResolutionDetails( value=value, reason=response.reason, variant=response.variant, ) + if response.reason == Reason.STATIC and self._cache is not None: + self._cache.insert(flag_key, result) + + return result + def _convert_context( self, evaluation_context: typing.Optional[EvaluationContext] ) -> Struct: diff --git a/providers/openfeature-provider-flagd/tests/e2e/rpc_cache.feature b/providers/openfeature-provider-flagd/tests/e2e/rpc_cache.feature new file mode 100644 index 00000000..066f62a2 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/rpc_cache.feature @@ -0,0 +1,33 @@ +Feature: Flag evaluation with Caching + +# This test suite contains scenarios to test the flag evaluation API. + + Background: + Given a provider is registered with caching + + Scenario: Resolves boolean details with caching + When a boolean flag with key "boolean-flag" is evaluated with details and default value "false" + Then the resolved boolean details value should be "true", the variant should be "on", and the reason should be "STATIC" + Then the resolved boolean details value should be "true", the variant should be "on", and the reason should be "CACHED" + + Scenario: Resolves string details + When a string flag with key "string-flag" is evaluated with details and default value "bye" + Then the resolved string details value should be "hi", the variant should be "greeting", and the reason should be "STATIC" + Then the resolved string details value should be "hi", the variant should be "greeting", and the reason should be "CACHED" + + Scenario: Resolves integer details + When an integer flag with key "integer-flag" is evaluated with details and default value 1 + Then the resolved integer details value should be 10, the variant should be "ten", and the reason should be "STATIC" + Then the resolved integer details value should be 10, the variant should be "ten", and the reason should be "CACHED" + + Scenario: Resolves float details + When a float flag with key "float-flag" is evaluated with details and default value 0.1 + Then the resolved float details value should be 0.5, the variant should be "half", and the reason should be "STATIC" + Then the resolved float details value should be 0.5, the variant should be "half", and the reason should be "CACHED" + + Scenario: Resolves object details + When an object flag with key "object-flag" is evaluated with details and a null default value + Then the resolved object details value should be contain fields "showImages", "title", and "imagesPerPage", with values "true", "Check out these pics!" and 100, respectively + And the variant should be "template", and the reason should be "STATIC" + Then the resolved object details value should be contain fields "showImages", "title", and "imagesPerPage", with values "true", "Check out these pics!" and 100, respectively + And the variant should be "template", and the reason should be "CACHED" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_rpc.py b/providers/openfeature-provider-flagd/tests/e2e/test_rpc.py index d39d90fc..9494d96f 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/test_rpc.py +++ b/providers/openfeature-provider-flagd/tests/e2e/test_rpc.py @@ -1,7 +1,12 @@ import pytest -from pytest_bdd import scenarios +from pytest_bdd import given, scenarios +from tests.e2e.steps import wait_for -from openfeature.contrib.provider.flagd.config import ResolverType +from openfeature import api +from openfeature.client import OpenFeatureClient +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import CacheType, ResolverType +from openfeature.provider import ProviderStatus @pytest.fixture(autouse=True, scope="module") @@ -24,8 +29,22 @@ def image(): return "ghcr.io/open-feature/flagd-testbed:v0.5.13" +@given("a provider is registered with caching", target_fixture="client") +def setup_caching_provider(setup, resolver_type, client_name) -> OpenFeatureClient: + api.set_provider( + FlagdProvider( + resolver_type=resolver_type, port=setup, cache_type=CacheType.LRU + ), + client_name, + ) + client = api.get_client(client_name) + wait_for(lambda: client.get_provider_status() == ProviderStatus.READY) + return client + + scenarios( "../../test-harness/gherkin/flagd.feature", "../../test-harness/gherkin/flagd-json-evaluator.feature", "../../spec/specification/assets/gherkin/evaluation.feature", + "./rpc_cache.feature", )