Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,34 +270,55 @@ At runtime:
## Performance

- **Startup**: WASM module compiled once during class loading (~100ms)
- **Evaluation**: ~13,000 ops/s with realistic layered contexts (JIT compiled)
- **Memory**: ~3MB for WASM module + Chicory runtime
- **Object Churn**: ~12.8 KB allocated per layered evaluation, ~6.9 KB per simple evaluation
- **Static flags**: Near-zero cost via pre-evaluation cache (see below)

### Pre-evaluation Cache (Issue #60)

Static flags (no targeting rules) and disabled flags are pre-evaluated during `updateState()`. Their results are cached on the Java side, so `evaluateFlag()` returns instantly without crossing the WASM boundary. This eliminates the ~4.4µs WASM overhead for the most common flag types.

### WASM vs Native JsonLogic Comparison

JMH benchmark comparing this WASM-based evaluator against a native Java JsonLogic implementation (`json-logic-java`):

| Scenario | Native JsonLogic | WASM Evaluator | Ratio |
|---|---|---|---|
| **Simple flag (no targeting)** | 0.022 µs/op | 4.41 µs/op | ~200x |
| **Targeting match** | 7.85 µs/op | 26.29 µs/op | ~3.4x |
| **Targeting no-match** | 3.55 µs/op | 15.21 µs/op | ~4x |

> **Note**: Simple flags now bypass WASM entirely via the pre-evaluation cache, effectively matching native performance.

**Context size impact** (targeting evaluation with varying context sizes):

| Context Size | Native JsonLogic | WASM Evaluator | Ratio |
|---|---|---|---|
| Empty | 3.55 µs/op | 15.21 µs/op | ~4x |
| Small (5 attributes) | 6.34 µs/op | 27.10 µs/op | ~4x |
| Large (100+ attributes) | 24.02 µs/op | 166.72 µs/op | ~7x |

The WASM overhead comes from JSON serialization across the WASM boundary. For targeting rules with large contexts, serialization dominates the cost.

### Benchmarks

The library includes JMH (Java Microbenchmark Harness) benchmarks for performance tracking:

```bash
# Run JMH benchmarks
# Run comparison benchmark (WASM vs native JsonLogic)
./mvnw exec:java@run-jmh-benchmark -Dbenchmark=ResolverComparisonBenchmark

# Run evaluator benchmarks
./mvnw exec:java@run-jmh-benchmark
```

**Benchmark Results** (example from development machine):
**Evaluator Benchmark Results** (example from development machine):
```
Benchmark Mode Cnt Score Error Units
FlagEvaluatorJmhBenchmark.evaluateWithLayeredContext thrpt 5 13035.383 ± 4173.375 ops/s
FlagEvaluatorJmhBenchmark.evaluateWithSimpleContext thrpt 5 14748.099 ± 2689.011 ops/s
FlagEvaluatorJmhBenchmark.serializeLayeredContext thrpt 5 222863.374 ± 151002.720 ops/s
```

**Object Churn (GC allocation rates)**:
```
Benchmark Alloc Rate Alloc/Op
evaluateWithLayeredContext 159.6 MB/sec 12.8 KB/op
evaluateWithSimpleContext 97.8 MB/sec 6.9 KB/op
```

