Skip to content

Commit 3326b4d

Browse files
aedenyEden Yefet
authored andcommitted
feat(flagd): add FILE resolver type for local flag definition files
Add support for reading flagd feature flag definitions directly from a local file, with automatic change detection and live reload. New resolver: - FileBasedResolver: reads a flagd flag definition JSON file from disk, validates it against the JSON schema, and evaluates flags locally via JsonEvaluator. Supports waiting for the file to appear (configurable timeout, default 5 min) and reloading on changes. - FileSystemHashWatcher: polls file content using MurmurHash-128 to detect changes. Designed for environments where filesystem events are unreliable (e.g., NFS, container-mounted volumes). Configurable polling interval (default 1 min). Baseline hash is computed synchronously on Start() to prevent race conditions. - When hash-based detection is not enabled, a standard FileSystemWatcher with 500ms debounce is used instead. Configuration via FLAGD_RESOLVER=file, FLAGD_SOURCE_FILE_PATH, and FLAGD_HASH_FILE_CHANGE env vars. Builder API: WithSourceFilePath(), WithUseHashFileChangeDetection(). DI options: FlagdProviderOptions.SourceFilePath, FlagdProviderOptions.UseHashFileChangeDetection. Error handling: FileShare.ReadWrite|Delete for safe concurrent access, schema validation on Init, ProviderError events on parse failures without crashing, ReaderWriterLockSlim protects evaluator during reloads. Tests: 31 new tests (201 total pass) covering FileBasedResolver lifecycle and resolution, FileSystemHashWatcher polling, FlagdConfig env var parsing, FlagdProvider instantiation, and DI option mapping. Documentation: Updated README with FILE resolver config table entries, usage examples, and hash-based detection docs. Signed-off-by: Eden Yefet <edenyefet@gmail.com>
1 parent 24959d5 commit 3326b4d

13 files changed

Lines changed: 1544 additions & 20 deletions

File tree

src/OpenFeature.Providers.Flagd/DependencyInjection/FlagdProviderOptions.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,21 @@ public record FlagdProviderOptions
6161
/// Source selector for the in-process provider. Defaults to empty string.
6262
/// </summary>
6363
public string SourceSelector { get; set; } = string.Empty;
64+
65+
/// <summary>
66+
/// Path to the flag definition JSON file for file-based in-memory resolution.
67+
/// Used when <see cref="ResolverType"/> is <see cref="ResolverType.FILE"/>.
68+
/// Defaults to empty string.
69+
/// </summary>
70+
public string SourceFilePath { get; set; } = string.Empty;
71+
72+
/// <summary>
73+
/// When true, the file watcher uses content hashing (MurmurHash) to detect changes.
74+
/// When false, the file watcher relies on file system events from the OS.
75+
/// File system events can be unreliable in certain containerized environments or mount types;
76+
/// hashing always works reliably but has a higher I/O cost.
77+
/// Used when <see cref="ResolverType"/> is <see cref="ResolverType.FILE"/>.
78+
/// Defaults to false.
79+
/// </summary>
80+
public bool UseHashFileChangeDetection { get; set; } = false;
6481
}

src/OpenFeature.Providers.Flagd/DependencyInjection/FlagdProviderOptionsExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public static FlagdConfig ToFlagdConfig(this FlagdProviderOptions options)
2323
.WithMaxEventStreamRetries(options.MaxEventStreamRetries)
2424
.WithResolverType(options.ResolverType)
2525
.WithSourceSelector(options.SourceSelector)
26+
.WithSourceFilePath(options.SourceFilePath)
27+
.WithUseHashFileChangeDetection(options.UseHashFileChangeDetection)
2628
.Build();
2729

2830
return config;

