diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 6ea0b2a..d5bead3 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -14,27 +14,55 @@ concurrency:
cancel-in-progress: true
jobs:
- checks:
+ java:
+ name: Java ${{ matrix.java }}
runs-on: ubuntu-latest
timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - java: "11"
+ goal: "-Dmaven.test.skip=true clean package"
+ - java: "25"
+ goal: "clean verify"
steps:
- name: Check out repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up Java
uses: actions/setup-java@v5
with:
- java-version: "24"
+ java-version: ${{ matrix.java }}
distribution: temurin
cache: maven
- name: Build and test Maven reactor
- run: mvn --batch-mode clean verify
+ run: mvn --batch-mode ${{ matrix.goal }}
- name: Verify published artifact boundaries
run: bash scripts/verify-artifacts.sh
+ example:
+ name: Featurevisor example-1
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up Java
+ uses: actions/setup-java@v5
+ with:
+ java-version: "25"
+ distribution: temurin
+ cache: maven
+
+ - name: Build SDK
+ run: mvn --batch-mode clean install
+
- name: Set up Node.js
uses: actions/setup-node@v7
with:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index f93e356..36d8519 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -20,12 +20,12 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up Java and Maven publishing
uses: actions/setup-java@v5
with:
- java-version: "24"
+ java-version: "25"
distribution: temurin
cache: maven
server-id: github
@@ -36,14 +36,40 @@ jobs:
id: version
shell: bash
run: |
+ if [[ ! "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+ echo "Expected a Featurevisor Java v3 semantic version tag" >&2
+ exit 1
+ fi
version="${GITHUB_REF_NAME#v}"
+ if ! grep -Fq "$version" pom.xml; then
+ echo "Tag $GITHUB_REF_NAME does not match the Maven revision" >&2
+ exit 1
+ fi
+ if ! grep -Fq "$version" README.md; then
+ echo "README installation version does not match $GITHUB_REF_NAME" >&2
+ exit 1
+ fi
+ if ! grep -Fq "version = \"$version\"" featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java; then
+ echo "CLI version does not match $GITHUB_REF_NAME" >&2
+ exit 1
+ fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing Featurevisor Java $version"
- - name: Build, test, and publish all artifacts
+ - name: Build and test all artifacts
+ run: >-
+ mvn --batch-mode
+ -Drevision=${{ steps.version.outputs.version }}
+ clean verify
+
+ - name: Verify published artifact boundaries
+ run: bash scripts/verify-artifacts.sh
+
+ - name: Publish all artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
mvn --batch-mode
-Drevision=${{ steps.version.outputs.version }}
- clean deploy
+ -DskipTests
+ deploy
diff --git a/README.md b/README.md
index 79fb3b4..4828477 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ Add the Featurevisor Java SDK as a dependency with your desired version:
com.featurevisor
featurevisor-java
- 0.1.0
+ 3.0.0
```
@@ -133,6 +133,8 @@ Featurevisor f = Featurevisor.createFeaturevisor(
Most applications only need `Featurevisor.createFeaturevisor`, the `Featurevisor` instance type, and `Featurevisor.FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types.
+Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `setDatafile`, `setContext`, `setSticky`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
+
## Initialization
The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
@@ -572,7 +574,7 @@ You can listen to these events that can occur at various stages in your applicat
### `datafile_set`
```java
-Runnable unsubscribe = f.on("datafile_set", (event) -> {
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, (event) -> {
String revision = (String) event.get("revision"); // new revision
String previousRevision = (String) event.get("previousRevision");
Boolean revisionChanged = (Boolean) event.get("revisionChanged"); // true if revision has changed
@@ -586,7 +588,7 @@ Runnable unsubscribe = f.on("datafile_set", (event) -> {
});
// stop listening to the event
-unsubscribe.run();
+unsubscribe.unsubscribe();
```
The `features` array will contain keys of features that have either been:
@@ -600,7 +602,7 @@ compared to the previous datafile content that existed in the SDK instance.
### `context_set`
```java
-Runnable unsubscribe = f.on("context_set", (event) -> {
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.CONTEXT_SET, (event) -> {
Boolean replaced = (Boolean) event.get("replaced"); // true if context was replaced
@SuppressWarnings("unchecked")
Map context = (Map) event.get("context"); // the new context
@@ -612,7 +614,7 @@ Runnable unsubscribe = f.on("context_set", (event) -> {
### `sticky_set`
```java
-Runnable unsubscribe = f.on("sticky_set", (event) -> {
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_SET, (event) -> {
Boolean replaced = (Boolean) event.get("replaced"); // true if sticky features got replaced
@SuppressWarnings("unchecked")
List features = (List) event.get("features"); // list of all affected feature keys
@@ -624,7 +626,7 @@ Runnable unsubscribe = f.on("sticky_set", (event) -> {
### `error`
```java
-Emitter.UnsubscribeFunction unsubscribe = f.on(Emitter.EventName.ERROR, (event) -> {
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.ERROR, (event) -> {
FeaturevisorDiagnostic diagnostic = (FeaturevisorDiagnostic) event.get("diagnostic");
System.err.println(diagnostic.getMessage());
});
@@ -638,16 +640,16 @@ Besides logging with debug level enabled, you can also get more details about ho
```java
// flag
-Map evaluation = f.evaluateFlag(featureKey, context);
+Evaluation evaluation = f.evaluateFlag(featureKey, context);
// variation
-Map evaluation = f.evaluateVariation(featureKey, context);
+Evaluation evaluation = f.evaluateVariation(featureKey, context);
// variable
-Map evaluation = f.evaluateVariable(featureKey, variableKey, context);
+Evaluation evaluation = f.evaluateVariable(featureKey, variableKey, context);
```
-The returned object will always contain the following properties:
+The returned `Evaluation` exposes the following properties:
- `featureKey`: the feature key
- `reason`: the reason how the value was evaluated
@@ -749,6 +751,8 @@ FeaturevisorModule module = new FeaturevisorModule("diagnostic-module")
## Child instance
+A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close()` removes both child-owned listeners and subscriptions delegated to the parent.
+
When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications.
But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request.
@@ -774,8 +778,11 @@ Similar to parent SDK, child instances also support several additional methods:
- `setContext`
- `setSticky`
+- `evaluateFlag`
- `isEnabled`
+- `evaluateVariation`
- `getVariation`
+- `evaluateVariable`
- `getVariable`
- `getVariableBoolean`
- `getVariableString`
@@ -916,9 +923,10 @@ $ make verify-artifacts
### Releasing
-- Manually create a new release on [GitHub](https://github.com/featurevisor/featurevisor-java/releases)
-- Tag it with a prefix of `v`, like `v1.0.0`
-- GitHub Actions publishes the parent POM, Java SDK, and OpenFeature provider to [GitHub Packages](https://github.com/orgs/featurevisor/packages?repo_name=featurevisor-java)
+1. Merge the release changes into `main`.
+2. Tag the release with a `v` prefix, such as `v3.0.0`, and push the tag.
+3. GitHub Actions verifies and publishes the parent POM, Java SDK, and OpenFeature provider to [GitHub Packages](https://github.com/orgs/featurevisor/packages?repo_name=featurevisor-java).
+4. Create the corresponding [GitHub release](https://github.com/featurevisor/featurevisor-java/releases).
## License
diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json
index ceae692..49396ce 100644
--- a/conformance/sdk-v3.json
+++ b/conformance/sdk-v3.json
@@ -1,5 +1,5 @@
{
- "version": 1,
+ "version": 2,
"description": "Featurevisor v3 cross SDK compatibility contracts",
"bucketing": {
"minimum": 0,
@@ -26,7 +26,47 @@
"pattern": "chrome",
"flags": "g",
"values": ["chrome", "chrome", "firefox", "chrome"],
- "matches": [true, true, false, true]
+ "matches": [true, true, false, true],
+ "portableCases": [
+ {
+ "pattern": "^chrome$",
+ "flags": "",
+ "value": "chrome",
+ "expected": true
+ },
+ {
+ "pattern": "^(chrome|firefox)$",
+ "flags": "i",
+ "value": "Firefox",
+ "expected": true
+ },
+ {
+ "pattern": "^second$",
+ "flags": "m",
+ "value": "first\nsecond",
+ "expected": true
+ },
+ {
+ "pattern": "first.*second",
+ "flags": "s",
+ "value": "first\nsecond",
+ "expected": true
+ },
+ {
+ "pattern": "\\(literal\\)",
+ "flags": "g",
+ "value": "(literal)",
+ "expected": true
+ }
+ ],
+ "rejectedSyntax": [
+ "foo(?=bar)",
+ "(?<=foo)bar",
+ "(?:foo|bar)",
+ "(?foo)",
+ "(foo)\\1",
+ "foo++"
+ ]
},
"typedVariables": [
{ "type": "integer", "value": 1, "valid": true },
@@ -44,6 +84,135 @@
"diagnostics": {
"requiredFields": ["level", "code", "message", "details"],
"detailsType": "object",
- "emptyDetailsJson": "{}"
+ "emptyDetailsJson": "{}",
+ "evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"],
+ "moduleEnvelopeFields": ["module", "moduleName", "originalError"],
+ "errorEventLevels": ["error"]
+ },
+ "numericBucketKeys": [
+ { "value": 1.2345678901234567, "expected": "1.2345678901234567" },
+ { "value": 0.30000000000000004, "expected": "0.30000000000000004" },
+ { "value": 0.000001, "expected": "0.000001" },
+ { "value": 1e-7, "expected": "1e-7" },
+ { "value": 100000000000000000000, "expected": "100000000000000000000" },
+ { "value": 1e21, "expected": "1e+21" }
+ ],
+ "portableConditions": {
+ "regexFlags": ["g", "i", "m", "s"],
+ "rejectedRegexFlags": ["d", "u", "v", "y"],
+ "dateFormat": "ISO 8601 with an explicit timezone",
+ "dates": [
+ "2024-01-01T00:00:00Z",
+ "2024-01-01T01:00:00+01:00",
+ "2024-01-01T00:00:00.250Z",
+ "2024-01-01T01:00:00.250+01:00"
+ ],
+ "semanticVersions": [
+ "1.2.3",
+ "1.2.3-beta.1",
+ "1.2.3+build.5"
+ ],
+ "invalidSemanticVersion": "invalid",
+ "invalidSemanticVersionDiagnosticCode": "condition_match_error"
+ },
+ "conditionCases": [
+ {
+ "name": "strict primitive equality",
+ "condition": {
+ "attribute": "value",
+ "operator": "equals",
+ "value": 1
+ },
+ "context": { "value": "1" },
+ "expected": false
+ },
+ {
+ "name": "not negates implicit and",
+ "condition": {
+ "not": [
+ { "attribute": "country", "operator": "equals", "value": "us" },
+ { "attribute": "device", "operator": "equals", "value": "mobile" }
+ ]
+ },
+ "context": { "country": "us", "device": "desktop" },
+ "expected": true
+ },
+ {
+ "name": "not with nested or means none match",
+ "condition": {
+ "not": [
+ {
+ "or": [
+ { "attribute": "country", "operator": "equals", "value": "us" },
+ { "attribute": "country", "operator": "equals", "value": "nl" }
+ ]
+ }
+ ]
+ },
+ "context": { "country": "de" },
+ "expected": true
+ },
+ {
+ "name": "empty not fails defensively",
+ "condition": { "not": [] },
+ "context": {},
+ "expected": false
+ },
+ {
+ "name": "fractional ISO date with offset",
+ "condition": {
+ "attribute": "date",
+ "operator": "before",
+ "value": "2024-01-01T00:00:00.500Z"
+ },
+ "context": { "date": "2024-01-01T01:00:00.250+01:00" },
+ "expected": true
+ }
+ ],
+ "childInstances": {
+ "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win",
+ "closeRemovesLocalAndDelegatedSubscriptions": true,
+ "detailedEvaluationMethods": ["flag", "variation", "variable"],
+ "contextCase": {
+ "parentAtSpawn": { "country": "nl", "plan": "free" },
+ "child": { "country": "de" },
+ "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" },
+ "expected": { "country": "de", "plan": "free", "region": "eu" }
+ }
+ },
+ "defaults": {
+ "presenceBased": true,
+ "values": ["", 0, false, null],
+ "aggregateEvaluationPreservesEmptyVariation": true,
+ "aggregateCase": {
+ "datafile": {
+ "schemaVersion": "2",
+ "revision": "defaults",
+ "segments": {},
+ "features": {
+ "experiment": {
+ "key": "experiment",
+ "bucketBy": "userId",
+ "variations": [{ "value": "control" }],
+ "traffic": []
+ }
+ }
+ },
+ "defaultVariationValue": "",
+ "expected": {
+ "enabled": false,
+ "variation": ""
+ }
+ }
+ },
+ "diagnosticCase": {
+ "featureKey": "missing",
+ "expectedLevel": "warn",
+ "expectedCode": "feature_not_found",
+ "detailsMustBeObject": true
+ },
+ "nativeContexts": {
+ "numericTypesUseOneComparisonContract": true,
+ "primitiveNativeSlicesSupportIncludes": true
}
}
diff --git a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
index c282e37..cf211e0 100644
--- a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
+++ b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
@@ -2,9 +2,10 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.featurevisor.sdk.Evaluation;
-import com.featurevisor.sdk.Emitter;
import com.featurevisor.sdk.Featurevisor;
import com.featurevisor.sdk.FeaturevisorDiagnosticHandler;
+import com.featurevisor.sdk.FeaturevisorEventName;
+import com.featurevisor.sdk.FeaturevisorUnsubscribe;
import com.featurevisor.sdk.VariableType;
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
@@ -52,7 +53,7 @@ public static final class Options {
private final String keySeparator;
private final String variationKey;
private final TrackingHandler onTrack;
- private final Emitter.UnsubscribeFunction datafileUnsubscribe;
+ private final FeaturevisorUnsubscribe datafileUnsubscribe;
private final boolean ownsFeaturevisor;
private String datafileError;
@@ -83,7 +84,7 @@ public FeaturevisorOpenFeatureProvider(Options options) {
});
this.featurevisor = Featurevisor.createFeaturevisor(fvOptions);
}
- this.datafileUnsubscribe = this.featurevisor.on(Emitter.EventName.DATAFILE_SET, details -> datafileError = null);
+ this.datafileUnsubscribe = this.featurevisor.on(FeaturevisorEventName.DATAFILE_SET, details -> datafileError = null);
}
public FeaturevisorOpenFeatureProvider(Featurevisor.FeaturevisorOptions options) {
@@ -221,20 +222,22 @@ private static boolean matches(Object value, String expected) {
return value instanceof Map || value instanceof List;
}
- private static Map normalizeMap(Map input) {
+ private static Map normalizeMap(Map, ?> input) {
Map result = new HashMap<>();
- input.forEach((key, value) -> result.put(key, normalize(value)));
+ input.forEach((key, value) -> {
+ if (key instanceof String) result.put((String) key, normalize(value));
+ });
return result;
}
private static Object normalize(Object value) {
if (value instanceof Instant) return value.toString();
- if (value instanceof Map) return normalizeMap((Map) value);
+ if (value instanceof Map) return normalizeMap((Map, ?>) value);
if (value instanceof List) { List