Skip to content

Commit cb73a19

Browse files
aepfliclaude
andauthored
feat(dotnet): add .NET WASM evaluator package (#107)
## Summary - Adds a .NET 8.0 package (`dotnet/`) wrapping the Rust WASM evaluator using **Wasmtime for .NET v34.0.2** - Implements the same optimized architecture as Java/Go packages: WASM instance pool, lock-free pre-evaluated cache, context key filtering, index-based evaluation, and generation guard - Includes 17 xunit tests (all passing), BenchmarkDotNet suite with evaluation + comparison benchmarks, Makefile, and README ## Architecture - **WASM Instance Pool**: `BlockingCollection<WasmInstance>` sized to `ProcessorCount` for parallel evaluation - **Pre-evaluated Cache**: Static/disabled flags served lock-free via `volatile` reference swap (13 ns, zero alloc) - **Context Key Filtering**: Only serialize context keys referenced in targeting rules - **Index-based Evaluation**: `evaluate_by_index(u32)` avoids flag key string serialization - **Generation Guard**: Detects stale cache snapshots during concurrent UpdateState/EvaluateFlag - **Prefix-based Host Function Matching**: Survives wasm-bindgen hash suffix changes across WASM rebuilds ## Comparison: WASM vs Old JsonLogic Provider (json-everything v5.4.0) | Scenario | Old (JsonLogic) | New (WASM) | Speedup | Memory | |----------|-----------------|------------|---------|--------| | Simple flag (pre-eval) | 38 ns / 72 B | **13 ns / 0 B** | **2.9x** | **100% less** | | Targeting (small ctx) | 6,993 ns / 7,120 B | **5,082 ns / 592 B** | **1.4x** | **12x less** | | Targeting (empty ctx) | 3,440 ns / 3,768 B | **2,669 ns / 288 B** | **1.3x** | **13x less** | | Targeting (large ctx, 100+ attrs) | 32,881 ns / 33,448 B | **5,328 ns / 592 B** | **6.2x** | **56x less** | | 8-thread large ctx | 229,529 ns / 268 KB | **105,425 ns / 5.6 KB** | **2.2x** | **48x less** | Context key filtering gives the biggest win: with 100+ attributes, the old provider serializes everything (33 KB allocations), while the WASM evaluator only serializes the 2 keys referenced in targeting rules (592 B). ## Standalone Evaluation Benchmarks | Benchmark | Mean | Allocated | |-----------|------|-----------| | PreEvaluated (static flag) | 12.5 ns | 0 B | | SimpleTargeting SmallCtx | 4.67 µs | 568 B | | SimpleTargeting LargeCtx | 4.30 µs | 568 B | | ComplexTargeting SmallCtx | 5.65 µs | 584 B | | ComplexTargeting LargeCtx | 5.87 µs | 584 B | ## Test plan - [x] 17 xunit tests passing (bool/string/numeric flags, targeting, concurrency, generation guard, pool size 1) - [x] BenchmarkDotNet evaluation + comparison suites run successfully - [ ] Verify `make wasm && make build && make test` from clean checkout Closes #105 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 84fff31 commit cb73a19

20 files changed

Lines changed: 2231 additions & 0 deletions

dotnet/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
bin/
2+
obj/
3+
*.wasm
4+
BenchmarkDotNet.Artifacts/

dotnet/Makefile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
WASM_SRC := ../target/wasm32-unknown-unknown/release/flagd_evaluator.wasm
2+
WASM_DST := src/FlagdEvaluator/flagd_evaluator.wasm
3+
4+
.PHONY: wasm build test bench clean
5+
6+
wasm:
7+
cargo build --manifest-path ../Cargo.toml --target wasm32-unknown-unknown --no-default-features --release --lib
8+
cp $(WASM_SRC) $(WASM_DST)
9+
10+
build:
11+
dotnet build dotnet.sln -c Release
12+
13+
test:
14+
dotnet test dotnet.sln -c Release --logger "console;verbosity=detailed"
15+
16+
bench:
17+
dotnet run --project benchmarks/FlagdEvaluator.Benchmarks/FlagdEvaluator.Benchmarks.csproj -c Release
18+
19+
clean:
20+
dotnet clean dotnet.sln
21+
rm -rf src/FlagdEvaluator/bin src/FlagdEvaluator/obj
22+
rm -rf tests/FlagdEvaluator.Tests/bin tests/FlagdEvaluator.Tests/obj
23+
rm -rf benchmarks/FlagdEvaluator.Benchmarks/bin benchmarks/FlagdEvaluator.Benchmarks/obj

dotnet/README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# flagd-evaluator .NET
2+
3+
.NET wrapper for the flagd-evaluator WASM module. Provides high-performance feature flag evaluation using a pool of Wasmtime WASM instances with lock-free pre-evaluated caching and context key filtering.
4+
5+
## Requirements
6+
7+
- .NET 8.0 SDK
8+
- Rust toolchain with `wasm32-unknown-unknown` target (for building WASM from source)
9+
10+
## Quick Start
11+
12+
```csharp
13+
using FlagdEvaluator;
14+
15+
// Create evaluator (pool size defaults to Environment.ProcessorCount)
16+
using var evaluator = new FlagEvaluator();
17+
18+
// Load flag configuration
19+
var result = evaluator.UpdateState("""
20+
{
21+
"flags": {
22+
"my-flag": {
23+
"state": "ENABLED",
24+
"defaultVariant": "on",
25+
"variants": { "on": true, "off": false },
26+
"targeting": {
27+
"if": [
28+
{ "==": [{ "var": "email" }, "admin@example.com"] },
29+
"on", "off"
30+
]
31+
}
32+
}
33+
}
34+
}
35+
""");
36+
37+
// Evaluate with context
38+
var context = new Dictionary<string, object?> { ["email"] = "admin@example.com" };
39+
var eval = evaluator.EvaluateFlag("my-flag", context);
40+
// eval.Value = true, eval.Variant = "on", eval.Reason = "TARGETING_MATCH"
41+
42+
// Typed convenience methods (return default on error)
43+
bool value = evaluator.EvaluateBool("my-flag", context, defaultValue: false);
44+
```
45+
46+
## API Reference
47+
48+
### `FlagEvaluator`
49+
50+
| Method | Description |
51+
|--------|-------------|
52+
| `FlagEvaluator(FlagEvaluatorOptions?)` | Create evaluator with optional configuration |
53+
| `UpdateState(string configJson)` | Update flag configuration across all instances |
54+
| `EvaluateFlag(string flagKey, Dictionary<string, object?>?)` | Evaluate a flag, returns full `EvaluationResult` |
55+
| `EvaluateBool(flagKey, context, defaultValue)` | Evaluate boolean flag with default fallback |
56+
| `EvaluateString(flagKey, context, defaultValue)` | Evaluate string flag with default fallback |
57+
| `EvaluateInt(flagKey, context, defaultValue)` | Evaluate integer flag with default fallback |
58+
| `EvaluateDouble(flagKey, context, defaultValue)` | Evaluate double flag with default fallback |
59+
| `PoolSize` | Number of WASM instances |
60+
| `Dispose()` | Release all resources |
61+
62+
### `FlagEvaluatorOptions`
63+
64+
| Property | Default | Description |
65+
|----------|---------|-------------|
66+
| `PoolSize` | `Environment.ProcessorCount` | Number of WASM instances for parallel evaluation |
67+
| `PermissiveValidation` | `false` | Accept invalid configs with warnings instead of rejecting |
68+
69+
### `EvaluationResult`
70+
71+
| Property | Type | Description |
72+
|----------|------|-------------|
73+
| `Value` | `JsonElement?` | The resolved flag value |
74+
| `Variant` | `string` | The variant name |
75+
| `Reason` | `string` | `STATIC`, `DEFAULT`, `TARGETING_MATCH`, `DISABLED`, `ERROR`, `FLAG_NOT_FOUND` |
76+
| `ErrorCode` | `string?` | Error code if evaluation failed |
77+
| `ErrorMessage` | `string?` | Error description |
78+
| `FlagMetadata` | `Dictionary<string, JsonElement>?` | Flag metadata |
79+
| `IsError` | `bool` | True if evaluation resulted in an error |
80+
81+
## Build & Test
82+
83+
```bash
84+
cd dotnet
85+
86+
# Build WASM from Rust source
87+
make wasm
88+
89+
# Build .NET solution
90+
make build
91+
92+
# Run tests
93+
make test
94+
95+
# Run benchmarks
96+
make bench
97+
```
98+
99+
## Architecture
100+
101+
- **WASM Instance Pool**: `BlockingCollection<WasmInstance>` sized to `ProcessorCount` for parallel evaluation
102+
- **Pre-evaluated Cache**: Static/disabled flags served lock-free via `volatile` reference swap
103+
- **Context Key Filtering**: Only serialize context keys referenced in targeting rules (32-34x speedup for large contexts)
104+
- **Index-based Evaluation**: `evaluate_by_index(u32)` avoids flag key string serialization
105+
- **Generation Guard**: Detects stale cache snapshots when `UpdateState` races with `EvaluateFlag`
106+
- **Pre-allocated Buffers**: Each instance pre-allocates 256B (flag key) + 1MB (context) buffers in WASM memory
107+
108+
## Thread Safety
109+
110+
`FlagEvaluator` is fully thread-safe:
111+
- `UpdateState()` serialized via lock; drains all pool instances, updates in parallel, swaps cache atomically
112+
- `EvaluateFlag()` acquires a pool instance, evaluates, returns it — concurrent up to pool size
113+
- Pre-evaluated flags bypass the pool entirely (lock-free volatile read)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using BenchmarkDotNet.Attributes;
2+
using BenchmarkDotNet.Jobs;
3+
using Eval = FlagdEvaluator;
4+
5+
namespace FlagdEvaluator.Benchmarks;
6+
7+
/// <summary>
8+
/// Comparison benchmarks: old JsonLogic provider (json-everything) vs new WASM evaluator.
9+
///
10+
/// Mirrors the Java ComparisonBenchmark and Go comparison_test.go patterns:
11+
/// - X1: Simple flag (old vs new, single-threaded)
12+
/// - X2: Targeting evaluation (old vs new, single-threaded)
13+
/// - X3: Context size sweep (empty, small, large — old vs new)
14+
/// - X4: Concurrent targeting (4 threads)
15+
/// - X5: Concurrent large context (8 threads)
16+
/// </summary>
17+
[MemoryDiagnoser]
18+
public class ComparisonBenchmarks
19+
{
20+
private Eval.FlagEvaluator _newEvaluator = null!;
21+
private MinimalInProcessResolver _oldResolver = null!;
22+
23+
private Dictionary<string, object?> _emptyCtx = null!;
24+
private Dictionary<string, object?> _smallCtx = null!;
25+
private Dictionary<string, object?> _largeCtx = null!;
26+
27+
private const string FlagConfig = """
28+
{
29+
"flags": {
30+
"simple-bool": {
31+
"state": "ENABLED",
32+
"defaultVariant": "on",
33+
"variants": { "on": true, "off": false }
34+
},
35+
"targeted-access": {
36+
"state": "ENABLED",
37+
"defaultVariant": "denied",
38+
"variants": { "denied": false, "granted": true },
39+
"targeting": {
40+
"if": [
41+
{ "and": [
42+
{ "==": [{ "var": "role" }, "admin"] },
43+
{ "in": [{ "var": "tier" }, ["premium", "enterprise"]] }
44+
]},
45+
"granted", null
46+
]
47+
}
48+
}
49+
}
50+
}
51+
""";
52+
53+
[GlobalSetup]
54+
public void Setup()
55+
{
56+
// New WASM evaluator
57+
_newEvaluator = new Eval.FlagEvaluator(new Eval.FlagEvaluatorOptions
58+
{
59+
PermissiveValidation = true,
60+
});
61+
_newEvaluator.UpdateState(FlagConfig);
62+
63+
// Old JsonLogic resolver
64+
_oldResolver = new MinimalInProcessResolver();
65+
_oldResolver.LoadFlags(FlagConfig);
66+
67+
// Contexts
68+
_emptyCtx = new Dictionary<string, object?>();
69+
70+
_smallCtx = new Dictionary<string, object?>
71+
{
72+
["targetingKey"] = "user-123",
73+
["role"] = "admin",
74+
["tier"] = "premium",
75+
["region"] = "us-east",
76+
["score"] = 85,
77+
};
78+
79+
_largeCtx = new Dictionary<string, object?> { ["targetingKey"] = "user-123" };
80+
_largeCtx["role"] = "admin";
81+
_largeCtx["tier"] = "premium";
82+
_largeCtx["region"] = "us-east";
83+
_largeCtx["score"] = 85;
84+
for (int i = 0; i < 100; i++)
85+
{
86+
object val = (i % 4) switch
87+
{
88+
0 => $"value_{i}",
89+
1 => (object)(i * 7),
90+
2 => (object)(i % 2 == 0),
91+
_ => (object)(i * 1.5),
92+
};
93+
_largeCtx[$"attr_{i}"] = val;
94+
}
95+
}
96+
97+
[GlobalCleanup]
98+
public void Cleanup() => _newEvaluator.Dispose();
99+
100+
// ========================================================================
101+
// X1: Old vs New — Simple flag evaluation (single-threaded)
102+
// ========================================================================
103+
104+
[Benchmark(Description = "X1: Old simple flag")]
105+
public OldEvaluationResult X1_Old_Simple() => _oldResolver.Evaluate("simple-bool", _emptyCtx);
106+
107+
[Benchmark(Description = "X1: New simple flag (pre-eval)")]
108+
public Eval.EvaluationResult X1_New_Simple() => _newEvaluator.EvaluateFlag("simple-bool", _emptyCtx);
109+
110+
// ========================================================================
111+
// X2: Old vs New — Targeting evaluation (single-threaded, small context)
112+
// ========================================================================
113+
114+
[Benchmark(Description = "X2: Old targeting")]
115+
public OldEvaluationResult X2_Old_Targeting() => _oldResolver.Evaluate("targeted-access", _smallCtx);
116+
117+
[Benchmark(Description = "X2: New targeting")]
118+
public Eval.EvaluationResult X2_New_Targeting() => _newEvaluator.EvaluateFlag("targeted-access", _smallCtx);
119+
120+
// ========================================================================
121+
// X3: Context size sweep — Old resolver
122+
// ========================================================================
123+
124+
[Benchmark(Description = "X3: Old empty ctx")]
125+
public OldEvaluationResult X3_Old_EmptyCtx() => _oldResolver.Evaluate("targeted-access", _emptyCtx);
126+
127+
[Benchmark(Description = "X3: Old small ctx")]
128+
public OldEvaluationResult X3_Old_SmallCtx() => _oldResolver.Evaluate("targeted-access", _smallCtx);
129+
130+
[Benchmark(Description = "X3: Old large ctx")]
131+
public OldEvaluationResult X3_Old_LargeCtx() => _oldResolver.Evaluate("targeted-access", _largeCtx);
132+
133+
// ========================================================================
134+
// X3: Context size sweep — New evaluator
135+
// ========================================================================
136+
137+
[Benchmark(Description = "X3: New empty ctx")]
138+
public Eval.EvaluationResult X3_New_EmptyCtx() => _newEvaluator.EvaluateFlag("targeted-access", _emptyCtx);
139+
140+
[Benchmark(Description = "X3: New small ctx")]
141+
public Eval.EvaluationResult X3_New_SmallCtx() => _newEvaluator.EvaluateFlag("targeted-access", _smallCtx);
142+
143+
[Benchmark(Description = "X3: New large ctx")]
144+
public Eval.EvaluationResult X3_New_LargeCtx() => _newEvaluator.EvaluateFlag("targeted-access", _largeCtx);
145+
146+
// ========================================================================
147+
// X4: Concurrent targeting — 4 threads
148+
// ========================================================================
149+
150+
[Benchmark(Description = "X4: Old 4-thread targeting")]
151+
public void X4_Old_Concurrent() => RunConcurrent(4, () => _oldResolver.Evaluate("targeted-access", _smallCtx));
152+
153+
[Benchmark(Description = "X4: New 4-thread targeting")]
154+
public void X4_New_Concurrent() => RunConcurrent(4, () => _newEvaluator.EvaluateFlag("targeted-access", _smallCtx));
155+
156+
// ========================================================================
157+
// X5: Concurrent large context — 8 threads
158+
// ========================================================================
159+
160+
[Benchmark(Description = "X5: Old 8-thread large ctx")]
161+
public void X5_Old_ConcurrentLarge() => RunConcurrent(8, () => _oldResolver.Evaluate("targeted-access", _largeCtx));
162+
163+
[Benchmark(Description = "X5: New 8-thread large ctx")]
164+
public void X5_New_ConcurrentLarge() => RunConcurrent(8, () => _newEvaluator.EvaluateFlag("targeted-access", _largeCtx));
165+
166+
private static void RunConcurrent(int threads, Action action)
167+
{
168+
var tasks = new Task[threads];
169+
for (int i = 0; i < threads; i++)
170+
{
171+
tasks[i] = Task.Run(action);
172+
}
173+
Task.WaitAll(tasks);
174+
}
175+
}

0 commit comments

Comments
 (0)