From 7953896b70a3e0f0f2c738679f91d1af3fc56181 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 7 Feb 2018 12:46:53 -0800 Subject: [PATCH 01/15] basic unit tests for flag evaluation --- src/main/java/Hello.java | 32 +++ .../java/com/launchdarkly/client/Clause.java | 9 + .../client/FeatureFlagBuilder.java | 5 + .../launchdarkly/client/FeatureFlagTest.java | 267 ++++++++++++++---- .../com/launchdarkly/client/TestUtil.java | 35 +++ 5 files changed, 293 insertions(+), 55 deletions(-) create mode 100644 src/main/java/Hello.java create mode 100644 src/test/java/com/launchdarkly/client/TestUtil.java diff --git a/src/main/java/Hello.java b/src/main/java/Hello.java new file mode 100644 index 000000000..5f0c7a96b --- /dev/null +++ b/src/main/java/Hello.java @@ -0,0 +1,32 @@ +import com.launchdarkly.client.LDClient; +import com.launchdarkly.client.LDUser; + +import java.io.IOException; + +import static java.util.Collections.singletonList; + +public class Hello { + + public static void main(String... args) throws IOException { + LDClient client = new LDClient("sdk-03947004-7d32-4878-a80b-ade2314efece"); + + LDUser user = new LDUser.Builder("bob@example.com") + .firstName("Bob") + .lastName("Loblaw") + .customString("groups", singletonList("beta_testers")) + .build(); + + boolean showFeature = client.boolVariation("new.dashboard", user, false); + + if (showFeature) { + System.out.println("Showing your feature"); + } else { + System.out.println("Not showing your feature"); + } + + client.flush(); + client.close(); + + System.out.println("bye bye"); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 53c315bb5..eb0a4aae0 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -16,6 +16,15 @@ class Clause { private List values; //interpreted as an OR of values private boolean negate; + public Clause() { } + + public Clause(String attribute, Operator op, List values, boolean negate) { + this.attribute = attribute; + this.op = op; + this.values = values; + this.negate = negate; + } + boolean matchesUser(LDUser user) { JsonElement userValue = user.getValueForEvaluation(attribute); if (userValue == null) { diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index 120764862..d06c903d6 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -3,6 +3,7 @@ import com.google.gson.JsonElement; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; class FeatureFlagBuilder { @@ -84,6 +85,10 @@ FeatureFlagBuilder variations(List variations) { return this; } + FeatureFlagBuilder variations(JsonElement... variations) { + return variations(Arrays.asList(variations)); + } + FeatureFlagBuilder deleted(boolean deleted) { this.deleted = deleted; return this; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 4c27d6f1d..d1460eeb6 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -1,18 +1,21 @@ package com.launchdarkly.client; - -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import java.util.Arrays; -import static java.util.Collections.singletonList; +import static com.launchdarkly.client.TestUtil.fallthroughVariation; +import static com.launchdarkly.client.TestUtil.jbool; +import static com.launchdarkly.client.TestUtil.jint; +import static com.launchdarkly.client.TestUtil.js; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; public class FeatureFlagTest { + private static LDUser BASE_USER = new LDUser.Builder("x").build(); + private FeatureStore featureStore; @Before @@ -21,65 +24,219 @@ public void before() { } @Test - public void testPrereqDoesNotExist() throws EvaluationException { - String keyA = "keyA"; - String keyB = "keyB"; - FeatureFlag f1 = newFlagWithPrereq(keyA, keyB); - - featureStore.upsert(f1.getKey(), f1); - LDUser user = new LDUser.Builder("userKey").build(); - FeatureFlag.EvalResult actual = f1.evaluate(user, featureStore); - - Assert.assertNull(actual.getValue()); - Assert.assertNotNull(actual.getPrerequisiteEvents()); - Assert.assertEquals(0, actual.getPrerequisiteEvents().size()); + public void flagReturnsOffVariationIfFlagIsOff() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(false) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore); + + assertEquals(js("off"), result.getValue()); + assertEquals(0, result.getPrerequisiteEvents().size()); } - + @Test - public void testPrereqCollectsEventsForPrereqs() throws EvaluationException { - String keyA = "keyA"; - String keyB = "keyB"; - String keyC = "keyC"; - FeatureFlag flagA = newFlagWithPrereq(keyA, keyB); - FeatureFlag flagB = newFlagWithPrereq(keyB, keyC); - FeatureFlag flagC = newFlagOff(keyC); - - featureStore.upsert(flagA.getKey(), flagA); - featureStore.upsert(flagB.getKey(), flagB); - featureStore.upsert(flagC.getKey(), flagC); - - LDUser user = new LDUser.Builder("userKey").build(); - - FeatureFlag.EvalResult flagAResult = flagA.evaluate(user, featureStore); - Assert.assertNotNull(flagAResult); - Assert.assertNull(flagAResult.getValue()); - Assert.assertEquals(2, flagAResult.getPrerequisiteEvents().size()); - - FeatureFlag.EvalResult flagBResult = flagB.evaluate(user, featureStore); - Assert.assertNotNull(flagBResult); - Assert.assertNull(flagBResult.getValue()); - Assert.assertEquals(1, flagBResult.getPrerequisiteEvents().size()); + public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(false) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore); + + assertNull(result.getValue()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore); + + assertEquals(js("off"), result.getValue()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } - FeatureFlag.EvalResult flagCResult = flagC.evaluate(user, featureStore); - Assert.assertNotNull(flagCResult); - Assert.assertEquals(null, flagCResult.getValue()); - Assert.assertEquals(0, flagCResult.getPrerequisiteEvents().size()); + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(0)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + featureStore.upsert(f1.getKey(), f1); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore); + + assertEquals(js("off"), result.getValue()); + + assertEquals(1, result.getPrerequisiteEvents().size()); + FeatureRequestEvent event = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event.key); + assertEquals("feature", event.kind); + assertEquals(js("nogo"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); } - private FeatureFlag newFlagWithPrereq(String featureKey, String prereqKey) { - return new FeatureFlagBuilder(featureKey) - .prerequisites(singletonList(new Prerequisite(prereqKey, 0))) - .variations(Arrays.asList(new JsonPrimitive(0), new JsonPrimitive(1))) - .fallthrough(new VariationOrRollout(0, null)) + @Test + public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(2) .build(); + featureStore.upsert(f1.getKey(), f1); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore); + + assertEquals(js("fall"), result.getValue()); + assertEquals(1, result.getPrerequisiteEvents().size()); + + FeatureRequestEvent event = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event.key); + assertEquals("feature", event.kind); + assertEquals(js("go"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); } - private FeatureFlag newFlagOff(String featureKey) { - return new FeatureFlagBuilder(featureKey) - .variations(Arrays.asList(new JsonPrimitive(0), new JsonPrimitive(1))) - .fallthrough(new VariationOrRollout(0, null)) - .on(false) + @Test + public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature2", 1))) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + FeatureFlag f2 = new FeatureFlagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(3) + .build(); + featureStore.upsert(f1.getKey(), f1); + featureStore.upsert(f2.getKey(), f2); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore); + + assertEquals(js("fall"), result.getValue()); + assertEquals(2, result.getPrerequisiteEvents().size()); + + FeatureRequestEvent event0 = result.getPrerequisiteEvents().get(0); + assertEquals(f2.getKey(), event0.key); + assertEquals("feature", event0.kind); + assertEquals(js("go"), event0.value); + assertEquals(f2.getVersion(), event0.version.intValue()); + assertEquals(f1.getKey(), event0.prereqOf); + + FeatureRequestEvent event1 = result.getPrerequisiteEvents().get(1); + assertEquals(f1.getKey(), event1.key); + assertEquals("feature", event1.kind); + assertEquals(js("go"), event1.value); + assertEquals(f1.getVersion(), event1.version.intValue()); + assertEquals(f0.getKey(), event1.prereqOf); + } + + @Test + public void flagMatchesUserFromTargets() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .targets(Arrays.asList(new Target(Arrays.asList("whoever", "userkey"), 2))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) .build(); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore); + + assertEquals(js("on"), result.getValue()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagMatchesUserFromRules() throws Exception { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule(Arrays.asList(clause), 2, null); + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .rules(Arrays.asList(rule)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .build(); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore); + + assertEquals(js("on"), result.getValue()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void clauseCanMatchBuiltInAttribute() throws Exception { + Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); + FeatureFlag f = TestUtil.booleanFlagWithClauses(clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(jbool(true), f.evaluate(user, featureStore).getValue()); + } + + @Test + public void clauseCanMatchCustomAttribute() throws Exception { + Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); + FeatureFlag f = TestUtil.booleanFlagWithClauses(clause); + LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); + + assertEquals(jbool(true), f.evaluate(user, featureStore).getValue()); + } + + @Test + public void clauseReturnsFalseForMissingAttribute() throws Exception { + Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); + FeatureFlag f = TestUtil.booleanFlagWithClauses(clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(jbool(false), f.evaluate(user, featureStore).getValue()); + } + + @Test + public void clauseCanBeNegated() throws Exception { + Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), true); + FeatureFlag f = TestUtil.booleanFlagWithClauses(clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(jbool(false), f.evaluate(user, featureStore).getValue()); } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java new file mode 100644 index 000000000..af0c39ad9 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -0,0 +1,35 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonPrimitive; + +import java.util.Arrays; + +public class TestUtil { + + public static JsonPrimitive js(String s) { + return new JsonPrimitive(s); + } + + public static JsonPrimitive jint(int n) { + return new JsonPrimitive(n); + } + + public static JsonPrimitive jbool(boolean b) { + return new JsonPrimitive(b); + } + + public static VariationOrRollout fallthroughVariation(int variation) { + return new VariationOrRollout(variation, null); + } + + public static FeatureFlag booleanFlagWithClauses(Clause... clauses) { + Rule rule = new Rule(Arrays.asList(clauses), 1, null); + return new FeatureFlagBuilder("feature") + .on(true) + .rules(Arrays.asList(rule)) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(jbool(false), jbool(true)) + .build(); + } +} From da077e71898ef955d1e812fb1c240b1cc2506d65 Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Thu, 1 Mar 2018 14:37:48 -0800 Subject: [PATCH 02/15] prepare 2.6.1 release (#118) --- .../java/com/launchdarkly/client/LDUser.java | 10 +++--- .../com/launchdarkly/client/LDUserTest.java | 36 ++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 95209dc90..d0d1c4cf0 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -71,11 +71,13 @@ public LDUser(String key) { } protected JsonElement getValueForEvaluation(String attribute) { - try { - return UserAttribute.valueOf(attribute).get(this); - } catch (IllegalArgumentException expected) { - return getCustom(attribute); + // Don't use Enum.valueOf because we don't want to trigger unnecessary exceptions + for (UserAttribute builtIn: UserAttribute.values()) { + if (builtIn.name().equals(attribute)) { + return builtIn.get(this); + } } + return getCustom(attribute); } JsonPrimitive getKey() { diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 085476034..f80184dce 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -4,6 +4,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.google.gson.reflect.TypeToken; + +import org.junit.Assert; import org.junit.Test; import java.lang.reflect.Type; @@ -198,6 +200,38 @@ public void testLDUserCustomMarshalWithBuiltInAttributesRedactsCorrectAttrs() { Type type = new TypeToken>(){}.getType(); Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); assertNull(privateJson.get("email")); - + } + + @Test + public void getValueGetsBuiltInAttribute() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .build(); + assertEquals(new JsonPrimitive("Jane"), user.getValueForEvaluation("name")); + } + + @Test + public void getValueGetsCustomAttribute() { + LDUser user = new LDUser.Builder("key") + .custom("height", 5) + .build(); + assertEquals(new JsonPrimitive(5), user.getValueForEvaluation("height")); + } + + @Test + public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .custom("name", "Joan") + .build(); + assertEquals(new JsonPrimitive("Jane"), user.getValueForEvaluation("name")); + } + + @Test + public void getValueReturnsNullIfNotFound() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .build(); + assertNull(user.getValueForEvaluation("height")); } } From 999ff4122f0a9dc509ea6e430e69cc38a5e4400a Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Thu, 1 Mar 2018 14:51:31 -0800 Subject: [PATCH 03/15] Update Changelog for release of version 2.6.1 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc035e63..bb0a9490b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.6.1] - 2018-03-01 +### Fixed +- Improved performance when evaluating flags with custom attributes, by avoiding an unnecessary caught exception (thanks, [rbalamohan](https://github.com/launchdarkly/java-client/issues/113)). + + ## [2.6.0] - 2018-02-12 ## Added - Adds support for a future LaunchDarkly feature, coming soon: semantic version user attributes. From 4cff700ffaef7e066b8eb8fe5a922674fc5f320f Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Thu, 1 Mar 2018 14:52:21 -0800 Subject: [PATCH 04/15] version 2.6.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 95c8348c2..bfba5b77c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=2.6.0 +version=2.6.1 ossrhUsername= ossrhPassword= From 2e1584bed4d26da2a54c1572adf927abbe6c7cf8 Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Thu, 1 Mar 2018 14:52:48 -0800 Subject: [PATCH 05/15] Preparing for release of version 2.6.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5c6c53c5..39943f1ac 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Quick setup com.launchdarkly launchdarkly-client - 2.6.0 + 2.6.1 1. Import the LaunchDarkly package: From 8a8f0c6a4d45bc33556ed14cd6f3df753a77f328 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 1 Mar 2018 15:59:56 -0800 Subject: [PATCH 06/15] fix error in javadoc code sample --- .../java/com/launchdarkly/client/RedisFeatureStoreBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 2d75b2686..bc82c704d 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -16,7 +16,7 @@ * {@link RedisFeatureStoreBuilder} calls can be chained, enabling the following pattern: * *
- * RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder("host", 443, 60)
+ * RedisFeatureStore store = new RedisFeatureStoreBuilder("host", 443, 60)
  *      .refreshStaleValues(true)
  *      .asyncRefresh(true)
  *      .socketTimeout(200)

From 0c79ac2a6cd9c0405795cdbb57e53c3fa987e9c5 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Fri, 9 Mar 2018 16:20:33 -0800
Subject: [PATCH 07/15] rm test file

---
 src/main/java/Hello.java | 32 --------------------------------
 1 file changed, 32 deletions(-)
 delete mode 100644 src/main/java/Hello.java

diff --git a/src/main/java/Hello.java b/src/main/java/Hello.java
deleted file mode 100644
index 5f0c7a96b..000000000
--- a/src/main/java/Hello.java
+++ /dev/null
@@ -1,32 +0,0 @@
-import com.launchdarkly.client.LDClient;
-import com.launchdarkly.client.LDUser;
-
-import java.io.IOException;
-
-import static java.util.Collections.singletonList;
-
-public class Hello {
-
- public static void main(String... args) throws IOException {
-   LDClient client = new LDClient("sdk-03947004-7d32-4878-a80b-ade2314efece");
-
-   LDUser user = new LDUser.Builder("bob@example.com")
-                           .firstName("Bob")
-                           .lastName("Loblaw")
-                           .customString("groups", singletonList("beta_testers"))
-                           .build();
-
-   boolean showFeature = client.boolVariation("new.dashboard", user, false);
-
-   if (showFeature) {
-    System.out.println("Showing your feature");
-   } else {
-    System.out.println("Not showing your feature");
-   }
-
-   client.flush();
-   client.close();
-   
-   System.out.println("bye bye");
- }
-}
\ No newline at end of file

From be75e5f8a3a5bd00e1eaab20b2e7d2c971bf19e2 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Fri, 9 Mar 2018 16:30:30 -0800
Subject: [PATCH 08/15] misc cleanup

---
 .../launchdarkly/client/FeatureFlagTest.java  | 27 +++++++------------
 1 file changed, 9 insertions(+), 18 deletions(-)

diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
index 6186f9e65..6959c41ff 100644
--- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
+++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
@@ -1,14 +1,11 @@
 package com.launchdarkly.client;
 
-import com.google.gson.JsonElement;
-import com.google.gson.JsonPrimitive;
-
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.util.Arrays;
 
+import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses;
 import static com.launchdarkly.client.TestUtil.fallthroughVariation;
 import static com.launchdarkly.client.TestUtil.jbool;
 import static com.launchdarkly.client.TestUtil.jint;
@@ -213,7 +210,7 @@ public void flagMatchesUserFromRules() throws Exception {
   @Test
   public void clauseCanMatchBuiltInAttribute() throws Exception {
     Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false);
-    FeatureFlag f = TestUtil.booleanFlagWithClauses(clause);
+    FeatureFlag f = booleanFlagWithClauses(clause);
     LDUser user = new LDUser.Builder("key").name("Bob").build();
     
     assertEquals(jbool(true), f.evaluate(user, featureStore).getValue());
@@ -222,7 +219,7 @@ public void clauseCanMatchBuiltInAttribute() throws Exception {
   @Test
   public void clauseCanMatchCustomAttribute() throws Exception {
     Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false);
-    FeatureFlag f = TestUtil.booleanFlagWithClauses(clause);
+    FeatureFlag f = booleanFlagWithClauses(clause);
     LDUser user = new LDUser.Builder("key").custom("legs", 4).build();
     
     assertEquals(jbool(true), f.evaluate(user, featureStore).getValue());
@@ -231,7 +228,7 @@ public void clauseCanMatchCustomAttribute() throws Exception {
   @Test
   public void clauseReturnsFalseForMissingAttribute() throws Exception {
     Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false);
-    FeatureFlag f = TestUtil.booleanFlagWithClauses(clause);
+    FeatureFlag f = booleanFlagWithClauses(clause);
     LDUser user = new LDUser.Builder("key").name("Bob").build();
     
     assertEquals(jbool(false), f.evaluate(user, featureStore).getValue());
@@ -240,7 +237,7 @@ public void clauseReturnsFalseForMissingAttribute() throws Exception {
   @Test
   public void clauseCanBeNegated() throws Exception {
     Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), true);
-    FeatureFlag f = TestUtil.booleanFlagWithClauses(clause);
+    FeatureFlag f = booleanFlagWithClauses(clause);
     LDUser user = new LDUser.Builder("key").name("Bob").build();
     
     assertEquals(jbool(false), f.evaluate(user, featureStore).getValue());
@@ -258,7 +255,7 @@ public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception {
     LDUser user = new LDUser.Builder("foo").build();
     
     FeatureFlag.EvalResult result = flag.evaluate(user, featureStore);
-    Assert.assertEquals(new JsonPrimitive(true), result.getValue());
+    assertEquals(jbool(true), result.getValue());
   }
 
   @Test
@@ -267,17 +264,11 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti
     LDUser user = new LDUser.Builder("foo").build();
     
     FeatureFlag.EvalResult result = flag.evaluate(user, featureStore);
-    Assert.assertEquals(new JsonPrimitive(false), result.getValue());
+    assertEquals(jbool(false), result.getValue());
   }
  
   private FeatureFlag segmentMatchBooleanFlag(String segmentKey) {
-    Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(new JsonPrimitive(segmentKey)), false);
-    Rule rule = new Rule(Arrays.asList(clause), 1, null);
-    return new FeatureFlagBuilder("key")
-        .variations(Arrays.asList(new JsonPrimitive(false), new JsonPrimitive(true)))
-        .fallthrough(new VariationOrRollout(0, null))
-        .on(true)
-        .rules(Arrays.asList(rule))
-        .build();
+    Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false);
+    return booleanFlagWithClauses(clause);
   }
 }

From bafc9b2d11fe37754e85b30e0d7e5c94bfef4d31 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 15 Mar 2018 12:22:14 -0700
Subject: [PATCH 09/15] fix Redis optimistic locking logic to retry updates as
 needed

---
 .../client/RedisFeatureStore.java             | 146 +++++++++---------
 .../client/RedisFeatureStoreTest.java         |  50 +++++-
 2 files changed, 120 insertions(+), 76 deletions(-)

diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
index 90e91b3f3..1097515ff 100644
--- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
+++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
@@ -1,19 +1,6 @@
 package com.launchdarkly.client;
 
-import static com.launchdarkly.client.VersionedDataKind.FEATURES;
-
-import java.io.IOException;
-import java.net.URI;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.TimeUnit;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Optional;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
@@ -24,6 +11,20 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gson.Gson;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+
+import static com.launchdarkly.client.VersionedDataKind.FEATURES;
+
 import redis.clients.jedis.Jedis;
 import redis.clients.jedis.JedisPool;
 import redis.clients.jedis.JedisPoolConfig;
@@ -38,12 +39,15 @@ public class RedisFeatureStore implements FeatureStore {
   private static final String DEFAULT_PREFIX = "launchdarkly";
   private static final String INIT_KEY = "$initialized$";
   private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "RedisFeatureStore-cache-refresher-pool-%d";
+  private static final Gson gson = new Gson();
+  
   private final JedisPool pool;
   private LoadingCache> cache;
   private final LoadingCache initCache = createInitCache();
   private String prefix;
   private ListeningExecutorService executorService;
-
+  private UpdateListener updateListener;
+  
   private static class CacheKey {
     final VersionedDataKind kind;
     final String key;
@@ -102,10 +106,6 @@ private void setPrefix(String prefix) {
     }
   }
 
-  private void createCache(long cacheTimeSecs) {
-    createCache(cacheTimeSecs, false, false);
-  }
-
   private void createCache(long cacheTimeSecs, boolean refreshStaleValues, boolean asyncRefresh) {
     if (cacheTimeSecs > 0) {
       if (refreshStaleValues) {
@@ -182,7 +182,6 @@ public  Map all(VersionedDataKind kind) {
     try (Jedis jedis = pool.getResource()) {
       Map allJson = jedis.hgetAll(itemsKey(kind));
       Map result = new HashMap<>();
-      Gson gson = new Gson();
 
       for (Map.Entry entry : allJson.entrySet()) {
         T item = gson.fromJson(entry.getValue(), kind.getItemClass());
@@ -197,7 +196,6 @@ public  Map all(VersionedDataKind kind) {
   @Override
   public void init(Map, Map> allData) {
     try (Jedis jedis = pool.getResource()) {
-      Gson gson = new Gson();
       Transaction t = jedis.multi();
 
       for (Map.Entry, Map> entry: allData.entrySet()) {
@@ -216,63 +214,54 @@ public void init(Map, Map>
 
   @Override
   public  void delete(VersionedDataKind kind, String key, int version) {
-    Jedis jedis = null;
-    try {
-      Gson gson = new Gson();
-      jedis = pool.getResource();
-      String baseKey = itemsKey(kind);
-      jedis.watch(baseKey);
-
-      VersionedData item = getRedis(kind, key, jedis);
-
-      if (item != null && item.getVersion() >= version) {
-        logger.warn("Attempted to delete key: {} version: {}" +
-            " with a version that is the same or older: {} in \"{}\"",
-            key, item.getVersion(), version, kind.getNamespace());
-        return;
-      }
-
-      VersionedData deletedItem = kind.makeDeletedItem(key, version);
-      jedis.hset(baseKey, key, gson.toJson(deletedItem));
-
-      if (cache != null) {
-        cache.invalidate(new CacheKey(kind, key));
-      }
-    } finally {
-      if (jedis != null) {
-        jedis.unwatch();
-        jedis.close();
-      }
-    }
+    T deletedItem = kind.makeDeletedItem(key, version);
+    updateItemWithVersioning(kind, deletedItem);
   }
-
+  
   @Override
   public  void upsert(VersionedDataKind kind, T item) {
-    Jedis jedis = null;
-    try {
-      jedis = pool.getResource();
-      Gson gson = new Gson();
-      String baseKey = itemsKey(kind);
-      jedis.watch(baseKey);
-
-      VersionedData old = getRedisEvenIfDeleted(kind, item.getKey(), jedis);
-
-      if (old != null && old.getVersion() >= item.getVersion()) {
-        logger.warn("Attempted to update key: {} version: {}" +
-            " with a version that is the same or older: {} in \"{}\"",
-            item.getKey(), old.getVersion(), item.getVersion(), kind.getNamespace());
-        return;
-      }
-
-      jedis.hset(baseKey, item.getKey(), gson.toJson(item));
+    updateItemWithVersioning(kind, item);
+  }
 
-      if (cache != null) {
-        cache.invalidate(new CacheKey(kind, item.getKey()));
-      }
-    } finally {
-      if (jedis != null) {
-        jedis.unwatch();
-        jedis.close();
+  private  void updateItemWithVersioning(VersionedDataKind kind, T newItem) {
+    while (true) {
+      Jedis jedis = null;
+      try {
+        jedis = pool.getResource();
+        String baseKey = itemsKey(kind);
+        jedis.watch(baseKey);
+  
+        if (updateListener != null) {
+          updateListener.aboutToUpdate(baseKey, newItem.getKey());
+        }
+        
+        VersionedData oldItem = getRedisEvenIfDeleted(kind, newItem.getKey(), jedis);
+  
+        if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) {
+          logger.warn("Attempted to {} key: {} version: {}" +
+              " with a version that is the same or older: {} in \"{}\"",
+              newItem.isDeleted() ? "delete" : "update",
+              newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace());
+          return;
+        }
+  
+        Transaction tx = jedis.multi();
+        tx.hset(baseKey, newItem.getKey(), gson.toJson(newItem));
+        List result = tx.exec();
+        if (result.isEmpty()) {
+          // if exec failed, it means the watch was triggered and we should retry
+          logger.debug("Concurrent modification detected, retrying");
+          continue;
+        }
+  
+        if (cache != null) {
+          cache.invalidate(new CacheKey(kind, newItem.getKey()));
+        }
+      } finally {
+        if (jedis != null) {
+          jedis.unwatch();
+          jedis.close();
+        }
       }
     }
   }
@@ -339,7 +328,6 @@ private  T getRedis(VersionedDataKind kind, String k
   }
 
   private  T getRedisEvenIfDeleted(VersionedDataKind kind, String key, Jedis jedis) {
-    Gson gson = new Gson();
     String json = jedis.hget(itemsKey(kind), key);
 
     if (json == null) {
@@ -354,4 +342,12 @@ private static JedisPoolConfig getPoolConfig() {
     return new JedisPoolConfig();
   }
 
+  static interface UpdateListener {
+    void aboutToUpdate(String baseKey, String itemKey);
+  }
+  
+  @VisibleForTesting
+  void setUpdateListener(UpdateListener updateListener) {
+    this.updateListener = updateListener;
+  }
 }
diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
index 90b194f76..2c6b2254d 100644
--- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
+++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
@@ -1,8 +1,19 @@
 package com.launchdarkly.client;
 
-import java.net.URI;
+import com.google.gson.Gson;
 
+import org.junit.Assert;
 import org.junit.Before;
+import org.junit.Test;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.launchdarkly.client.VersionedDataKind.FEATURES;
+import static java.util.Collections.singletonMap;
+
+import redis.clients.jedis.Jedis;
 
 public class RedisFeatureStoreTest extends FeatureStoreTestBase {
 
@@ -10,4 +21,41 @@ public class RedisFeatureStoreTest extends FeatureStoreTestBase flags = singletonMap(feature1.getKey(), feature1);
+      Map, Map> allData = new HashMap<>();
+      allData.put(FEATURES, flags);
+      store.init(allData);
+      
+      RedisFeatureStore.UpdateListener concurrentModHook = new RedisFeatureStore.UpdateListener() {
+        int tries = 0;
+        FeatureFlag intermediateVer = feature1;
+        
+        @Override
+        public void aboutToUpdate(String baseKey, String itemKey) {
+          if (tries < 3) {
+            tries++;
+            intermediateVer = new FeatureFlagBuilder(intermediateVer)
+                .version(intermediateVer.getVersion() + 1).build();
+            otherClient.hset(baseKey, "foo", gson.toJson(intermediateVer));
+          }
+        }
+      };
+      store.setUpdateListener(concurrentModHook);
+      
+      store.upsert(FEATURES, finalVer);
+      FeatureFlag result = store.get(FEATURES, feature1.getKey());
+      Assert.assertEquals(finalVer.getVersion(), result.getVersion());
+    } finally {
+      otherClient.close();
+    }
+  }
 }

From 17d9d0d22128a99615631d65eead11bb6efc13c5 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Fri, 16 Mar 2018 11:47:10 -0700
Subject: [PATCH 10/15] we should persist deleted items in the local cache too

---
 .../client/RedisFeatureStore.java             | 27 +++++++------------
 1 file changed, 10 insertions(+), 17 deletions(-)

diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
index 1097515ff..aa8b78612 100644
--- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
+++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
@@ -120,7 +120,9 @@ private CacheLoader> createDefaultCacheLoader(
     return new CacheLoader>() {
       @Override
       public Optional load(CacheKey key) throws Exception {
-        return Optional.fromNullable(getRedis(key.kind, key.key));
+        try (Jedis jedis = pool.getResource()) {
+          return Optional.fromNullable(getRedisEvenIfDeleted(key.kind, key.key, jedis));
+        }
       }
     };
   }
@@ -169,7 +171,13 @@ public  T get(VersionedDataKind kind, String key) {
     if (cache != null) {
       item = (T) cache.getUnchecked(new CacheKey(kind, key)).orNull();
     } else {
-      item = getRedis(kind, key);
+      try (Jedis jedis = pool.getResource()) {
+        item = getRedisEvenIfDeleted(kind, key, jedis);
+      }
+    }
+    if (item != null && item.isDeleted()) {
+      logger.debug("[get] Key: {} has been deleted in \"{}\". Returning null", key, kind.getNamespace());
+      return null;
     }
     if (item != null) {
       logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace());
@@ -312,21 +320,6 @@ private Boolean getInit() {
     }
   }
 
-  private  T getRedis(VersionedDataKind kind, String key) {
-    try (Jedis jedis = pool.getResource()) {
-      return getRedis(kind, key, jedis);
-    }
-  }
-
-  private  T getRedis(VersionedDataKind kind, String key, Jedis jedis) {
-    T item = getRedisEvenIfDeleted(kind, key, jedis);
-    if (item != null && item.isDeleted()) {
-      logger.debug("[get] Key: {} has been deleted in \"{}\". Returning null", key, kind.getNamespace());
-      return null;
-    }
-    return item;
-  }
-
   private  T getRedisEvenIfDeleted(VersionedDataKind kind, String key, Jedis jedis) {
     String json = jedis.hget(itemsKey(kind), key);
 

From 556c03c3f08b38c453966bfb293fe669dbff4a31 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Mon, 19 Mar 2018 16:18:52 -0700
Subject: [PATCH 11/15] better unit tests for Redis concurrent modification

---
 .../client/RedisFeatureStoreTest.java         | 72 ++++++++++++-------
 1 file changed, 47 insertions(+), 25 deletions(-)

diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
index 2c6b2254d..cbe103cec 100644
--- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
+++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java
@@ -23,39 +23,61 @@ public void setup() {
   }
   
   @Test
-  public void handlesUpsertRaceConditionAgainstExternalClient() {
+  public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() {
     final Jedis otherClient = new Jedis("localhost");
-    final Gson gson = new Gson();
     try {
-      final FeatureFlag feature1 = new FeatureFlagBuilder("foo").version(1).build();
-      FeatureFlag finalVer = new FeatureFlagBuilder(feature1).version(10).build();
+      final FeatureFlag flag = new FeatureFlagBuilder("foo").version(1).build();
+      initStoreWithSingleFeature(store, flag);
       
-      Map flags = singletonMap(feature1.getKey(), feature1);
-      Map, Map> allData = new HashMap<>();
-      allData.put(FEATURES, flags);
-      store.init(allData);
+      store.setUpdateListener(makeConcurrentModifier(otherClient, flag, 2, 4));
       
-      RedisFeatureStore.UpdateListener concurrentModHook = new RedisFeatureStore.UpdateListener() {
-        int tries = 0;
-        FeatureFlag intermediateVer = feature1;
-        
-        @Override
-        public void aboutToUpdate(String baseKey, String itemKey) {
-          if (tries < 3) {
-            tries++;
-            intermediateVer = new FeatureFlagBuilder(intermediateVer)
-                .version(intermediateVer.getVersion() + 1).build();
-            otherClient.hset(baseKey, "foo", gson.toJson(intermediateVer));
-          }
-        }
-      };
-      store.setUpdateListener(concurrentModHook);
+      FeatureFlag myVer = new FeatureFlagBuilder(flag).version(10).build();
+      store.upsert(FEATURES, myVer);
+      FeatureFlag result = store.get(FEATURES, feature1.getKey());
+      Assert.assertEquals(myVer.getVersion(), result.getVersion());
+    } finally {
+      otherClient.close();
+    }
+  }
+  
+  @Test
+  public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() {
+    final Jedis otherClient = new Jedis("localhost");
+    try {
+      final FeatureFlag flag = new FeatureFlagBuilder("foo").version(1).build();
+      initStoreWithSingleFeature(store, flag);
+      
+      store.setUpdateListener(makeConcurrentModifier(otherClient, flag, 3, 3));
       
-      store.upsert(FEATURES, finalVer);
+      FeatureFlag myVer = new FeatureFlagBuilder(flag).version(2).build();
+      store.upsert(FEATURES, myVer);
       FeatureFlag result = store.get(FEATURES, feature1.getKey());
-      Assert.assertEquals(finalVer.getVersion(), result.getVersion());
+      Assert.assertEquals(3, result.getVersion());
     } finally {
       otherClient.close();
     }
   }
+  
+  private void initStoreWithSingleFeature(RedisFeatureStore store, FeatureFlag flag) {
+    Map flags = singletonMap(flag.getKey(), flag);
+    Map, Map> allData = new HashMap<>();
+    allData.put(FEATURES, flags);
+    store.init(allData);
+  }
+  
+  private RedisFeatureStore.UpdateListener makeConcurrentModifier(final Jedis otherClient, final FeatureFlag flag,
+    final int startVersion, final int endVersion) {
+    final Gson gson = new Gson();
+    return new RedisFeatureStore.UpdateListener() {
+      int versionCounter = startVersion;
+      @Override
+      public void aboutToUpdate(String baseKey, String itemKey) {
+        if (versionCounter <= endVersion) {
+          FeatureFlag newVer = new FeatureFlagBuilder(flag).version(versionCounter).build();
+          versionCounter++;
+          otherClient.hset(baseKey, flag.getKey(), gson.toJson(newVer));
+        }
+      }
+    };
+  }
 }

From 3063cd21e09e4e6b774e4145defbd345d9af449c Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 22 Mar 2018 15:27:32 -0700
Subject: [PATCH 12/15] treat unsupported operators as a non-match, don't throw
 NPE

---
 .../java/com/launchdarkly/client/Clause.java  |  8 ++++--
 .../launchdarkly/client/FeatureFlagTest.java  | 28 +++++++++++++++++++
 2 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java
index fa4055fb6..65cb756dc 100644
--- a/src/main/java/com/launchdarkly/client/Clause.java
+++ b/src/main/java/com/launchdarkly/client/Clause.java
@@ -75,9 +75,11 @@ boolean matchesUser(FeatureStore store, LDUser user) {
   }
   
   private boolean matchAny(JsonPrimitive userValue) {
-    for (JsonPrimitive v : values) {
-      if (op.apply(userValue, v)) {
-        return true;
+    if (op != null) {
+      for (JsonPrimitive v : values) {
+        if (op.apply(userValue, v)) {
+          return true;
+        }
       }
     }
     return false;
diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
index 6959c41ff..9afe4309c 100644
--- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
+++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
@@ -243,6 +243,34 @@ public void clauseCanBeNegated() throws Exception {
     assertEquals(jbool(false), f.evaluate(user, featureStore).getValue());
   }
   
+  @Test
+  public void clauseWithNullOperatorDoesNotMatch() throws Exception {
+    // Operator will be null if it failed to be unmarshaled from JSON, i.e. it's not a supported enum value
+    Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false);
+    FeatureFlag f = booleanFlagWithClauses(badClause);
+    LDUser user = new LDUser.Builder("key").name("Bob").build();
+    
+    assertEquals(jbool(false), f.evaluate(user, featureStore).getValue());
+  }
+  
+  @Test
+  public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception {
+    Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false);
+    Rule badRule = new Rule(Arrays.asList(badClause), 1, null);
+    Clause goodClause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false);
+    Rule goodRule = new Rule(Arrays.asList(goodClause), 1, null);
+    FeatureFlag f = new FeatureFlagBuilder("feature")
+        .on(true)
+        .rules(Arrays.asList(badRule, goodRule))
+        .fallthrough(fallthroughVariation(0))
+        .offVariation(0)
+        .variations(jbool(false), jbool(true))
+        .build();
+    LDUser user = new LDUser.Builder("key").name("Bob").build();
+    
+    assertEquals(jbool(true), f.evaluate(user, featureStore).getValue());
+  }
+  
   @Test
   public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception {
     Segment segment = new Segment.Builder("segkey")

From ae3696aad3bd270fe4cfded37568875d3e506b5e Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 22 Mar 2018 15:36:48 -0700
Subject: [PATCH 13/15] add another unit test

---
 .../launchdarkly/client/FeatureFlagTest.java  | 23 ++++++++++++++++++-
 1 file changed, 22 insertions(+), 1 deletion(-)

diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
index 9afe4309c..522c86bc1 100644
--- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
+++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java
@@ -1,5 +1,11 @@
 package com.launchdarkly.client;
 
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -13,6 +19,7 @@
 import static com.launchdarkly.client.VersionedDataKind.FEATURES;
 import static com.launchdarkly.client.VersionedDataKind.SEGMENTS;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 
 public class FeatureFlagTest {
@@ -243,9 +250,23 @@ public void clauseCanBeNegated() throws Exception {
     assertEquals(jbool(false), f.evaluate(user, featureStore).getValue());
   }
   
+  @Test
+  public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception {
+    // This just verifies that GSON will give us a null in this case instead of throwing an exception,
+    // so we fail as gracefully as possible if a new operator type has been added in the application
+    // and the SDK hasn't been upgraded yet.
+    String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}";
+    Gson gson = new Gson();
+    Clause clause = gson.fromJson(badClauseJson, Clause.class);
+    assertNotNull(clause);
+    
+    JsonElement json = gson.toJsonTree(clause);
+    String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}";
+    assertEquals(gson.fromJson(expectedJson, JsonElement.class), json);
+  }
+  
   @Test
   public void clauseWithNullOperatorDoesNotMatch() throws Exception {
-    // Operator will be null if it failed to be unmarshaled from JSON, i.e. it's not a supported enum value
     Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false);
     FeatureFlag f = booleanFlagWithClauses(badClause);
     LDUser user = new LDUser.Builder("key").name("Bob").build();

From b48f6fb9391d86baa130d8bc88c757f8e2e96954 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Mon, 26 Mar 2018 15:41:22 -0700
Subject: [PATCH 14/15] reduce "same or lower version" log level to debug

---
 src/main/java/com/launchdarkly/client/RedisFeatureStore.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
index aa8b78612..1065269aa 100644
--- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
+++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
@@ -246,7 +246,7 @@ private  void updateItemWithVersioning(VersionedDataKin
         VersionedData oldItem = getRedisEvenIfDeleted(kind, newItem.getKey(), jedis);
   
         if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) {
-          logger.warn("Attempted to {} key: {} version: {}" +
+          logger.debug("Attempted to {} key: {} version: {}" +
               " with a version that is the same or older: {} in \"{}\"",
               newItem.isDeleted() ? "delete" : "update",
               newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace());

From 3305f2c55543052ab48a51f38dd32ae13dabc41c Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Mon, 26 Mar 2018 15:55:58 -0700
Subject: [PATCH 15/15] version 3.0.3

---
 gradle.properties | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/gradle.properties b/gradle.properties
index 452e60520..ecd500f40 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,3 @@
-version=3.0.2
+version=3.0.3
 ossrhUsername=
 ossrhPassword=