Skip to content

Commit 01285e7

Browse files
feat(flagd): Add in-process evaluator (#104)
Signed-off-by: Simon Schrottner <[email protected]> Co-authored-by: Cole Bailey <[email protected]>
1 parent 4251f36 commit 01285e7

File tree

13 files changed

+318
-65
lines changed

13 files changed

+318
-65
lines changed

providers/openfeature-provider-flagd/README.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# flagd Provider for OpenFeature
22

3-
This provider is designed to use flagd's [evaluation protocol](https://github.com/open-feature/schemas/blob/main/protobuf/schema/v1/schema.proto).
3+
This provider is designed to use flagd's [evaluation protocol](https://github.com/open-feature/schemas/blob/main/protobuf/schema/v1/schema.proto), or locally evaluate flags defined in a flagd [flag definition](https://github.com/open-feature/schemas/blob/main/json/flagd-definitions.json) via the OpenFeature Python SDK.
44

55
## Installation
66

@@ -29,7 +29,9 @@ api.set_provider(FlagdProvider())
2929

3030
### In-process resolver
3131

32-
This mode performs flag evaluations locally (in-process).
32+
This mode performs flag evaluations locally (in-process). Flag configurations for evaluation are obtained via gRPC protocol using [sync protobuf schema](https://buf.build/open-feature/flagd/file/main:sync/v1/sync_service.proto) service definition.
33+
34+
Consider the following example to create a `FlagdProvider` with in-process evaluations,
3335

3436
```python
3537
from openfeature import api
@@ -38,10 +40,39 @@ from openfeature.contrib.provider.flagd.config import ResolverType
3840

3941
api.set_provider(FlagdProvider(
4042
resolver_type=ResolverType.IN_PROCESS,
43+
))
44+
```
45+
46+
In the above example, in-process handlers attempt to connect to a sync service on address `localhost:8013` to obtain [flag definitions](https://github.com/open-feature/schemas/blob/main/json/flags.json).
47+
48+
<!--
49+
#### Sync-metadata
50+
51+
To support the injection of contextual data configured in flagd for in-process evaluation, the provider exposes a `getSyncMetadata` accessor which provides the most recent value returned by the [GetMetadata RPC](https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1#flagd.sync.v1.FlagSyncService.GetMetadata).
52+
The value is updated with every (re)connection to the sync implementation.
53+
This can be used to enrich evaluations with such data.
54+
If the `in-process` mode is not used, and before the provider is ready, the `getSyncMetadata` returns an empty map.
55+
-->
56+
### File mode
57+
58+
In-process resolvers can also work in an offline mode.
59+
To enable this mode, you should provide a valid flag configuration file with the option `offlineFlagSourcePath`.
60+
61+
```python
62+
from openfeature import api
63+
from openfeature.contrib.provider.flagd import FlagdProvider
64+
from openfeature.contrib.provider.flagd.config import ResolverType
65+
66+
api.set_provider(FlagdProvider(
67+
resolver_type=ResolverType.FILE,
4168
offline_flag_source_path="my-flag.json",
4269
))
4370
```
4471

72+
Provider will attempt to detect file changes using polling.
73+
Polling happens at 5 second intervals and this is currently unconfigurable.
74+
This mode is useful for local development, tests and offline applications.
75+
4576
### Configuration options
4677

4778
The default options can be defined in the FlagdProvider constructor.

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class CacheType(Enum):
4444
ENV_VAR_RETRY_BACKOFF_MS = "FLAGD_RETRY_BACKOFF_MS"
4545
ENV_VAR_RETRY_BACKOFF_MAX_MS = "FLAGD_RETRY_BACKOFF_MAX_MS"
4646
ENV_VAR_RETRY_GRACE_PERIOD_SECONDS = "FLAGD_RETRY_GRACE_PERIOD"
47+
ENV_VAR_SELECTOR = "FLAGD_SOURCE_SELECTOR"
4748
ENV_VAR_STREAM_DEADLINE_MS = "FLAGD_STREAM_DEADLINE_MS"
4849
ENV_VAR_TLS = "FLAGD_TLS"
4950
ENV_VAR_TLS_CERT = "FLAGD_SERVER_CERT_PATH"
@@ -79,6 +80,7 @@ def __init__( # noqa: PLR0913
7980
host: typing.Optional[str] = None,
8081
port: typing.Optional[int] = None,
8182
tls: typing.Optional[bool] = None,
83+
selector: typing.Optional[str] = None,
8284
resolver: typing.Optional[ResolverType] = None,
8385
offline_flag_source_path: typing.Optional[str] = None,
8486
offline_poll_interval_ms: typing.Optional[int] = None,
@@ -221,3 +223,7 @@ def __init__( # noqa: PLR0913
221223
if cert_path is None
222224
else cert_path
223225
)
226+
227+
self.selector = (
228+
env_or_default(ENV_VAR_SELECTOR, None) if selector is None else selector
229+
)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def __init__( # noqa: PLR0913
4646
deadline_ms: typing.Optional[int] = None,
4747
timeout: typing.Optional[int] = None,
4848
retry_backoff_ms: typing.Optional[int] = None,
49+
selector: typing.Optional[str] = None,
4950
resolver_type: typing.Optional[ResolverType] = None,
5051
offline_flag_source_path: typing.Optional[str] = None,
5152
stream_deadline_ms: typing.Optional[int] = None,
@@ -86,6 +87,7 @@ def __init__( # noqa: PLR0913
8687
retry_backoff_ms=retry_backoff_ms,
8788
retry_backoff_max_ms=retry_backoff_max_ms,
8889
retry_grace_period=retry_grace_period,
90+
selector=selector,
8991
resolver=resolver_type,
9092
offline_flag_source_path=offline_flag_source_path,
9193
stream_deadline_ms=stream_deadline_ms,
Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,5 @@
1-
import typing
2-
3-
from openfeature.evaluation_context import EvaluationContext
4-
from openfeature.flag_evaluation import FlagResolutionDetails
5-
61
from .grpc import GrpcResolver
72
from .in_process import InProcessResolver
8-
9-
10-
class AbstractResolver(typing.Protocol):
11-
def initialize(self, evaluation_context: EvaluationContext) -> None: ...
12-
13-
def shutdown(self) -> None: ...
14-
15-
def resolve_boolean_details(
16-
self,
17-
key: str,
18-
default_value: bool,
19-
evaluation_context: typing.Optional[EvaluationContext] = None,
20-
) -> FlagResolutionDetails[bool]: ...
21-
22-
def resolve_string_details(
23-
self,
24-
key: str,
25-
default_value: str,
26-
evaluation_context: typing.Optional[EvaluationContext] = None,
27-
) -> FlagResolutionDetails[str]: ...
28-
29-
def resolve_float_details(
30-
self,
31-
key: str,
32-
default_value: float,
33-
evaluation_context: typing.Optional[EvaluationContext] = None,
34-
) -> FlagResolutionDetails[float]: ...
35-
36-
def resolve_integer_details(
37-
self,
38-
key: str,
39-
default_value: int,
40-
evaluation_context: typing.Optional[EvaluationContext] = None,
41-
) -> FlagResolutionDetails[int]: ...
42-
43-
def resolve_object_details(
44-
self,
45-
key: str,
46-
default_value: typing.Union[dict, list],
47-
evaluation_context: typing.Optional[EvaluationContext] = None,
48-
) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
49-
3+
from .protocol import AbstractResolver
504

515
__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"]

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ..config import Config
1212
from .process.connector import FlagStateConnector
13+
from .process.connector.grpc_watcher import GrpcWatcher
1314
from .process.flags import FlagStore
1415
from .process.targeting import targeting
1516

@@ -28,13 +29,19 @@ def __init__(
2829
],
2930
):
3031
self.config = config
31-
if not self.config.offline_flag_source_path:
32-
raise ValueError(
33-
"offline_flag_source_path must be provided when using in-process resolver"
34-
)
3532
self.flag_store = FlagStore(emit_provider_configuration_changed)
36-
self.connector: FlagStateConnector = FileWatcher(
37-
self.config, self.flag_store, emit_provider_ready, emit_provider_error
33+
self.connector: FlagStateConnector = (
34+
FileWatcher(
35+
self.config, self.flag_store, emit_provider_ready, emit_provider_error
36+
)
37+
if self.config.offline_flag_source_path
38+
else GrpcWatcher(
39+
self.config,
40+
self.flag_store,
41+
emit_provider_ready,
42+
emit_provider_error,
43+
emit_provider_stale,
44+
)
3845
)
3946

4047
def initialize(self, evaluation_context: EvaluationContext) -> None:
@@ -112,6 +119,7 @@ def _resolve(
112119
raise ParseError(
113120
"Parsed JSONLogic targeting did not return a string or bool"
114121
)
122+
115123
variant, value = flag.get_variant(variant)
116124
if not value:
117125
raise ParseError(f"Resolved variant {variant} not in variants config.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import json
2+
import logging
3+
import threading
4+
import time
5+
import typing
6+
7+
import grpc
8+
9+
from openfeature.evaluation_context import EvaluationContext
10+
from openfeature.event import ProviderEventDetails
11+
from openfeature.exception import ErrorCode, ParseError, ProviderNotReadyError
12+
from openfeature.schemas.protobuf.flagd.sync.v1 import (
13+
sync_pb2,
14+
sync_pb2_grpc,
15+
)
16+
17+
from ....config import Config
18+
from ..connector import FlagStateConnector
19+
from ..flags import FlagStore
20+
21+
logger = logging.getLogger("openfeature.contrib")
22+
23+
24+
class GrpcWatcher(FlagStateConnector):
25+
def __init__(
26+
self,
27+
config: Config,
28+
flag_store: FlagStore,
29+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
30+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
31+
emit_provider_stale: typing.Callable[[ProviderEventDetails], None],
32+
):
33+
self.flag_store = flag_store
34+
self.config = config
35+
36+
self.channel = self._generate_channel(config)
37+
self.stub = sync_pb2_grpc.FlagSyncServiceStub(self.channel)
38+
self.retry_backoff_seconds = config.retry_backoff_ms * 0.001
39+
self.retry_backoff_max_seconds = config.retry_backoff_ms * 0.001
40+
self.retry_grace_period = config.retry_grace_period
41+
self.streamline_deadline_seconds = config.stream_deadline_ms * 0.001
42+
self.deadline = config.deadline_ms * 0.001
43+
self.selector = config.selector
44+
self.emit_provider_ready = emit_provider_ready
45+
self.emit_provider_error = emit_provider_error
46+
self.emit_provider_stale = emit_provider_stale
47+
48+
self.connected = False
49+
self.thread: typing.Optional[threading.Thread] = None
50+
self.timer: typing.Optional[threading.Timer] = None
51+
52+
self.start_time = time.time()
53+
54+
def _generate_channel(self, config: Config) -> grpc.Channel:
55+
target = f"{config.host}:{config.port}"
56+
# Create the channel with the service config
57+
options = [
58+
("grpc.keepalive_time_ms", config.keep_alive_time),
59+
("grpc.initial_reconnect_backoff_ms", config.retry_backoff_ms),
60+
("grpc.max_reconnect_backoff_ms", config.retry_backoff_max_ms),
61+
("grpc.min_reconnect_backoff_ms", config.stream_deadline_ms),
62+
]
63+
if config.tls:
64+
channel_args = {
65+
"options": options,
66+
"credentials": grpc.ssl_channel_credentials(),
67+
}
68+
if config.cert_path:
69+
with open(config.cert_path, "rb") as f:
70+
channel_args["credentials"] = grpc.ssl_channel_credentials(f.read())
71+
72+
channel = grpc.secure_channel(target, **channel_args)
73+
74+
else:
75+
channel = grpc.insecure_channel(
76+
target,
77+
options=options,
78+
)
79+
80+
return channel
81+
82+
def initialize(self, context: EvaluationContext) -> None:
83+
self.connect()
84+
85+
def connect(self) -> None:
86+
self.active = True
87+
88+
# Run monitoring in a separate thread
89+
self.monitor_thread = threading.Thread(
90+
target=self.monitor, daemon=True, name="FlagdGrpcSyncServiceMonitorThread"
91+
)
92+
self.monitor_thread.start()
93+
## block until ready or deadline reached
94+
timeout = self.deadline + time.time()
95+
while not self.connected and time.time() < timeout:
96+
time.sleep(0.05)
97+
logger.debug("Finished blocking gRPC state initialization")
98+
99+
if not self.connected:
100+
raise ProviderNotReadyError(
101+
"Blocking init finished before data synced. Consider increasing startup deadline to avoid inconsistent evaluations."
102+
)
103+
104+
def monitor(self) -> None:
105+
self.channel.subscribe(self._state_change_callback, try_to_connect=True)
106+
107+
def _state_change_callback(self, new_state: grpc.ChannelConnectivity) -> None:
108+
logger.debug(f"gRPC state change: {new_state}")
109+
if new_state == grpc.ChannelConnectivity.READY:
110+
if not self.thread or not self.thread.is_alive():
111+
self.thread = threading.Thread(
112+
target=self.listen,
113+
daemon=True,
114+
name="FlagdGrpcSyncWorkerThread",
115+
)
116+
self.thread.start()
117+
118+
if self.timer and self.timer.is_alive():
119+
logger.debug("gRPC error timer expired")
120+
self.timer.cancel()
121+
122+
elif new_state == grpc.ChannelConnectivity.TRANSIENT_FAILURE:
123+
# this is the failed reconnect attempt so we are going into stale
124+
self.emit_provider_stale(
125+
ProviderEventDetails(
126+
message="gRPC sync disconnected, reconnecting",
127+
)
128+
)
129+
self.start_time = time.time()
130+
# adding a timer, so we can emit the error event after time
131+
self.timer = threading.Timer(self.retry_grace_period, self.emit_error)
132+
133+
logger.debug("gRPC error timer started")
134+
self.timer.start()
135+
self.connected = False
136+
137+
def emit_error(self) -> None:
138+
logger.debug("gRPC error emitted")
139+
self.emit_provider_error(
140+
ProviderEventDetails(
141+
message="gRPC sync disconnected, reconnecting",
142+
error_code=ErrorCode.GENERAL,
143+
)
144+
)
145+
146+
def shutdown(self) -> None:
147+
self.active = False
148+
self.channel.close()
149+
150+
def listen(self) -> None:
151+
call_args = (
152+
{"timeout": self.streamline_deadline_seconds}
153+
if self.streamline_deadline_seconds > 0
154+
else {}
155+
)
156+
request_args = {"selector": self.selector} if self.selector is not None else {}
157+
158+
while self.active:
159+
try:
160+
request = sync_pb2.SyncFlagsRequest(**request_args)
161+
162+
logger.debug("Setting up gRPC sync flags connection")
163+
for flag_rsp in self.stub.SyncFlags(
164+
request, wait_for_ready=True, **call_args
165+
):
166+
flag_str = flag_rsp.flag_configuration
167+
logger.debug(
168+
f"Received flag configuration - {abs(hash(flag_str)) % (10**8)}"
169+
)
170+
self.flag_store.update(json.loads(flag_str))
171+
172+
if not self.connected:
173+
self.emit_provider_ready(
174+
ProviderEventDetails(
175+
message="gRPC sync connection established"
176+
)
177+
)
178+
self.connected = True
179+
180+
if not self.active:
181+
logger.debug("Terminating gRPC sync thread")
182+
return
183+
except grpc.RpcError as e: # noqa: PERF203
184+
logger.error(f"SyncFlags stream error, {e.code()=} {e.details()=}")
185+
except json.JSONDecodeError:
186+
logger.exception(
187+
f"Could not parse JSON flag data from SyncFlags endpoint: {flag_str=}"
188+
)
189+
except ParseError:
190+
logger.exception(
191+
f"Could not parse flag data using flagd syntax: {flag_str=}"
192+
)

0 commit comments

Comments
 (0)