src/OpenFeature.Providers.Flagd/FlagdConfig.cs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ public enum ResolverType
2020
/// locally for in-process evaluation.
2121
/// Evaluations are preformed in-process.
2222
/// </summary>
23-
IN_PROCESS
23+
IN_PROCESS,
24+
/// <summary>
25+
/// This is the file-based resolving type, where flags are loaded from a local JSON file
26+
/// and evaluated in-process without creating any gRPC streams.
27+
/// Evaluations are preformed in-process.
28+
/// </summary>
29+
FILE
2430
}
2531

2632
/// <summary>
@@ -38,9 +44,13 @@ public class FlagdConfig
3844
internal const string EnvVarMaxEventStreamRetries = "FLAGD_MAX_EVENT_STREAM_RETRIES";
3945
internal const string EnvVarResolverType = "FLAGD_RESOLVER";
4046
internal const string EnvVarSourceSelector = "FLAGD_SOURCE_SELECTOR";
47+
internal const string EnvVarSourceFilePath = "FLAGD_SOURCE_FILE_PATH";
48+
internal const string EnvVarHashFileChange = "FLAGD_HASH_FILE_CHANGE";
4149
internal const string FlagdSelectorHeaderName = "flagd-selector";
4250
internal static int CacheSizeDefault = 10;
4351
internal static string InProcessResolverValue = "in-process";
52+
internal static string RpcResolverValue = "rpc";
53+
internal static string FileResolverValue = "file";
4454
internal static string LruCacheValue = "lru";
4555

4656
/// <summary>
@@ -173,6 +183,28 @@ public ILogger Logger
173183
set => _logger = value;
174184
}
175185

186+
/// <summary>
187+
/// Path to the flag definition JSON file. Used when ResolverType is FILE.
188+
/// </summary>
189+
public string SourceFilePath
190+
{
191+
get => _sourceFilePath;
192+
set => _sourceFilePath = value;
193+
}
194+
195+
/// <summary>
196+
/// When true, the file watcher uses content hashing (MurmurHash) to detect changes.
197+
/// When false, the file watcher relies on file system events from the OS.
198+
/// File system events can be unreliable in certain containerized environments or mount types;
199+
/// hashing always works reliably but has a higher I/O cost.
200+
/// Defaults to false. Used when ResolverType is FILE.
201+
/// </summary>
202+
public bool UseHashFileChangeDetection
203+
{
204+
get => _useHashFileChangeDetection;
205+
set => _useHashFileChangeDetection = value;
206+
}
207+
176208
internal bool UseCertificate => _cert.Length > 0;
177209

178210
private string _host;
@@ -186,6 +218,8 @@ public ILogger Logger
186218
private string _sourceSelector;
187219
private ILogger _logger;
188220
private ResolverType _resolverType;
221+
private string _sourceFilePath;
222+
private bool _useHashFileChangeDetection;
189223

190224
internal FlagdConfig()
191225
{
@@ -205,8 +239,9 @@ internal FlagdConfig()
205239
_maxEventStreamRetries = int.TryParse(Environment.GetEnvironmentVariable(EnvVarMaxEventStreamRetries), out var maxEventStreamRetries) ? maxEventStreamRetries : 3;
206240
}
207241

208-
var resolverTypeStr = Environment.GetEnvironmentVariable(EnvVarResolverType) ?? "RPC";
209-
_resolverType = string.Equals(resolverTypeStr, InProcessResolverValue, StringComparison.OrdinalIgnoreCase) ? ResolverType.IN_PROCESS : ResolverType.RPC;
242+
_resolverType = GetResolverTypeFromEnvironment();
243+
_sourceFilePath = GetSourceFilePathFromEnvironment();
244+
_useHashFileChangeDetection = GetUseHashFileChangeDetectionFromEnvironment();
210245
}
211246