**Benchmark Scenarios:**
- **evaluateWithLayeredContext**: Full flag evaluation with 4-layer context (API, Transaction, Client, Invocation) and 100+ entries per layer
- **evaluateWithSimpleContext**: Baseline evaluation with minimal context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -93,6 +94,9 @@ public class FlagEvaluator implements AutoCloseable {
private final long flagKeyBufferPtr;
private final long contextBufferPtr;

// Cache of pre-evaluated results for static/disabled flags (replaced atomically on updateState)
private volatile Map<String, EvaluationResult<Object>> preEvaluatedCache = Collections.emptyMap();

/**
* Creates a new flag evaluator with strict validation mode.
*
Expand Down Expand Up @@ -160,7 +164,13 @@ public synchronized UpdateStateResult updateState(String jsonConfig) throws Eval

String resultJson = memory.readString(resultPtr, resultLen);

return OBJECT_MAPPER.readValue(resultJson, UpdateStateResult.class);
UpdateStateResult result = OBJECT_MAPPER.readValue(resultJson, UpdateStateResult.class);

// Update the pre-evaluated cache (atomic replacement)
Map<String, EvaluationResult<Object>> preEval = result.getPreEvaluated();
this.preEvaluatedCache = (preEval != null) ? preEval : Collections.emptyMap();

return result;
} catch (Exception e) {
throw new EvaluatorException("Failed to update state", e);
} finally {
Expand Down Expand Up @@ -199,7 +209,14 @@ public synchronized UpdateStateResult updateState(String jsonConfig) throws Eval
* @return the evaluation result containing value, variant, reason, and metadata
* @throws EvaluatorException if the evaluation fails
*/
@SuppressWarnings("unchecked")
public synchronized <T> EvaluationResult<T> evaluateFlag(Class<T> type, String flagKey, String contextJson) throws EvaluatorException {
// Fast path: return cached result for static/disabled flags
EvaluationResult<Object> cached = preEvaluatedCache.get(flagKey);
if (cached != null) {
return (EvaluationResult<T>) (EvaluationResult<?>) cached;
}

byte[] flagBytes = flagKey.getBytes(StandardCharsets.UTF_8);
if (flagBytes.length > MAX_FLAG_KEY_SIZE) {
throw new EvaluatorException("Flag key exceeds maximum size of " + MAX_FLAG_KEY_SIZE + " bytes");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;

/**
* Result of updating flag state.
Expand All @@ -15,6 +16,8 @@ public class UpdateStateResult {

private List<String> changedFlags;

private Map<String, EvaluationResult<Object>> preEvaluated;

public UpdateStateResult() {
}

Expand Down Expand Up @@ -59,6 +62,22 @@ public void setChangedFlags(List<String> changedFlags) {
this.changedFlags = changedFlags;
}

/**
* Gets the pre-evaluated results for static and disabled flags.
*
* <p>These flags don't require targeting evaluation, so their results are
* computed during {@code updateState()} to allow host-side caching.
*
* @return map of flag key to pre-evaluated result, or null if none
*/
public Map<String, EvaluationResult<Object>> getPreEvaluated() {
return preEvaluated;
}

public void setPreEvaluated(Map<String, EvaluationResult<Object>> preEvaluated) {
this.preEvaluated = preEvaluated;
}

@Override
public String toString() {
return "UpdateStateResult{" +
Expand Down
55 changes: 55 additions & 0 deletions src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ impl FlagEvaluator {
success: false,
error: Some(validation_error.to_json_string()),
changed_flags: None,
pre_evaluated: None,
});
}
}
Expand All @@ -131,20 +132,29 @@ impl FlagEvaluator {
success: false,
error: Some(e),
changed_flags: None,
pre_evaluated: None,
});
}
};

// Detect changed flags
let changed_flags = self.detect_changed_flags(&new_parsing_result);

// Pre-evaluate static and disabled flags (no targeting rules needed)
let pre_evaluated = self.pre_evaluate_static_flags(&new_parsing_result);

// Store the new state
self.state = Some(new_parsing_result);

Ok(UpdateStateResponse {
success: true,
error: None,
changed_flags: Some(changed_flags),
pre_evaluated: if pre_evaluated.is_empty() {
None
} else {
Some(pre_evaluated)
},
})
}

Expand Down Expand Up @@ -507,6 +517,51 @@ impl FlagEvaluator {
// Helper methods
// =========================================================================

/// Pre-evaluates static and disabled flags that don't require targeting evaluation.
///
/// These results can be cached by the host (e.g., Java) to skip the WASM boundary
/// entirely for flags that always return the same result regardless of context.
fn pre_evaluate_static_flags(
&self,
parsing_result: &ParsingResult,
) -> HashMap<String, EvaluationResult> {
let empty_context = JsonValue::Object(Map::new());
let mut results = HashMap::new();

for (flag_key, flag) in &parsing_result.flags {
// Pre-evaluate disabled flags
if flag.state == "DISABLED" {
let result = self.evaluate_flag_internal(
flag,
flag_key,
&empty_context,
&parsing_result.flag_set_metadata,
);
results.insert(flag_key.clone(), result);
continue;
}

// Pre-evaluate static flags (no targeting rules)
let is_static = match &flag.targeting {
None => true,
Some(JsonValue::Object(map)) if map.is_empty() => true,
_ => false,
};

if is_static {
let result = self.evaluate_flag_internal(
flag,
flag_key,
&empty_context,
&parsing_result.flag_set_metadata,
);
results.insert(flag_key.clone(), result);
}
}

results
}

/// Detects which flags have changed between the current and new state.
fn detect_changed_flags(&self, new_state: &ParsingResult) -> Vec<String> {
let mut changed_keys = HashSet::new();
Expand Down
10 changes: 10 additions & 0 deletions src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ mod feature_flag;

pub use feature_flag::{FeatureFlag, ParsingResult};

use crate::types::EvaluationResult;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Response from updating flag state indicating which flags have changed.
///
Expand All @@ -25,4 +27,12 @@ pub struct UpdateStateResponse {
/// List of flag keys that were changed (added, removed, or mutated)
#[serde(skip_serializing_if = "Option::is_none")]
pub changed_flags: Option<Vec<String>>,

/// Pre-evaluated results for static and disabled flags.
///
/// These flags don't require targeting evaluation, so their results are
/// computed during `update_state()` to allow host-side caching and avoid
/// WASM boundary overhead on every evaluation call.
#[serde(skip_serializing_if = "Option::is_none")]
pub pre_evaluated: Option<HashMap<String, EvaluationResult>>,
}
Loading