Skip to content

Commit

Permalink
feat(flagd-rpc): add caching
Browse files Browse the repository at this point in the history
Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
  • Loading branch information
aepfli committed Nov 23, 2024
1 parent db81de1 commit 6bf2b3c
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 16 deletions.
1 change: 1 addition & 0 deletions providers/openfeature-provider-flagd/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"panzi-json-logic>=1.0.1",
"semver>=3,<4",
"pyyaml>=6.0.1",
"cachebox"
]
requires-python = ">=3.8"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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,
Expand All @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions providers/openfeature-provider-flagd/tests/e2e/rpc_cache.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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"

Scenario: Flag change event
When a string flag with key "changing-flag" is evaluated with details
When a PROVIDER_CONFIGURATION_CHANGED handler is added
And a flag with key "changing-flag" is modified
Then the resolved string details reason should be "STATIC"
Then the resolved string details reason should be "CACHED"
Then the PROVIDER_CONFIGURATION_CHANGED handler must run
And the event details must indicate "changing-flag" was altered
Then the resolved string details reason should be "STATIC"
Then the resolved string details reason should be "CACHED"
36 changes: 36 additions & 0 deletions providers/openfeature-provider-flagd/tests/e2e/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ def setup_key_and_default(
return (key, default)


@when(
parsers.cfparse(
'a string flag with key "{key}" is evaluated with details"',
),
target_fixture="key_and_default",
)
def setup_key_without_default(key: str) -> typing.Tuple[str, JsonPrimitive]:
return setup_key_and_default(key, "")


@when(
parsers.cfparse(
'an object flag with key "{key}" is evaluated with a null default value',
Expand Down Expand Up @@ -661,3 +671,29 @@ def flagd_init(client: OpenFeatureClient, event_handles, error_handles):
@then("an error should be indicated within the configured deadline")
def flagd_error(error_handles):
assert_handlers(error_handles, ProviderEvent.PROVIDER_ERROR)


@when(
parsers.cfparse(
'a string flag with key "{key}" is evaluated with details',
),
target_fixture="key",
)
def setup_key(key: str) -> str:
return key


@then(
parsers.cfparse(
'the resolved string details reason should be "{reason}"',
extra_types={"bool": to_bool},
)
)
def assert_reason_for_flag(
client: OpenFeatureClient,
key: str,
reason: str,
evaluation_context: EvaluationContext,
):
evaluation_result = client.get_string_details(key, "fallback", evaluation_context)
assert_equal(evaluation_result.reason, reason)
23 changes: 21 additions & 2 deletions providers/openfeature-provider-flagd/tests/e2e/test_rpc.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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",
)

0 comments on commit 6bf2b3c

Please sign in to comment.