212247
internal Uri GetUri()
@@ -229,6 +264,35 @@ internal Uri GetUri()
229264
}
230265
return uri;
231266
}
267+
268+
private static ResolverType GetResolverTypeFromEnvironment()
269+
{
270+
var resolverTypeStr = Environment.GetEnvironmentVariable(EnvVarResolverType);
271+
272+
if (string.IsNullOrWhiteSpace(resolverTypeStr))
273+
{
274+
return ResolverType.RPC;
275+
}
276+
277+
if (string.Equals(resolverTypeStr, InProcessResolverValue, StringComparison.OrdinalIgnoreCase))
278+
return ResolverType.IN_PROCESS;
279+
if (string.Equals(resolverTypeStr, FileResolverValue, StringComparison.OrdinalIgnoreCase))
280+
return ResolverType.FILE;
281+
282+
return ResolverType.RPC;
283+
}
284+
285+
private static string GetSourceFilePathFromEnvironment()
286+
{
287+
var sourceFilePathStr = Environment.GetEnvironmentVariable(EnvVarSourceFilePath);
288+
return string.IsNullOrWhiteSpace(sourceFilePathStr) ? string.Empty : sourceFilePathStr;
289+
}
290+
291+
private static bool GetUseHashFileChangeDetectionFromEnvironment()
292+
{
293+
var value = Environment.GetEnvironmentVariable(EnvVarHashFileChange);
294+
return !string.IsNullOrEmpty(value) && bool.TryParse(value, out var parsed) && parsed;
295+
}
232296
}
233297

234298
/// <summary>
@@ -328,6 +392,26 @@ public FlagdConfigBuilder WithSourceSelector(string sourceSelector)
328392
return this;
329393
}
330394

395+
/// <summary>
396+
/// Path to the flag definition JSON file for file-based in-memory resolution.
397+
/// </summary>
398+
public FlagdConfigBuilder WithSourceFilePath(string sourceFilePath)
399+
{
400+
_config.SourceFilePath = sourceFilePath;
401+
return this;
402+
}
403+
404+
/// <summary>
405+
/// Enable or disable content hashing for file change detection.
406+
/// When true, the file watcher uses content hashing (MurmurHash) to detect changes.
407+
/// When false, the file watcher relies on file system events from the OS.
408+
/// </summary>
409+
public FlagdConfigBuilder WithUseHashFileChangeDetection(bool useHash)
410+
{
411+
_config.UseHashFileChangeDetection = useHash;
412+
return this;
413+
}
414+
331415
/// <summary>
332416
/// Provide a <see cref="ILogger"/> to be used by the Flagd provider.
333417
/// </summary>
@@ -351,13 +435,18 @@ public FlagdConfig Build()
351435

352436
private void PreBuild()
353437
{
438+
if (this._config.ResolverType == ResolverType.FILE)
439+
{
440+
return;
441+
}
442+
354443
if (this._config.Port == 0)
355444
{
356445
var defaultPortForResolver = this._config.ResolverType switch
357446
{
358447
ResolverType.RPC => 8013,
359448
ResolverType.IN_PROCESS => 8015,
360-
_ => throw new NotImplementedException("ResolverType does not use Ports.")
449+
_ => 8013
361450
};
362451

363452
this._config.Port = defaultPortForResolver;

src/OpenFeature.Providers.Flagd/FlagdProvider.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ public sealed class FlagdProvider : FeatureProvider
3434
/// FLAGD_CACHE - Enable or disable the cache (default="false")
3535
/// FLAGD_MAX_CACHE_SIZE - The maximum size of the cache (default="10")
3636
/// FLAGD_MAX_EVENT_STREAM_RETRIES - The maximum amount of retries for establishing the EventStream
37-
/// FLAGD_RESOLVER - The type of resolver (in-process or rpc) to be used for the provider
37+
/// FLAGD_RESOLVER - The type of resolver (in-process, file or rpc) to be used for the provider
38+
/// FLAGD_SOURCE_FILE_PATH - The path to the flag definition JSON file (used when FLAGD_RESOLVER="file")
39+
/// FLAGD_HASH_FILE_CHANGE - Use content hashing for file change detection (default="false", used when FLAGD_RESOLVER="file")
3840
/// </summary>
3941
public FlagdProvider() : this(FlagdConfig.Builder().Build())
4042
{
@@ -47,7 +49,9 @@ public FlagdProvider() : this(FlagdConfig.Builder().Build())
4749
/// FLAGD_CACHE - Enable or disable the cache (default="false")
4850
/// FLAGD_MAX_CACHE_SIZE - The maximum size of the cache (default="10")
4951
/// FLAGD_MAX_EVENT_STREAM_RETRIES - The maximum amount of retries for establishing the EventStream
50-
/// FLAGD_RESOLVER - The type of resolver (in-process or rpc) to be used for the provider
52+
/// FLAGD_RESOLVER - The type of resolver (in-process, file or rpc) to be used for the provider
53+
/// FLAGD_SOURCE_FILE_PATH - The path to the flag definition JSON file (used when FLAGD_RESOLVER="file")
54+
/// FLAGD_HASH_FILE_CHANGE - Use content hashing for file change detection (default="false", used when FLAGD_RESOLVER="file")
5155
/// <param name="url">The URL of the flagd server</param>
5256
/// <exception cref="ArgumentNullException">if no url is provided.</exception>
5357
/// </summary>
@@ -74,6 +78,19 @@ public FlagdProvider(FlagdConfig config)
7478
var jsonSchemaValidator = new JsonSchemaValidator(_config.Logger);
7579
_resolver = new InProcessResolver(_config, jsonSchemaValidator);
7680
}
81+
else if (_config.ResolverType == ResolverType.FILE)
82+
{
83+
if (string.IsNullOrWhiteSpace(_config.SourceFilePath))
84+
throw new ArgumentException("SourceFilePath must be set when using ResolverType.FILE");
85+
86+
var jsonSchemaValidator = new JsonSchemaValidator(_config.Logger);
87+
_resolver = new FileBasedResolver(
88+
_config.Logger,
89+
_config.SourceFilePath,
90+
jsonSchemaValidator,
91+
_config.SourceSelector,
92+
_config.UseHashFileChangeDetection);
93+
}
7794
else
7895
{
7996
_resolver = new RpcResolver(config);
@@ -220,7 +237,6 @@ public override async Task<ResolutionDetails<string>> ResolveStringValueAsync(st
220237
/// <inheritdoc/>
221238
public override async Task<ResolutionDetails<int>> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext context = null, CancellationToken cancellationToken = default)
222239
{
223-
224240
return await _resolver.ResolveIntegerValueAsync(flagKey, defaultValue, context).ConfigureAwait(false);
225241
}
226242

src/OpenFeature.Providers.Flagd/README.md

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,21 @@ namespace OpenFeatureTestApp
156156

157157
The URI of the flagd server to which the `flagd Provider` connects to can either be passed directly to the constructor, or be configured using the following environment variables:
158158

159-
| Option name | Environment variable name | Type | Default | Values |
160-
| ---------------------------- | ------------------------------ | ------- | --------- | --------------- |
161-
| host | FLAGD_HOST | string | localhost | |
162-
| port | FLAGD_PORT | number | 8013 | |
163-
| tls | FLAGD_TLS | boolean | false | |
164-
| tls certPath | FLAGD_SERVER_CERT_PATH | string | | |
165-
| unix socket path | FLAGD_SOCKET_PATH | string | | |
166-
| Caching | FLAGD_CACHE | string | | lru |
167-
| Maximum cache size | FLAGD_MAX_CACHE_SIZE | number | 10 | |
168-
| Maximum event stream retries | FLAGD_MAX_EVENT_STREAM_RETRIES | number | 3 | |
169-
| Resolver type | FLAGD_RESOLVER | string | rpc | rpc, in-process |
170-
| Source selector | FLAGD_SOURCE_SELECTOR | string | | |
171-
| Logger | n/a | n/a | | |
159+
| Option name | Environment variable name | Type | Default | Values |
160+
| ---------------------------- | ------------------------------ | ------- | --------- | ----------------------- |
161+
| host | FLAGD_HOST | string | localhost | |
162+
| port | FLAGD_PORT | number | 8013 | |
163+
| tls | FLAGD_TLS | boolean | false | |
164+
| tls certPath | FLAGD_SERVER_CERT_PATH | string | | |
165+
| unix socket path | FLAGD_SOCKET_PATH | string | | |
166+
| Caching | FLAGD_CACHE | string | | lru |
167+
| Maximum cache size | FLAGD_MAX_CACHE_SIZE | number | 10 | |
168+
| Maximum event stream retries | FLAGD_MAX_EVENT_STREAM_RETRIES | number | 3 | |
169+
| Resolver type | FLAGD_RESOLVER | string | rpc | rpc, in-process, file |
170+
| Source selector | FLAGD_SOURCE_SELECTOR | string | | |
171+
| Source file path | FLAGD_SOURCE_FILE_PATH | string | | |
172+
| Hash file change detection | FLAGD_HASH_FILE_CHANGE | boolean | false | |
173+
| Logger | n/a | n/a | | |
172174

173175
Note that if `FLAGD_SOCKET_PATH` is set, this value takes precedence, and the other variables (`FLAGD_HOST`, `FLAGD_PORT`, `FLAGD_TLS`, `FLAGD_SERVER_CERT_PATH`) are disregarded.
174176

@@ -245,3 +247,56 @@ var flagdConfig = new FlagdConfigBuilder()
245247
.WithLogger(logger)
246248
.Build();
247249
```
250+
251+
## File resolver type
252+
253+
The flagd provider supports a **file-based resolver mode**, which reads flag definitions from a local JSON file.
254+
This is useful for local development, testing, or air-gapped environments where flags are distributed as files
255+
(e.g., via ConfigMaps, volume mounts, or file sync).
256+
257+
The file resolver is activated by setting the `FLAGD_RESOLVER` environment variable to `file` and providing the
258+
path to the flag definition file via `FLAGD_SOURCE_FILE_PATH`:
259+
260+
```shell
261+
export FLAGD_RESOLVER=file
262+
export FLAGD_SOURCE_FILE_PATH=/etc/flags/my-flags.json
263+
```
264+
265+
Or by configuring the provider programmatically:
266+
267+
```csharp
268+
using OpenFeature.Providers.Flagd;
269+
270+
var flagdConfig = new FlagdConfigBuilder()
271+
.WithResolverType(ResolverType.FILE)
272+
.WithSourceFilePath("/etc/flags/my-flags.json")
273+
.Build();
274+
275+
var flagdProvider = new FlagdProvider(flagdConfig);
276+
OpenFeature.Api.Instance.SetProvider(flagdProvider);
277+
```
278+
279+
The file resolver watches for changes to the flag file and automatically reloads the configuration when changes are
280+
detected. By default, it uses the operating system's `FileSystemWatcher` for change notification.
281+
282+
### Hash-based file change detection
283+
284+
In some environments (e.g., NFS mounts, certain container runtimes, or network file systems), native file system
285+
events may not be reliable. For these cases, you can enable content-based change detection using MurmurHash:
286+
287+
```csharp
288+
var flagdConfig = new FlagdConfigBuilder()
289+
.WithResolverType(ResolverType.FILE)
290+
.WithSourceFilePath("/etc/flags/my-flags.json")
291+
.WithUseHashFileChangeDetection(true)
292+
.Build();
293+
```
294+
295+
Or via environment variable:
296+
297+
```shell
298+
export FLAGD_HASH_FILE_CHANGE=true
299+
```
300+
301+
When enabled, the provider polls the file at a regular interval and compares content hashes rather than relying on
302+
OS-level file change notifications. This is more reliable but has a slightly higher I/O cost due to periodic file reads.

0 commit comments

Comments
 (0)