From 3d95d43fd7769d998038ea5079d0b637c57c96ac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jun 2018 15:01:44 -0700 Subject: [PATCH 001/156] don't give up permanently after a 400 error --- src/main/java/com/launchdarkly/client/Util.java | 1 + .../com/launchdarkly/client/DefaultEventProcessorTest.java | 5 +++++ .../java/com/launchdarkly/client/PollingProcessorTest.java | 5 +++++ .../java/com/launchdarkly/client/StreamProcessorTest.java | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index bb4bccd4b..78ea7b759 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -41,6 +41,7 @@ static Request.Builder getRequestBuilder(String sdkKey) { static boolean isHttpErrorRecoverable(int statusCode) { if (statusCode >= 400 && statusCode < 500) { switch (statusCode) { + case 400: // bad request case 408: // request timeout case 429: // too many requests return true; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index d4b250e0a..36bc2097e 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -391,6 +391,11 @@ public void sdkKeyIsSent() throws Exception { assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); } + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } + @Test public void http401ErrorIsUnrecoverable() throws Exception { testUnrecoverableHttpError(401); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 10d1a3722..dc18f1421 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -53,6 +53,11 @@ public void testConnectionProblem() throws Exception { pollingProcessor.close(); verifyAll(); } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } @Test public void http401ErrorIsUnrecoverable() throws Exception { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 8b342359e..ee29937c9 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -282,6 +282,11 @@ public void streamWillReconnectAfterGeneralIOException() throws Exception { ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } @Test public void http401ErrorIsUnrecoverable() throws Exception { From 398202aa9b1f36af2656931fdd5e0d5501645336 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 13:35:31 -0700 Subject: [PATCH 002/156] implement evaluation with explanation --- .../client/EvaluationDetails.java | 139 ++++++++++++++++++ .../client/EvaluationException.java | 1 + .../com/launchdarkly/client/EventFactory.java | 8 +- .../com/launchdarkly/client/FeatureFlag.java | 131 +++++++++-------- .../com/launchdarkly/client/LDClient.java | 87 ++++++++--- .../client/LDClientInterface.java | 57 ++++++- .../java/com/launchdarkly/client/Rule.java | 8 +- .../launchdarkly/client/VariationType.java | 55 +++---- .../client/DefaultEventProcessorTest.java | 26 ++-- .../client/EventSummarizerTest.java | 8 +- .../launchdarkly/client/FeatureFlagTest.java | 53 +++---- .../client/LDClientEvaluationTest.java | 27 ++-- .../com/launchdarkly/client/TestUtil.java | 22 ++- 13 files changed, 445 insertions(+), 177 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EvaluationDetails.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetails.java new file mode 100644 index 000000000..99a172942 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluationDetails.java @@ -0,0 +1,139 @@ +package com.launchdarkly.client; + +import com.google.common.base.Objects; +import com.google.gson.JsonElement; + +/** + * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), + * combining the result of a flag evaluation with an explanation of how it was calculated. + * @since 4.3.0 + */ +public class EvaluationDetails { + + /** + * Enum values used in {@link EvaluationDetails} to explain why a flag evaluated to a particular value. + * @since 4.3.0 + */ + public static enum Reason { + /** + * Indicates that the flag was off and therefore returned its configured off value. + */ + OFF, + /** + * Indicates that the user key was specifically targeted for this flag. + */ + TARGET_MATCH, + /** + * Indicates that the user matched one of the flag's rules. + */ + RULE_MATCH, + /** + * Indicates that the flag was treated as if it was off because it had a prerequisite flag that + * either was off or did not return the expected variation., + */ + PREREQUISITE_FAILED, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, + /** + * Indicates that the default value (passed as a parameter to one of the {@code variation} methods0 + * was returned. This normally indicates an error condition. + */ + DEFAULT; + } + + private final Reason reason; + private final Integer variationIndex; + private final T value; + private final Integer matchIndex; + private final String matchId; + + public EvaluationDetails(Reason reason, Integer variationIndex, T value, Integer matchIndex, String matchId) { + super(); + this.reason = reason; + this.variationIndex = variationIndex; + this.value = value; + this.matchIndex = matchIndex; + this.matchId = matchId; + } + + static EvaluationDetails off(Integer offVariation, JsonElement value) { + return new EvaluationDetails(Reason.OFF, offVariation, value, null, null); + } + + static EvaluationDetails fallthrough(int variationIndex, JsonElement value) { + return new EvaluationDetails(Reason.FALLTHROUGH, variationIndex, value, null, null); + } + + static EvaluationDetails defaultValue(T value) { + return new EvaluationDetails(Reason.DEFAULT, null, value, null, null); + } + + /** + * An enum describing the main factor that influenced the flag evaluation value. + * @return a {@link Reason} + */ + public Reason getReason() { + return reason; + } + + /** + * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation - + * or {@code null} if the default value was returned. + * @return the variation index or null + */ + public Integer getVariationIndex() { + return variationIndex; + } + + /** + * The result of the flag evaluation. This will be either one of the flag's variations or the default + * value that was passed to the {@code variation} method. + * @return the flag value + */ + public T getValue() { + return value; + } + + /** + * A number whose meaning depends on the {@link Reason}. For {@link Reason#TARGET_MATCH}, it is the + * zero-based index of the matched target. For {@link Reason#RULE_MATCH}, it is the zero-based index + * of the matched rule. For all other reasons, it is {@code null}. + * @return the index of the matched item or null + */ + public Integer getMatchIndex() { + return matchIndex; + } + + /** + * A string whose meaning depends on the {@link Reason}. For {@link Reason#RULE_MATCH}, it is the + * unique identifier of the matched rule, if any. For {@link Reason#PREREQUISITE_FAILED}, it is the + * flag key of the prerequisite flag that stopped evaluation. For all other reasons, it is {@code null}. + * @return a rule ID, flag key, or null + */ + public String getMatchId() { + return matchId; + } + + @Override + public boolean equals(Object other) { + if (other instanceof EvaluationDetails) { + @SuppressWarnings("unchecked") + EvaluationDetails o = (EvaluationDetails)other; + return reason == o.reason && variationIndex == o.variationIndex && Objects.equal(value, o.value) + && matchIndex == o.matchIndex && Objects.equal(matchId, o.matchId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(reason, variationIndex, value, matchIndex, matchId); + } + + @Override + public String toString() { + return "{" + reason + ", " + variationIndex + ", " + value + ", " + matchIndex + ", " + matchId + "}"; + } +} diff --git a/src/main/java/com/launchdarkly/client/EvaluationException.java b/src/main/java/com/launchdarkly/client/EvaluationException.java index f7adf563f..174a2417e 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationException.java +++ b/src/main/java/com/launchdarkly/client/EvaluationException.java @@ -3,6 +3,7 @@ /** * An error indicating an abnormal result from evaluating a feature */ +@SuppressWarnings("serial") class EvaluationException extends Exception { public EvaluationException(String message) { super(message); diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index fce94a49c..345619b0b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -7,9 +7,9 @@ abstract class EventFactory { protected abstract long getTimestamp(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, FeatureFlag.VariationAndValue result, JsonElement defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetails result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - result == null ? null : result.getVariation(), result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); } @@ -22,10 +22,10 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, FeatureFlag.VariationAndValue result, + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetails result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), - result == null ? null : result.getVariation(), result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), false); } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index faff33c6b..0ab75e23c 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -2,6 +2,8 @@ import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; +import com.launchdarkly.client.EvaluationDetails.Reason; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,65 +72,86 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa } if (isOn()) { - VariationAndValue result = evaluate(user, featureStore, prereqEvents, eventFactory); - if (result != null) { - return new EvalResult(result, prereqEvents); - } + EvaluationDetails details = evaluate(user, featureStore, prereqEvents, eventFactory); + return new EvalResult(details, prereqEvents); } - return new EvalResult(new VariationAndValue(offVariation, getOffVariationValue()), prereqEvents); + + EvaluationDetails details = EvaluationDetails.off(offVariation, getOffVariationValue()); + return new EvalResult(details, prereqEvents); } // Returning either a JsonElement or null indicating prereq failure/error. - private VariationAndValue evaluate(LDUser user, FeatureStore featureStore, List events, + private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { - boolean prereqOk = true; - if (prerequisites != null) { - for (Prerequisite prereq : prerequisites) { - FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - VariationAndValue prereqEvalResult = null; - if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); - return null; - } else if (prereqFeatureFlag.isOn()) { - prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); - if (prereqEvalResult == null || prereqEvalResult.getVariation() != prereq.getVariation()) { - prereqOk = false; - } - } else { - prereqOk = false; - } - //We don't short circuit and also send events for each prereq. - events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); - } + EvaluationDetails prereqErrorResult = checkPrerequisites(user, featureStore, events, eventFactory); + if (prereqErrorResult != null) { + return prereqErrorResult; } - if (prereqOk) { - Integer index = evaluateIndex(user, featureStore); - return new VariationAndValue(index, getVariation(index)); - } - return null; - } - - private Integer evaluateIndex(LDUser user, FeatureStore store) { + // Check to see if targets match if (targets != null) { - for (Target target : targets) { + for (int i = 0; i < targets.size(); i++) { + Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return target.getVariation(); + return new EvaluationDetails(Reason.TARGET_MATCH, + target.getVariation(), getVariation(target.getVariation()), i, null); } } } } // Now walk through the rules and see if any match if (rules != null) { - for (Rule rule : rules) { - if (rule.matchesUser(store, user)) { - return rule.variationIndexForUser(user, key, salt); + for (int i = 0; i < rules.size(); i++) { + Rule rule = rules.get(i); + if (rule.matchesUser(featureStore, user)) { + int index = rule.variationIndexForUser(user, key, salt); + return new EvaluationDetails(Reason.RULE_MATCH, + index, getVariation(index), i, rule.getId()); } } } // Walk through the fallthrough and see if it matches - return fallthrough.variationIndexForUser(user, key, salt); + int index = fallthrough.variationIndexForUser(user, key, salt); + return EvaluationDetails.fallthrough(index, getVariation(index)); + } + + // Checks prerequisites if any; returns null if successful, or an EvaluationDetails if we have to + // short-circuit due to a prerequisite failure. + private EvaluationDetails checkPrerequisites(LDUser user, FeatureStore featureStore, List events, + EventFactory eventFactory) throws EvaluationException { + if (prerequisites == null) { + return null; + } + EvaluationDetails ret = null; + boolean prereqOk = true; + for (int i = 0; i < prerequisites.size(); i++) { + Prerequisite prereq = prerequisites.get(i); + FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); + EvaluationDetails prereqEvalResult = null; + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); + prereqOk = false; + } else if (prereqFeatureFlag.isOn()) { + prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); + if (prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { + prereqOk = false; + } + } else { + prereqOk = false; + } + // We continue to evaluate all prerequisites even if one failed, but set the result to the first failure if any. + if (prereqFeatureFlag != null) { + events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); + } + if (!prereqOk) { + if (ret == null) { + ret = new EvaluationDetails(Reason.PREREQUISITE_FAILED, + offVariation, getOffVariationValue(), i, prereq.getKey()); + } + } + } + return ret; } JsonElement getOffVariationValue() throws EvaluationException { @@ -207,37 +230,21 @@ List getVariations() { return variations; } - Integer getOffVariation() { return offVariation; } - - static class VariationAndValue { - private final Integer variation; - private final JsonElement value; - - VariationAndValue(Integer variation, JsonElement value) { - this.variation = variation; - this.value = value; - } - - Integer getVariation() { - return variation; - } - - JsonElement getValue() { - return value; - } + Integer getOffVariation() { + return offVariation; } static class EvalResult { - private final VariationAndValue result; + private final EvaluationDetails details; private final List prerequisiteEvents; - private EvalResult(VariationAndValue result, List prerequisiteEvents) { - this.result = result; + private EvalResult(EvaluationDetails details, List prerequisiteEvents) { + this.details = details; this.prerequisiteEvents = prerequisiteEvents; } - VariationAndValue getResult() { - return result; + EvaluationDetails getDetails() { + return details; } List getPrerequisiteEvents() { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..3bf8eed5f 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -2,6 +2,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.EvaluationDetails.Reason; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -165,7 +166,7 @@ public Map allFlags(LDUser user) { for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getResult().getValue(); + JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getDetails().getValue(); result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { @@ -177,34 +178,54 @@ public Map allFlags(LDUser user) { @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Boolean); - return value.getAsJsonPrimitive().getAsBoolean(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); } @Override public Integer intVariation(String featureKey, LDUser user, int defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Integer); - return value.getAsJsonPrimitive().getAsInt(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); } @Override public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Double); - return value.getAsJsonPrimitive().getAsDouble(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); } @Override public String stringVariation(String featureKey, LDUser user, String defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.String); - return value.getAsJsonPrimitive().getAsString(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); } @Override public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { - JsonElement value = evaluate(featureKey, user, defaultValue, VariationType.Json); - return value; + return evaluate(featureKey, user, defaultValue, defaultValue, VariationType.Json); } + @Override + public EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); + } + + @Override + public EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); + } + + @Override + public EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); + } + + @Override + public EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); + } + + @Override + public EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); + } + @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { @@ -227,14 +248,36 @@ public boolean isFlagKnown(String featureKey) { return false; } - private JsonElement evaluate(String featureKey, LDUser user, JsonElement defaultValue, VariationType expectedType) { + private T evaluate(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { + return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); + } + + private EvaluationDetails evaluateDetail(String featureKey, LDUser user, T defaultValue, + JsonElement defaultJson, VariationType expectedType) { + EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); + T resultValue; + if (details.getReason() == Reason.DEFAULT) { + resultValue = defaultValue; + } else { + try { + resultValue = expectedType.coerceValue(details.getValue()); + } catch (EvaluationException e) { + logger.error("Encountered exception in LaunchDarkly client: " + e); + resultValue = defaultValue; + } + } + return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue, + details.getMatchIndex(), details.getMatchId()); + } + + private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } } @@ -243,12 +286,12 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); @@ -257,19 +300,19 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - if (evalResult.getResult() != null && evalResult.getResult().getValue() != null) { - expectedType.assertResultType(evalResult.getResult().getValue()); - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getResult(), defaultValue)); - return evalResult.getResult().getValue(); + if (evalResult.getDetails() != null && evalResult.getDetails().getValue() != null) { + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); + return evalResult.getDetails(); } else { sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: " + e); + logger.debug(e.getMessage(), e); } sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 94ee3f060..c5fb4280a 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -58,7 +58,7 @@ public interface LDClientInterface extends Closeable { * @return whether or not the flag should be enabled, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel */ boolean boolVariation(String featureKey, LDUser user, boolean defaultValue); - + /** * Calculates the integer value of a feature flag for a given user. * @@ -99,6 +99,61 @@ public interface LDClientInterface extends Closeable { */ JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue); + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue); + /** * Returns true if the specified feature flag currently exists. * @param featureKey the unique key for the feature flag diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index 6009abc74..cee3d7ae0 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -8,6 +8,7 @@ * Invariant: one of the variation or rollout must be non-nil. */ class Rule extends VariationOrRollout { + private String id; private List clauses; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation @@ -15,11 +16,16 @@ class Rule extends VariationOrRollout { super(); } - Rule(List clauses, Integer variation, Rollout rollout) { + Rule(String id, List clauses, Integer variation, Rollout rollout) { super(variation, rollout); + this.id = id; this.clauses = clauses; } + String getId() { + return id; + } + boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { if (!clause.matchesUser(store, user)) { diff --git a/src/main/java/com/launchdarkly/client/VariationType.java b/src/main/java/com/launchdarkly/client/VariationType.java index 8d8228f1b..c222d57a4 100644 --- a/src/main/java/com/launchdarkly/client/VariationType.java +++ b/src/main/java/com/launchdarkly/client/VariationType.java @@ -3,48 +3,51 @@ import com.google.gson.JsonElement; -enum VariationType { - Boolean { - @Override - void assertResultType(JsonElement result) throws EvaluationException { +abstract class VariationType { + abstract T coerceValue(JsonElement result) throws EvaluationException; + + private VariationType() { + } + + static VariationType Boolean = new VariationType() { + Boolean coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isBoolean()) { - return; + return result.getAsBoolean(); } throw new EvaluationException("Feature flag evaluation expected result as boolean type, but got non-boolean type."); } - }, - Integer { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType Integer = new VariationType() { + Integer coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return; + return result.getAsInt(); } throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); } - }, - Double { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType Double = new VariationType() { + Double coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return; + return result.getAsDouble(); } throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); } - }, - String { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType String = new VariationType() { + String coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isString()) { - return; + return result.getAsString(); } throw new EvaluationException("Feature flag evaluation expected result as string type, but got non-string type."); } - }, - Json { - @Override - void assertResultType(JsonElement result) { + }; + + static VariationType Json = new VariationType() { + JsonElement coerceValue(JsonElement result) throws EvaluationException { + return result; } }; - - abstract void assertResultType(JsonElement result) throws EvaluationException; } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index d4b250e0a..bb20af90d 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -87,7 +87,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -105,7 +105,7 @@ public void userIsFilteredInIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -123,7 +123,7 @@ public void featureEventCanContainInlineUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -140,7 +140,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -157,7 +157,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -174,7 +174,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -193,7 +193,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -222,7 +222,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -250,7 +250,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -269,9 +269,9 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); JsonElement value = new JsonPrimitive("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(new Integer(1), value), null); + EvaluationDetails.fallthrough(1, value), null); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(new Integer(1), value), null); + EvaluationDetails.fallthrough(1, value), null); ep.sendEvent(fe1); ep.sendEvent(fe2); @@ -294,9 +294,9 @@ public void nonTrackedEventsAreSummarized() throws Exception { JsonElement default1 = new JsonPrimitive("default1"); JsonElement default2 = new JsonPrimitive("default2"); Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(new Integer(2), value), default1); + EvaluationDetails.fallthrough(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(new Integer(2), value), default2); + EvaluationDetails.fallthrough(2, value), default2); ep.sendEvent(fe1); ep.sendEvent(fe2); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 6a7a83875..664fb8e56 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -65,13 +65,13 @@ public void summarizeEventIncrementsCounters() { FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(1, js("value1")), js("default1")); + EvaluationDetails.fallthrough(1, js("value1")), js("default1")); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(2, js("value2")), js("default1")); + EvaluationDetails.fallthrough(2, js("value2")), js("default1")); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(1, js("value99")), js("default2")); + EvaluationDetails.fallthrough(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(1, js("value1")), js("default1")); + EvaluationDetails.fallthrough(1, js("value1")), js("default1")); Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); es.summarizeEvent(event1); es.summarizeEvent(event2); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 70bc94c9f..be062eb47 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.EvaluationDetails.Reason; import org.junit.Before; import org.junit.Test; @@ -17,7 +18,6 @@ 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 { @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(EvaluationDetails.off(1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertNull(result.getResult().getValue()); + assertEquals(EvaluationDetails.off(null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -68,7 +68,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -91,7 +91,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -120,7 +120,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("fall"), result.getResult().getValue()); + assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -157,7 +157,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("fall"), result.getResult().getValue()); + assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -185,14 +185,14 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(js("on"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.TARGET_MATCH, 2, js("on"), 0, null), result.getDetails()); 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); + Rule rule = new Rule("ruleid", Arrays.asList(clause), 2, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) .rules(Arrays.asList(rule)) @@ -203,44 +203,44 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(js("on"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 2, js("on"), 0, "ruleid"), result.getDetails()); 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 = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseCanMatchCustomAttribute() throws Exception { Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseReturnsFalseForMissingAttribute() throws Exception { Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseCanBeNegated() throws Exception { Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), true); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test @@ -261,18 +261,18 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false); - FeatureFlag f = booleanFlagWithClauses(badClause); + FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().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); + Rule badRule = new Rule("rule1", 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); + Rule goodRule = new Rule("rule2", Arrays.asList(goodClause), 1, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) .rules(Arrays.asList(badRule, goodRule)) @@ -282,7 +282,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 1, jbool(true), 1, "rule2"), details); } @Test @@ -297,7 +298,7 @@ public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { LDUser user = new LDUser.Builder("foo").build(); FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(jbool(true), result.getResult().getValue()); + assertEquals(jbool(true), result.getDetails().getValue()); } @Test @@ -306,11 +307,11 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti LDUser user = new LDUser.Builder("foo").build(); FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(jbool(false), result.getResult().getValue()); + assertEquals(jbool(false), result.getDetails().getValue()); } private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false); - return booleanFlagWithClauses(clause); + return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index f6d175dc0..c6c17ebba 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -7,6 +7,10 @@ import java.util.Arrays; +import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.jbool; +import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -19,7 +23,7 @@ public class LDClientEvaluationTest { private static final LDUser user = new LDUser("userkey"); - private TestFeatureStore featureStore = new TestFeatureStore(); + private FeatureStore featureStore = TestUtil.initedFeatureStore(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) .eventProcessorFactory(Components.nullEventProcessor()) @@ -29,7 +33,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.setFeatureTrue("key"); + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -41,7 +45,7 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.setIntegerValue("key", 2); + featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @@ -53,7 +57,7 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void doubleVariationReturnsFlagValue() throws Exception { - featureStore.setDoubleValue("key", 2.5d); + featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.5d))); assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } @@ -65,7 +69,7 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void stringVariationReturnsFlagValue() throws Exception { - featureStore.setStringValue("key", "b"); + featureStore.upsert(FEATURES, flagWithValue("key", js("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } @@ -79,7 +83,7 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception public void jsonVariationReturnsFlagValue() throws Exception { JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - featureStore.setJsonValue("key", data); + featureStore.upsert(FEATURES, flagWithValue("key", data)); assertEquals(data, client.jsonVariation("key", user, jint(42))); } @@ -100,16 +104,9 @@ public void canMatchUserBySegment() throws Exception { featureStore.upsert(SEGMENTS, segment); Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js("segment1")), false); - Rule rule = new Rule(Arrays.asList(clause), 0, null); - FeatureFlag feature = new FeatureFlagBuilder("test-feature") - .version(1) - .rules(Arrays.asList(rule)) - .variations(TestFeatureStore.TRUE_FALSE_VARIATIONS) - .on(true) - .fallthrough(new VariationOrRollout(1, null)) - .build(); + FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); - assertTrue(client.boolVariation("test-feature", user, false)); + assertTrue(client.boolVariation("feature", user, false)); } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 01304c620..494586c26 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -11,7 +11,9 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; import static org.hamcrest.Matchers.equalTo; @@ -25,6 +27,12 @@ public FeatureStore createFeatureStore() { }; } + public static FeatureStore initedFeatureStore() { + FeatureStore store = new InMemoryFeatureStore(); + store.init(Collections., Map>emptyMap()); + return store; + } + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { return new EventProcessorFactory() { public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { @@ -76,9 +84,9 @@ 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") + public static FeatureFlag booleanFlagWithClauses(String key, Clause... clauses) { + Rule rule = new Rule(null, Arrays.asList(clauses), 1, null); + return new FeatureFlagBuilder(key) .on(true) .rules(Arrays.asList(rule)) .fallthrough(fallthroughVariation(0)) @@ -87,6 +95,14 @@ public static FeatureFlag booleanFlagWithClauses(Clause... clauses) { .build(); } + public static FeatureFlag flagWithValue(String key, JsonElement value) { + return new FeatureFlagBuilder(key) + .on(false) + .offVariation(0) + .variations(value) + .build(); + } + public static Matcher hasJsonProperty(final String name, JsonElement value) { return hasJsonProperty(name, equalTo(value)); } From 8abe79cbdab67fb2d4725b7dff51d951fed35d7a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 16:07:45 -0700 Subject: [PATCH 003/156] use case-class-like type instead of enum + optional fields --- .../client/EvaluationDetails.java | 85 +----- .../launchdarkly/client/EvaluationReason.java | 260 ++++++++++++++++++ .../com/launchdarkly/client/FeatureFlag.java | 44 +-- .../com/launchdarkly/client/LDClient.java | 6 +- .../client/DefaultEventProcessorTest.java | 27 +- .../client/EventSummarizerTest.java | 9 +- .../launchdarkly/client/FeatureFlagTest.java | 62 ++++- .../com/launchdarkly/client/TestUtil.java | 4 + 8 files changed, 368 insertions(+), 129 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EvaluationReason.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetails.java index 99a172942..86abb7578 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetails.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetails.java @@ -1,7 +1,6 @@ package com.launchdarkly.client; import com.google.common.base.Objects; -import com.google.gson.JsonElement; /** * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), @@ -10,71 +9,26 @@ */ public class EvaluationDetails { - /** - * Enum values used in {@link EvaluationDetails} to explain why a flag evaluated to a particular value. - * @since 4.3.0 - */ - public static enum Reason { - /** - * Indicates that the flag was off and therefore returned its configured off value. - */ - OFF, - /** - * Indicates that the user key was specifically targeted for this flag. - */ - TARGET_MATCH, - /** - * Indicates that the user matched one of the flag's rules. - */ - RULE_MATCH, - /** - * Indicates that the flag was treated as if it was off because it had a prerequisite flag that - * either was off or did not return the expected variation., - */ - PREREQUISITE_FAILED, - /** - * Indicates that the flag was on but the user did not match any targets or rules. - */ - FALLTHROUGH, - /** - * Indicates that the default value (passed as a parameter to one of the {@code variation} methods0 - * was returned. This normally indicates an error condition. - */ - DEFAULT; - } - - private final Reason reason; + private final EvaluationReason reason; private final Integer variationIndex; private final T value; - private final Integer matchIndex; - private final String matchId; - public EvaluationDetails(Reason reason, Integer variationIndex, T value, Integer matchIndex, String matchId) { + public EvaluationDetails(EvaluationReason reason, Integer variationIndex, T value) { super(); this.reason = reason; this.variationIndex = variationIndex; this.value = value; - this.matchIndex = matchIndex; - this.matchId = matchId; - } - - static EvaluationDetails off(Integer offVariation, JsonElement value) { - return new EvaluationDetails(Reason.OFF, offVariation, value, null, null); - } - - static EvaluationDetails fallthrough(int variationIndex, JsonElement value) { - return new EvaluationDetails(Reason.FALLTHROUGH, variationIndex, value, null, null); } static EvaluationDetails defaultValue(T value) { - return new EvaluationDetails(Reason.DEFAULT, null, value, null, null); + return new EvaluationDetails<>(EvaluationReason.defaultValue(), null, value); } /** - * An enum describing the main factor that influenced the flag evaluation value. - * @return a {@link Reason} + * An object describing the main factor that influenced the flag evaluation value. + * @return an {@link EvaluationReason} */ - public Reason getReason() { + public EvaluationReason getReason() { return reason; } @@ -95,45 +49,24 @@ public Integer getVariationIndex() { public T getValue() { return value; } - - /** - * A number whose meaning depends on the {@link Reason}. For {@link Reason#TARGET_MATCH}, it is the - * zero-based index of the matched target. For {@link Reason#RULE_MATCH}, it is the zero-based index - * of the matched rule. For all other reasons, it is {@code null}. - * @return the index of the matched item or null - */ - public Integer getMatchIndex() { - return matchIndex; - } - - /** - * A string whose meaning depends on the {@link Reason}. For {@link Reason#RULE_MATCH}, it is the - * unique identifier of the matched rule, if any. For {@link Reason#PREREQUISITE_FAILED}, it is the - * flag key of the prerequisite flag that stopped evaluation. For all other reasons, it is {@code null}. - * @return a rule ID, flag key, or null - */ - public String getMatchId() { - return matchId; - } @Override public boolean equals(Object other) { if (other instanceof EvaluationDetails) { @SuppressWarnings("unchecked") EvaluationDetails o = (EvaluationDetails)other; - return reason == o.reason && variationIndex == o.variationIndex && Objects.equal(value, o.value) - && matchIndex == o.matchIndex && Objects.equal(matchId, o.matchId); + return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); } return false; } @Override public int hashCode() { - return Objects.hashCode(reason, variationIndex, value, matchIndex, matchId); + return Objects.hashCode(reason, variationIndex, value); } @Override public String toString() { - return "{" + reason + ", " + variationIndex + ", " + value + ", " + matchIndex + ", " + matchId + "}"; + return "{" + reason + "," + variationIndex + "," + value + "}"; } } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java new file mode 100644 index 000000000..592c880be --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -0,0 +1,260 @@ +package com.launchdarkly.client; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; + +import java.util.Objects; + +/** + * Describes the reason that a flag evaluation produced a particular value. This is returned by + * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). + * + * Note that this is an enum-like class hierarchy rather than an enum, because some of the + * possible reasons have their own properties. + * + * @since 4.3.0 + */ +public abstract class EvaluationReason { + + public static enum Kind { + /** + * Indicates that the flag was off and therefore returned its configured off value. + */ + OFF, + /** + * Indicates that the user key was specifically targeted for this flag. + */ + TARGET_MATCH, + /** + * Indicates that the user matched one of the flag's rules. + */ + RULE_MATCH, + /** + * Indicates that the flag was considered off because it had at least one prerequisite flag + * that either was off or did not return the desired variation. + */ + PREREQUISITES_FAILED, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, + /** + * Indicates that the default value (passed as a parameter to one of the {@code variation} methods) + * was returned. This normally indicates an error condition. + */ + DEFAULT; + } + + /** + * Returns an enum indicating the general category of the reason. + * @return a {@link Kind} value + */ + public abstract Kind getKind(); + + @Override + public String toString() { + return getKind().name(); + } + + private EvaluationReason() { } + + /** + * Returns an instance of {@link Off}. + */ + public static Off off() { + return Off.instance; + } + + /** + * Returns an instance of {@link TargetMatch}. + */ + public static TargetMatch targetMatch(int targetIndex) { + return new TargetMatch(targetIndex); + } + + /** + * Returns an instance of {@link RuleMatch}. + */ + public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { + return new RuleMatch(ruleIndex, ruleId); + } + + /** + * Returns an instance of {@link PrerequisitesFailed}. + */ + public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { + return new PrerequisitesFailed(prerequisiteKeys); + } + + /** + * Returns an instance of {@link Fallthrough}. + */ + public static Fallthrough fallthrough() { + return Fallthrough.instance; + } + + /** + * Returns an instance of {@link Default}. + */ + public static Default defaultValue() { + return Default.instance; + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned + * its configured off value. + */ + public static class Off extends EvaluationReason { + public Kind getKind() { + return Kind.OFF; + } + + private static final Off instance = new Off(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted + * for this flag. + */ + public static class TargetMatch extends EvaluationReason { + private final int targetIndex; + + private TargetMatch(int targetIndex) { + this.targetIndex = targetIndex; + } + + public Kind getKind() { + return Kind.TARGET_MATCH; + } + + public int getTargetIndex() { + return targetIndex; + } + + @Override + public boolean equals(Object other) { + if (other instanceof TargetMatch) { + TargetMatch o = (TargetMatch)other; + return targetIndex == o.targetIndex; + } + return false; + } + + @Override + public int hashCode() { + return targetIndex; + } + + @Override + public String toString() { + return getKind().name() + "(" + targetIndex + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. + */ + public static class RuleMatch extends EvaluationReason { + private final int ruleIndex; + private final String ruleId; + + private RuleMatch(int ruleIndex, String ruleId) { + this.ruleIndex = ruleIndex; + this.ruleId = ruleId; + } + + public Kind getKind() { + return Kind.RULE_MATCH; + } + + public int getRuleIndex() { + return ruleIndex; + } + + public String getRuleId() { + return ruleId; + } + + @Override + public boolean equals(Object other) { + if (other instanceof RuleMatch) { + RuleMatch o = (RuleMatch)other; + return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(ruleIndex, ruleId); + } + + @Override + public String toString() { + return getKind().name() + "(" + (ruleId == null ? String.valueOf(ruleIndex) : ruleId + ")"); + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it + * had at least one prerequisite flag that either was off or did not return the desired variation. + */ + public static class PrerequisitesFailed extends EvaluationReason { + private final ImmutableList prerequisiteKeys; + + private PrerequisitesFailed(Iterable prerequisiteKeys) { + this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); + } + + public Kind getKind() { + return Kind.PREREQUISITES_FAILED; + } + + public Iterable getPrerequisiteKeys() { + return prerequisiteKeys; + } + + @Override + public boolean equals(Object other) { + if (other instanceof PrerequisitesFailed) { + PrerequisitesFailed o = (PrerequisitesFailed)other; + return prerequisiteKeys.equals(o.prerequisiteKeys); + } + return false; + } + + @Override + public int hashCode() { + return prerequisiteKeys.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + Joiner.on(",").join(prerequisiteKeys) + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not + * match any targets or rules. + */ + public static class Fallthrough extends EvaluationReason { + public Kind getKind() { + return Kind.FALLTHROUGH; + } + + private static final Fallthrough instance = new Fallthrough(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the default value (passed as a parameter + * to one of the {@code variation} methods) was returned. This normally indicates an error condition. + */ + public static class Default extends EvaluationReason { + public Kind getKind() { + return Kind.DEFAULT; + } + + private static final Default instance = new Default(); + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 0ab75e23c..032d75be7 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -2,18 +2,17 @@ import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.Map; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + class FeatureFlag implements VersionedData { private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); @@ -76,16 +75,16 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(details, prereqEvents); } - EvaluationDetails details = EvaluationDetails.off(offVariation, getOffVariationValue()); + EvaluationDetails details = new EvaluationDetails<>(EvaluationReason.off(), offVariation, getOffVariationValue()); return new EvalResult(details, prereqEvents); } // Returning either a JsonElement or null indicating prereq failure/error. private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { - EvaluationDetails prereqErrorResult = checkPrerequisites(user, featureStore, events, eventFactory); - if (prereqErrorResult != null) { - return prereqErrorResult; + EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); + if (prereqFailureReason != null) { + return new EvaluationDetails<>(prereqFailureReason, offVariation, getOffVariationValue()); } // Check to see if targets match @@ -94,8 +93,8 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetails(Reason.TARGET_MATCH, - target.getVariation(), getVariation(target.getVariation()), i, null); + return new EvaluationDetails<>(EvaluationReason.targetMatch(i), + target.getVariation(), getVariation(target.getVariation())); } } } @@ -106,26 +105,26 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetails(Reason.RULE_MATCH, - index, getVariation(index), i, rule.getId()); + return new EvaluationDetails<>(EvaluationReason.ruleMatch(i, rule.getId()), + index, getVariation(index)); } } } // Walk through the fallthrough and see if it matches int index = fallthrough.variationIndexForUser(user, key, salt); - return EvaluationDetails.fallthrough(index, getVariation(index)); + return new EvaluationDetails<>(EvaluationReason.fallthrough(), index, getVariation(index)); } - // Checks prerequisites if any; returns null if successful, or an EvaluationDetails if we have to + // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to // short-circuit due to a prerequisite failure. - private EvaluationDetails checkPrerequisites(LDUser user, FeatureStore featureStore, List events, + private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { if (prerequisites == null) { return null; } - EvaluationDetails ret = null; - boolean prereqOk = true; + List failedPrereqs = null; for (int i = 0; i < prerequisites.size(); i++) { + boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); EvaluationDetails prereqEvalResult = null; @@ -140,18 +139,21 @@ private EvaluationDetails checkPrerequisites(LDUser user, FeatureSt } else { prereqOk = false; } - // We continue to evaluate all prerequisites even if one failed, but set the result to the first failure if any. + // We continue to evaluate all prerequisites even if one failed. if (prereqFeatureFlag != null) { events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { - if (ret == null) { - ret = new EvaluationDetails(Reason.PREREQUISITE_FAILED, - offVariation, getOffVariationValue(), i, prereq.getKey()); + if (failedPrereqs == null) { + failedPrereqs = new ArrayList<>(); } + failedPrereqs.add(prereq.getKey()); } } - return ret; + if (failedPrereqs != null && !failedPrereqs.isEmpty()) { + return EvaluationReason.prerequisitesFailed(failedPrereqs); + } + return null; } JsonElement getOffVariationValue() throws EvaluationException { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 3bf8eed5f..6272d745c 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -2,7 +2,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -256,7 +255,7 @@ private EvaluationDetails evaluateDetail(String featureKey, LDUser user, JsonElement defaultJson, VariationType expectedType) { EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); T resultValue; - if (details.getReason() == Reason.DEFAULT) { + if (details.getReason().getKind() == EvaluationReason.Kind.DEFAULT) { resultValue = defaultValue; } else { try { @@ -266,8 +265,7 @@ private EvaluationDetails evaluateDetail(String featureKey, LDUser user, resultValue = defaultValue; } } - return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue, - details.getMatchIndex(), details.getMatchId()); + return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue); } private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index bb20af90d..eee257264 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -17,6 +17,7 @@ import static com.launchdarkly.client.TestUtil.hasJsonProperty; import static com.launchdarkly.client.TestUtil.isJsonArray; +import static com.launchdarkly.client.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; @@ -87,7 +88,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -105,7 +106,7 @@ public void userIsFilteredInIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -123,7 +124,7 @@ public void featureEventCanContainInlineUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -140,7 +141,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -157,7 +158,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -174,7 +175,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -193,7 +194,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -222,7 +223,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -250,7 +251,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -269,9 +270,9 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); JsonElement value = new JsonPrimitive("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, value), null); + simpleEvaluation(1, value), null); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(1, value), null); + simpleEvaluation(1, value), null); ep.sendEvent(fe1); ep.sendEvent(fe2); @@ -294,9 +295,9 @@ public void nonTrackedEventsAreSummarized() throws Exception { JsonElement default1 = new JsonPrimitive("default1"); JsonElement default2 = new JsonPrimitive("default2"); Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(2, value), default1); + simpleEvaluation(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(2, value), default2); + simpleEvaluation(2, value), default2); ep.sendEvent(fe1); ep.sendEvent(fe2); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 664fb8e56..0c101b3b8 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -6,6 +6,7 @@ import java.util.Map; import static com.launchdarkly.client.TestUtil.js; +import static com.launchdarkly.client.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -65,13 +66,13 @@ public void summarizeEventIncrementsCounters() { FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, js("value1")), js("default1")); + simpleEvaluation(1, js("value1")), js("default1")); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(2, js("value2")), js("default1")); + simpleEvaluation(2, js("value2")), js("default1")); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(1, js("value99")), js("default2")); + simpleEvaluation(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, js("value1")), js("default1")); + simpleEvaluation(1, js("value1")), js("default1")); Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); es.summarizeEvent(event1); es.summarizeEvent(event2); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index be062eb47..530daaf77 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -1,8 +1,8 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.junit.Before; import org.junit.Test; @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.off(1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.off(null, null), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.off(), null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -68,7 +68,8 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -91,7 +92,8 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -120,7 +122,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -157,7 +159,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -173,6 +175,44 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio assertEquals(f0.getKey(), event1.prereqOf); } + @Test + public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 0), new Prerequisite("feature2", 0))) + .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(); + FeatureFlag f2 = new FeatureFlagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(3) + .build(); + featureStore.upsert(FEATURES, f1); + featureStore.upsert(FEATURES, f2); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + + assertEquals(2, result.getPrerequisiteEvents().size()); + + Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event0.key); + + Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); + assertEquals(f2.getKey(), event1.key); + } + @Test public void flagMatchesUserFromTargets() throws Exception { FeatureFlag f = new FeatureFlagBuilder("feature") @@ -185,7 +225,7 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.TARGET_MATCH, 2, js("on"), 0, null), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.targetMatch(0), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -203,7 +243,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 2, js("on"), 0, "ruleid"), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -282,8 +322,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 1, jbool(true), 1, "rule2"), details); + EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); } @Test diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 494586c26..a092eaabe 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -103,6 +103,10 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } + public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { + return new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, value); + } + public static Matcher hasJsonProperty(final String name, JsonElement value) { return hasJsonProperty(name, equalTo(value)); } From 1874e200c1b2b24685273d7603bfeb2ab2e1428f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 16:15:23 -0700 Subject: [PATCH 004/156] fix tests --- src/test/java/com/launchdarkly/client/TestUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index a092eaabe..c36aa6cf0 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -104,7 +104,7 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { } public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { - return new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, value); + return new EvaluationDetails<>(EvaluationReason.fallthrough(), variation, value); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From e694ab47f37028a45156841f41e4fea4bc655ac7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:28:44 -0700 Subject: [PATCH 005/156] misc refactoring & tests --- ...tionDetails.java => EvaluationDetail.java} | 16 +-- .../launchdarkly/client/EvaluationReason.java | 119 ++++++++++++------ .../com/launchdarkly/client/EventFactory.java | 4 +- .../com/launchdarkly/client/FeatureFlag.java | 30 ++--- .../com/launchdarkly/client/LDClient.java | 41 +++--- .../client/LDClientInterface.java | 20 +-- .../launchdarkly/client/FeatureFlagTest.java | 22 ++-- .../client/LDClientEvaluationTest.java | 62 +++++++++ .../com/launchdarkly/client/TestUtil.java | 55 +++++++- 9 files changed, 259 insertions(+), 110 deletions(-) rename src/main/java/com/launchdarkly/client/{EvaluationDetails.java => EvaluationDetail.java} (77%) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java similarity index 77% rename from src/main/java/com/launchdarkly/client/EvaluationDetails.java rename to src/main/java/com/launchdarkly/client/EvaluationDetail.java index 86abb7578..83eecfe89 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetails.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -2,26 +2,28 @@ import com.google.common.base.Objects; +import static com.google.common.base.Preconditions.checkNotNull; + /** * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), * combining the result of a flag evaluation with an explanation of how it was calculated. * @since 4.3.0 */ -public class EvaluationDetails { +public class EvaluationDetail { private final EvaluationReason reason; private final Integer variationIndex; private final T value; - public EvaluationDetails(EvaluationReason reason, Integer variationIndex, T value) { - super(); + public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { + checkNotNull(reason); this.reason = reason; this.variationIndex = variationIndex; this.value = value; } - static EvaluationDetails defaultValue(T value) { - return new EvaluationDetails<>(EvaluationReason.defaultValue(), null, value); + static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, T defaultValue) { + return new EvaluationDetail<>(EvaluationReason.error(errorKind), null, defaultValue); } /** @@ -52,9 +54,9 @@ public T getValue() { @Override public boolean equals(Object other) { - if (other instanceof EvaluationDetails) { + if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") - EvaluationDetails o = (EvaluationDetails)other; + EvaluationDetail o = (EvaluationDetail)other; return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); } return false; diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 592c880be..a1eb6936e 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -5,6 +5,8 @@ import java.util.Objects; +import static com.google.common.base.Preconditions.checkNotNull; + /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). @@ -16,6 +18,10 @@ */ public abstract class EvaluationReason { + /** + * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. + * @since 4.3.0 + */ public static enum Kind { /** * Indicates that the flag was off and therefore returned its configured off value. @@ -39,10 +45,38 @@ public static enum Kind { */ FALLTHROUGH, /** - * Indicates that the default value (passed as a parameter to one of the {@code variation} methods) - * was returned. This normally indicates an error condition. + * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected + * error. In this case the result value will be the default value that the caller passed to the client. + */ + ERROR; + } + + /** + * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. + * @since 4.3.0 + */ + public static enum ErrorKind { + /** + * Indicates that the caller tried to evaluate a flag before the client had successfully initialized. + */ + CLIENT_NOT_READY, + /** + * Indicates that the caller provided a flag key that did not match any known flag. + */ + FLAG_NOT_FOUND, + /** + * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. */ - DEFAULT; + USER_NOT_SPECIFIED, + /** + * Indicates that the result value was not of the requested type, e.g. you called + * {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)} but the value was an integer. + */ + WRONG_TYPE, + /** + * Indicates that an unexpected exception stopped flag evaluation; check the log for details. + */ + EXCEPTION } /** @@ -68,8 +102,8 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. */ - public static TargetMatch targetMatch(int targetIndex) { - return new TargetMatch(targetIndex); + public static TargetMatch targetMatch() { + return TargetMatch.instance; } /** @@ -94,15 +128,16 @@ public static Fallthrough fallthrough() { } /** - * Returns an instance of {@link Default}. + * Returns an instance of {@link Error}. */ - public static Default defaultValue() { - return Default.instance; + public static Error error(ErrorKind errorKind) { + return new Error(errorKind); } /** * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned * its configured off value. + * @since 4.3.0 */ public static class Off extends EvaluationReason { public Kind getKind() { @@ -115,44 +150,19 @@ public Kind getKind() { /** * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted * for this flag. + * @since 4.3.0 */ public static class TargetMatch extends EvaluationReason { - private final int targetIndex; - - private TargetMatch(int targetIndex) { - this.targetIndex = targetIndex; - } - public Kind getKind() { return Kind.TARGET_MATCH; } - public int getTargetIndex() { - return targetIndex; - } - - @Override - public boolean equals(Object other) { - if (other instanceof TargetMatch) { - TargetMatch o = (TargetMatch)other; - return targetIndex == o.targetIndex; - } - return false; - } - - @Override - public int hashCode() { - return targetIndex; - } - - @Override - public String toString() { - return getKind().name() + "(" + targetIndex + ")"; - } + private static final TargetMatch instance = new TargetMatch(); } /** * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. + * @since 4.3.0 */ public static class RuleMatch extends EvaluationReason { private final int ruleIndex; @@ -198,11 +208,13 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it * had at least one prerequisite flag that either was off or did not return the desired variation. + * @since 4.3.0 */ public static class PrerequisitesFailed extends EvaluationReason { private final ImmutableList prerequisiteKeys; private PrerequisitesFailed(Iterable prerequisiteKeys) { + checkNotNull(prerequisiteKeys); this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); } @@ -237,6 +249,7 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not * match any targets or rules. + * @since 4.3.0 */ public static class Fallthrough extends EvaluationReason { public Kind getKind() { @@ -247,14 +260,38 @@ public Kind getKind() { } /** - * Subclass of {@link EvaluationReason} that indicates that the default value (passed as a parameter - * to one of the {@code variation} methods) was returned. This normally indicates an error condition. + * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. + * @since 4.3.0 */ - public static class Default extends EvaluationReason { + public static class Error extends EvaluationReason { + private final ErrorKind errorKind; + + private Error(ErrorKind errorKind) { + checkNotNull(errorKind); + this.errorKind = errorKind; + } + public Kind getKind() { - return Kind.DEFAULT; + return Kind.ERROR; } - private static final Default instance = new Default(); + public ErrorKind getErrorKind() { + return errorKind; + } + + @Override + public boolean equals(Object other) { + return other instanceof Error && errorKind == ((Error) other).errorKind; + } + + @Override + public int hashCode() { + return errorKind.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + errorKind.name() + ")"; + } } } diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 345619b0b..c68468f0b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -7,7 +7,7 @@ abstract class EventFactory { protected abstract long getTimestamp(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetails result, JsonElement defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); @@ -22,7 +22,7 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetails result, + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 032d75be7..5a64f4b01 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; +import static com.google.common.base.Preconditions.checkNotNull; import static com.launchdarkly.client.VersionedDataKind.FEATURES; class FeatureFlag implements VersionedData { @@ -66,25 +67,24 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + key + "; returning null"); - return new EvalResult(null, prereqEvents); + // this should have been prevented by LDClient.evaluateInternal + throw new EvaluationException("null user or null user key"); } if (isOn()) { - EvaluationDetails details = evaluate(user, featureStore, prereqEvents, eventFactory); + EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); return new EvalResult(details, prereqEvents); } - EvaluationDetails details = new EvaluationDetails<>(EvaluationReason.off(), offVariation, getOffVariationValue()); + EvaluationDetail details = new EvaluationDetail<>(EvaluationReason.off(), offVariation, getOffVariationValue()); return new EvalResult(details, prereqEvents); } - // Returning either a JsonElement or null indicating prereq failure/error. - private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, + private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { - return new EvaluationDetails<>(prereqFailureReason, offVariation, getOffVariationValue()); + return new EvaluationDetail<>(prereqFailureReason, offVariation, getOffVariationValue()); } // Check to see if targets match @@ -93,7 +93,7 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetails<>(EvaluationReason.targetMatch(i), + return new EvaluationDetail<>(EvaluationReason.targetMatch(), target.getVariation(), getVariation(target.getVariation())); } } @@ -105,14 +105,14 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetails<>(EvaluationReason.ruleMatch(i, rule.getId()), + return new EvaluationDetail<>(EvaluationReason.ruleMatch(i, rule.getId()), index, getVariation(index)); } } } // Walk through the fallthrough and see if it matches int index = fallthrough.variationIndexForUser(user, key, salt); - return new EvaluationDetails<>(EvaluationReason.fallthrough(), index, getVariation(index)); + return new EvaluationDetail<>(EvaluationReason.fallthrough(), index, getVariation(index)); } // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to @@ -127,7 +127,7 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - EvaluationDetails prereqEvalResult = null; + EvaluationDetail prereqEvalResult = null; if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); prereqOk = false; @@ -237,15 +237,17 @@ Integer getOffVariation() { } static class EvalResult { - private final EvaluationDetails details; + private final EvaluationDetail details; private final List prerequisiteEvents; - private EvalResult(EvaluationDetails details, List prerequisiteEvents) { + private EvalResult(EvaluationDetail details, List prerequisiteEvents) { + checkNotNull(details); + checkNotNull(prerequisiteEvents); this.details = details; this.prerequisiteEvents = prerequisiteEvents; } - EvaluationDetails getDetails() { + EvaluationDetail getDetails() { return details; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6272d745c..6827585f1 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -201,27 +201,27 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def } @Override - public EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue) { + public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); } @Override - public EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue) { + public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); } @Override - public EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue) { + public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); } @Override - public EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue) { + public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); } @Override - public EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue) { + public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); } @@ -251,31 +251,31 @@ private T evaluate(String featureKey, LDUser user, T defaultValue, JsonEleme return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); } - private EvaluationDetails evaluateDetail(String featureKey, LDUser user, T defaultValue, + private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { - EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); + EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson); T resultValue; - if (details.getReason().getKind() == EvaluationReason.Kind.DEFAULT) { + if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; } else { try { resultValue = expectedType.coerceValue(details.getValue()); } catch (EvaluationException e) { logger.error("Encountered exception in LaunchDarkly client: " + e); - resultValue = defaultValue; + return EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); } } - return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue); + return new EvaluationDetail(details.getReason(), details.getVariationIndex(), resultValue); } - private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { + private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } @@ -284,12 +284,12 @@ private EvaluationDetails evaluateInternal(String featureKey, LDUse if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); @@ -298,19 +298,14 @@ private EvaluationDetails evaluateInternal(String featureKey, LDUse for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - if (evalResult.getDetails() != null && evalResult.getDetails().getValue() != null) { - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); - return evalResult.getDetails(); - } else { - sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); - } + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); + return evalResult.getDetails(); } catch (Exception e) { logger.error("Encountered exception in LaunchDarkly client: " + e); logger.debug(e.getMessage(), e); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); } - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index c5fb4280a..f1d984d86 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -105,10 +105,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue); + EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -116,10 +116,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue); + EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -127,10 +127,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue); + EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -138,10 +138,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue); + EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -149,10 +149,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue); + EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue); /** * Returns true if the specified feature flag currently exists. diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 530daaf77..ac32c3a89 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.off(), null, null), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.off(), null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -69,7 +69,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -93,7 +93,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -122,7 +122,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -159,7 +159,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -202,7 +202,7 @@ public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); @@ -225,7 +225,7 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.targetMatch(0), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.targetMatch(), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -243,7 +243,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -322,8 +322,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); + EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); } @Test diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index c6c17ebba..ca978d4f2 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -8,12 +8,15 @@ import java.util.Arrays; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; +import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; +import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; import static com.launchdarkly.client.TestUtil.flagWithValue; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; @@ -109,4 +112,63 @@ public void canMatchUserBySegment() throws Exception { assertTrue(client.boolVariation("feature", user, false)); } + + @Test + public void canGetDetailsForSuccessfulEvaluation() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = new EvaluationDetail<>(EvaluationReason.off(), 0, true); + assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + } + + @Test + public void appropriateErrorIfClientNotInitialized() throws Exception { + FeatureStore badFeatureStore = new InMemoryFeatureStore(); + LDConfig badConfig = new LDConfig.Builder() + .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .eventProcessorFactory(Components.nullEventProcessor()) + .updateProcessorFactory(specificUpdateProcessor(failedUpdateProcessor())) + .startWaitMillis(0) + .build(); + try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, false); + assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); + } + } + + @Test + public void appropriateErrorIfFlagDoesNotExist() throws Exception { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, false); + assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + } + + @Test + public void appropriateErrorIfUserNotSpecified() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, false); + assertEquals(expectedResult, client.boolVariationDetail("key", null, false)); + } + + @Test + public void appropriateErrorIfValueWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, 3); + assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); + } + + @Test + public void appropriateErrorForUnexpectedException() throws Exception { + FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + LDConfig badConfig = new LDConfig.Builder() + .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .eventProcessorFactory(Components.nullEventProcessor()) + .updateProcessorFactory(Components.nullUpdateProcessor()) + .build(); + try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, false); + assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); + } + } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index c36aa6cf0..a55f81fb9 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -14,6 +15,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.Future; import static org.hamcrest.Matchers.equalTo; @@ -48,7 +50,56 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } }; } + + public static FeatureStore featureStoreThatThrowsException(final RuntimeException e) { + return new FeatureStore() { + @Override + public void close() throws IOException { } + + @Override + public T get(VersionedDataKind kind, String key) { + throw e; + } + + @Override + public Map all(VersionedDataKind kind) { + throw e; + } + + @Override + public void init(Map, Map> allData) { } + + @Override + public void delete(VersionedDataKind kind, String key, int version) { } + + @Override + public void upsert(VersionedDataKind kind, T item) { } + + @Override + public boolean initialized() { + return true; + } + }; + } + public static UpdateProcessor failedUpdateProcessor() { + return new UpdateProcessor() { + @Override + public Future start() { + return SettableFuture.create(); + } + + @Override + public boolean initialized() { + return false; + } + + @Override + public void close() throws IOException { + } + }; + } + public static class TestEventProcessor implements EventProcessor { List events = new ArrayList<>(); @@ -103,8 +154,8 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } - public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { - return new EvaluationDetails<>(EvaluationReason.fallthrough(), variation, value); + public static EvaluationDetail simpleEvaluation(int variation, JsonElement value) { + return new EvaluationDetail<>(EvaluationReason.fallthrough(), variation, value); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From d692bb325c1fc1cabe756dcee2fe1af031646305 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:38:27 -0700 Subject: [PATCH 006/156] don't need array index --- src/main/java/com/launchdarkly/client/FeatureFlag.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 5a64f4b01..736c20f6b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -89,8 +89,7 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature // Check to see if targets match if (targets != null) { - for (int i = 0; i < targets.size(); i++) { - Target target = targets.get(i); + for (Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { return new EvaluationDetail<>(EvaluationReason.targetMatch(), From 9eb8b34acdce026e0840931cd9c9c171117d8395 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:56:07 -0700 Subject: [PATCH 007/156] stop using deprecated TestFeatureStore in tests --- .../client/LDClientEventTest.java | 18 ++++++++----- .../client/LDClientLddModeTest.java | 8 ++++-- .../client/LDClientOfflineTest.java | 9 +++++-- .../com/launchdarkly/client/LDClientTest.java | 27 +++++++++---------- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 8123e6e8f..ca6cf285d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -9,6 +9,7 @@ import java.util.Arrays; import static com.launchdarkly.client.TestUtil.fallthroughVariation; +import static com.launchdarkly.client.TestUtil.flagWithValue; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; @@ -22,7 +23,7 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); - private TestFeatureStore featureStore = new TestFeatureStore(); + private FeatureStore featureStore = TestUtil.initedFeatureStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) @@ -72,7 +73,8 @@ public void trackSendsEventWithData() throws Exception { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", jbool(true)); + featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -88,7 +90,8 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setIntegerValue("key", 2); + FeatureFlag flag = flagWithValue("key", jint(2)); + featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -104,7 +107,8 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setDoubleValue("key", 2.5d); + FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -120,7 +124,8 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setStringValue("key", "b"); + FeatureFlag flag = flagWithValue("key", js("b")); + featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -138,7 +143,8 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { public void jsonVariationSendsEvent() throws Exception { JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - FeatureFlag flag = featureStore.setJsonValue("key", data); + FeatureFlag flag = flagWithValue("key", data); + featureStore.upsert(FEATURES, flag); JsonElement defaultVal = new JsonPrimitive(42); client.jsonVariation("key", user, defaultVal); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index 62a584c35..f77030875 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -4,7 +4,10 @@ import java.io.IOException; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -41,12 +44,13 @@ public void lddModeClientIsInitialized() throws IOException { @Test public void lddModeClientGetsFlagFromFeatureStore() throws IOException { - TestFeatureStore testFeatureStore = new TestFeatureStore(); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", TestUtil.jbool(true)); + testFeatureStore.upsert(FEATURES, flag); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.boolVariation("key", new LDUser("user"), false)); } diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 5a8369f49..51377e123 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -8,7 +8,11 @@ import java.io.IOException; import java.util.Map; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; +import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -56,12 +60,13 @@ public void offlineClientReturnsDefaultValue() throws IOException { @Test public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { - TestFeatureStore testFeatureStore = new TestFeatureStore(); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", jbool(true)); + testFeatureStore.upsert(FEATURES, flag); try (LDClient client = new LDClient("SDK_KEY", config)) { Map allFlags = client.allFlags(new LDUser("user")); assertNotNull(allFlags); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index cd50a1c57..b95b070e3 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -10,7 +10,11 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; +import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; @@ -152,8 +156,7 @@ public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception @Test public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -163,15 +166,14 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -187,8 +189,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { @Test public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(false); + FeatureStore testFeatureStore = new InMemoryFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -198,15 +199,14 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertFalse(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -216,15 +216,14 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(testFeatureStore)) .startWaitMillis(0L); @@ -235,7 +234,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); verifyAll(); From afdb396ef78d4220affd45718ddd8da56a1c2bac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 2 Jul 2018 17:47:29 -0700 Subject: [PATCH 008/156] add brief Java compatibility note to readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9f7c2f290..876ef8950 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ LaunchDarkly SDK for Java [![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-client.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-client) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client?ref=badge_shield) +Supported Java versions +----------------------- + +This version of the LaunchDarkly SDK works with Java 7 and above. + Quick setup ----------- From b9e06344cba629593e84257382253c7d8bccdea0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 13 Jul 2018 17:02:46 -0700 Subject: [PATCH 009/156] avoid unnecessary retry after Redis update --- src/main/java/com/launchdarkly/client/RedisFeatureStore.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index d4724a40f..1a9c1935a 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -259,6 +259,7 @@ private void updateItemWithVersioning(VersionedDataKin if (cache != null) { cache.invalidate(new CacheKey(kind, newItem.getKey())); } + return; } finally { if (jedis != null) { jedis.unwatch(); From 742514eef27fdc50bfec7c37903439482c691e48 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 12:58:00 -0700 Subject: [PATCH 010/156] fix javadoc errors --- .../java/com/launchdarkly/client/EvaluationDetail.java | 2 +- .../java/com/launchdarkly/client/EvaluationReason.java | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 83eecfe89..5ea06b20b 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -5,7 +5,7 @@ import static com.google.common.base.Preconditions.checkNotNull; /** - * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), + * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, * combining the result of a flag evaluation with an explanation of how it was calculated. * @since 4.3.0 */ diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index a1eb6936e..03f7a0ca8 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -9,7 +9,7 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by - * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). + * methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}. * * Note that this is an enum-like class hierarchy rather than an enum, because some of the * possible reasons have their own properties. @@ -94,6 +94,7 @@ private EvaluationReason() { } /** * Returns an instance of {@link Off}. + * @return a reason object */ public static Off off() { return Off.instance; @@ -101,6 +102,7 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. + * @return a reason object */ public static TargetMatch targetMatch() { return TargetMatch.instance; @@ -108,6 +110,7 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { return new RuleMatch(ruleIndex, ruleId); @@ -115,6 +118,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisitesFailed}. + * @return a reason object */ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { return new PrerequisitesFailed(prerequisiteKeys); @@ -122,6 +126,7 @@ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequis /** * Returns an instance of {@link Fallthrough}. + * @return a reason object */ public static Fallthrough fallthrough() { return Fallthrough.instance; @@ -129,6 +134,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * @return a reason object */ public static Error error(ErrorKind errorKind) { return new Error(errorKind); From b388b63eea3317c9bd4de28d111b0bccab88d0b1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 14:52:20 -0700 Subject: [PATCH 011/156] include explanations, if requested, in full feature request events --- .../com/launchdarkly/client/Components.java | 8 + .../launchdarkly/client/EvaluationReason.java | 4 + .../java/com/launchdarkly/client/Event.java | 10 +- .../com/launchdarkly/client/EventFactory.java | 35 +++- .../com/launchdarkly/client/EventOutput.java | 7 +- .../com/launchdarkly/client/LDClient.java | 58 ++++-- .../launchdarkly/client/TestFeatureStore.java | 7 + .../client/EventSummarizerTest.java | 7 +- .../client/LDClientEventTest.java | 179 ++++++++++++++++-- 9 files changed, 265 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 5fcc53be2..ac017b7a0 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -18,6 +18,7 @@ public abstract class Components { /** * Returns a factory for the default in-memory implementation of {@link FeatureStore}. + * @return a factory object */ public static FeatureStoreFactory inMemoryFeatureStore() { return inMemoryFeatureStoreFactory; @@ -26,6 +27,7 @@ public static FeatureStoreFactory inMemoryFeatureStore() { /** * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, * using {@link RedisFeatureStoreBuilder#DEFAULT_URI}. + * @return a factory/builder object */ public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); @@ -34,6 +36,8 @@ public static RedisFeatureStoreBuilder redisFeatureStore() { /** * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, * specifying the Redis URI. + * @param redisUri the URI of the Redis host + * @return a factory/builder object */ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); @@ -43,6 +47,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * Returns a factory for the default implementation of {@link EventProcessor}, which * forwards all analytics events to LaunchDarkly (unless the client is offline or you have * set {@link LDConfig.Builder#sendEvents(boolean)} to {@code false}). + * @return a factory object */ public static EventProcessorFactory defaultEventProcessor() { return defaultEventProcessorFactory; @@ -51,6 +56,7 @@ public static EventProcessorFactory defaultEventProcessor() { /** * Returns a factory for a null implementation of {@link EventProcessor}, which will discard * all analytics events and not send them to LaunchDarkly, regardless of any other configuration. + * @return a factory object */ public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; @@ -60,6 +66,7 @@ public static EventProcessorFactory nullEventProcessor() { * Returns a factory for the default implementation of {@link UpdateProcessor}, which receives * feature flag data from LaunchDarkly using either streaming or polling as configured (or does * nothing if the client is offline, or in LDD mode). + * @return a factory object */ public static UpdateProcessorFactory defaultUpdateProcessor() { return defaultUpdateProcessorFactory; @@ -68,6 +75,7 @@ public static UpdateProcessorFactory defaultUpdateProcessor() { /** * Returns a factory for a null implementation of {@link UpdateProcessor}, which does not * connect to LaunchDarkly, regardless of any other configuration. + * @return a factory object */ public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 03f7a0ca8..679b007e3 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -110,6 +110,8 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * @param ruleIndex the rule index + * @param ruleId the rule identifier * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { @@ -118,6 +120,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisitesFailed}. + * @param prerequisiteKeys the list of flag keys of prerequisites that failed * @return a reason object */ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { @@ -134,6 +137,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * @param errorKind describes the type of error * @return a reason object */ public static Error error(ErrorKind errorKind) { diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java index ec10cbbef..a65a27d01 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/Event.java @@ -46,10 +46,17 @@ public static final class FeatureRequest extends Event { final String prereqOf; final boolean trackEvents; final Long debugEventsUntilDate; + final EvaluationReason reason; final boolean debug; - + + @Deprecated public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + this(timestamp, key, user, version, variation, value, defaultVal, prereqOf, trackEvents, debugEventsUntilDate, null, debug); + } + + public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, + JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, EvaluationReason reason, boolean debug) { super(timestamp, user); this.key = key; this.version = version; @@ -59,6 +66,7 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, this.prereqOf = prereqOf; this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; this.debug = debug; } } diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index c68468f0b..13559cf97 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -3,35 +3,43 @@ import com.google.gson.JsonElement; abstract class EventFactory { - public static final EventFactory DEFAULT = new DefaultEventFactory(); + public static final EventFactory DEFAULT = new DefaultEventFactory(false); + public static final EventFactory DEFAULT_WITH_REASONS = new DefaultEventFactory(true); protected abstract long getTimestamp(); + protected abstract boolean isIncludeReasons(); public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } - public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue) { + public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue, + EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + null, defaultValue, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); } - public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, JsonElement defaultValue) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); + public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, JsonElement defaultValue, + EvaluationReason.ErrorKind errorKind) { + return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); } public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), false); + null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { return new Event.FeatureRequest(from.creationDate, from.key, from.user, from.version, from.variation, from.value, - from.defaultVal, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); + from.defaultVal, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, from.reason, true); } public Event.Custom newCustomEvent(String key, LDUser user, JsonElement data) { @@ -43,9 +51,20 @@ public Event.Identify newIdentifyEvent(LDUser user) { } public static class DefaultEventFactory extends EventFactory { + private final boolean includeReasons; + + public DefaultEventFactory(boolean includeReasons) { + this.includeReasons = includeReasons; + } + @Override protected long getTimestamp() { return System.currentTimeMillis(); } + + @Override + protected boolean isIncludeReasons() { + return includeReasons; + } } } diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java index 016e52f32..7d471086e 100644 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ b/src/main/java/com/launchdarkly/client/EventOutput.java @@ -44,9 +44,11 @@ static final class FeatureRequest extends EventOutputWithTimestamp { private final JsonElement value; @SerializedName("default") private final JsonElement defaultVal; private final String prereqOf; + private final EvaluationReason reason; FeatureRequest(long creationDate, String key, String userKey, LDUser user, - Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, boolean debug) { + Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, + EvaluationReason reason, boolean debug) { super(debug ? "debug" : "feature", creationDate); this.key = key; this.userKey = userKey; @@ -56,6 +58,7 @@ static final class FeatureRequest extends EventOutputWithTimestamp { this.value = value; this.defaultVal = defaultVal; this.prereqOf = prereqOf; + this.reason = reason; } } @@ -163,7 +166,7 @@ private EventOutput createOutputEvent(Event e) { return new EventOutput.FeatureRequest(fe.creationDate, fe.key, inlineThisUser ? null : userKey, inlineThisUser ? e.user : null, - fe.version, fe.variation, fe.value, fe.defaultVal, fe.prereqOf, fe.debug); + fe.version, fe.variation, fe.value, fe.defaultVal, fe.prereqOf, fe.reason, fe.debug); } else if (e instanceof Event.Identify) { return new EventOutput.Identify(e.creationDate, e.user); } else if (e instanceof Event.Custom) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6827585f1..d7e3fb3a5 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -40,8 +40,7 @@ public final class LDClient implements LDClientInterface { final UpdateProcessor updateProcessor; final FeatureStore featureStore; final boolean shouldCloseFeatureStore; - private final EventFactory eventFactory = EventFactory.DEFAULT; - + /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most * cases, you should use this constructor. @@ -116,7 +115,7 @@ public void track(String eventName, LDUser user, JsonElement data) { if (user == null || user.getKey() == null) { logger.warn("Track called with null user or null user key!"); } - eventProcessor.sendEvent(eventFactory.newCustomEvent(eventName, user, data)); + eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data)); } @Override @@ -132,7 +131,7 @@ public void identify(LDUser user) { if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); } - eventProcessor.sendEvent(eventFactory.newIdentifyEvent(user)); + eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); } private void sendFlagRequestEvent(Event.FeatureRequest event) { @@ -165,9 +164,8 @@ public Map allFlags(LDUser user) { for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getDetails().getValue(); - result.put(entry.getKey(), evalResult); - + JsonElement evalResult = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue(); + result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { logger.error("Exception caught when evaluating all flags:", e); } @@ -202,27 +200,32 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); + return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json, + EventFactory.DEFAULT_WITH_REASONS); } @Override @@ -248,12 +251,12 @@ public boolean isFlagKnown(String featureKey) { } private T evaluate(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { - return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); + return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType, EventFactory.DEFAULT).getValue(); } private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, - JsonElement defaultJson, VariationType expectedType) { - EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson); + JsonElement defaultJson, VariationType expectedType, EventFactory eventFactory) { + EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson, eventFactory); T resultValue; if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; @@ -268,13 +271,14 @@ private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T return new EvaluationDetail(details.getReason(), details.getVariationIndex(), resultValue); } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { + private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue, EventFactory eventFactory) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.CLIENT_NOT_READY)); return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } @@ -283,18 +287,29 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser FeatureFlag featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); - sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } - FeatureFlag.EvalResult evalResult = featureFlag.evaluate(user, featureStore, eventFactory); + FeatureFlag.EvalResult evalResult; + try { + evalResult = featureFlag.evaluate(user, featureStore, eventFactory); + } catch (Exception e) { + logger.error("Encountered exception in LaunchDarkly client: " + e); + logger.debug(e.getMessage(), e);; + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationReason.ErrorKind.EXCEPTION)); + return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); + } for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } @@ -303,7 +318,8 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser } catch (Exception e) { logger.error("Encountered exception in LaunchDarkly client: " + e); logger.debug(e.getMessage(), e); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.EXCEPTION)); return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); } } diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index afebbad2f..39d20e7cd 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -32,6 +32,7 @@ public class TestFeatureStore extends InMemoryFeatureStore { * * @param key the key of the feature flag * @param value the new value of the feature flag + * @return the feature flag */ public FeatureFlag setBooleanValue(String key, Boolean value) { FeatureFlag newFeature = new FeatureFlagBuilder(key) @@ -49,6 +50,7 @@ public FeatureFlag setBooleanValue(String key, Boolean value) { * If the feature rule is not currently in the store, it will create one that is true for every {@link LDUser}. * * @param key the key of the feature flag to evaluate to true + * @return the feature flag */ public FeatureFlag setFeatureTrue(String key) { return setBooleanValue(key, true); @@ -59,6 +61,7 @@ public FeatureFlag setFeatureTrue(String key) { * If the feature rule is not currently in the store, it will create one that is false for every {@link LDUser}. * * @param key the key of the feature flag to evaluate to false + * @return the feature flag */ public FeatureFlag setFeatureFalse(String key) { return setBooleanValue(key, false); @@ -68,6 +71,7 @@ public FeatureFlag setFeatureFalse(String key) { * Sets the value of an integer multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setIntegerValue(String key, Integer value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -77,6 +81,7 @@ public FeatureFlag setIntegerValue(String key, Integer value) { * Sets the value of a double multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setDoubleValue(String key, Double value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -86,6 +91,7 @@ public FeatureFlag setDoubleValue(String key, Double value) { * Sets the value of a string multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setStringValue(String key, String value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -95,6 +101,7 @@ public FeatureFlag setStringValue(String key, String value) { * Sets the value of a JsonElement multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setJsonValue(String key, JsonElement value) { FeatureFlag newFeature = new FeatureFlagBuilder(key) diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 0c101b3b8..f64ba29bd 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -20,6 +20,11 @@ public class EventSummarizerTest { protected long getTimestamp() { return eventTimestamp; } + + @Override + protected boolean isIncludeReasons() { + return false; + } }; @Test @@ -73,7 +78,7 @@ public void summarizeEventIncrementsCounters() { simpleEvaluation(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, js("value1")), js("default1")); - Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); + Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); es.summarizeEvent(event1); es.summarizeEvent(event2); es.summarizeEvent(event3); diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index ca6cf285d..7c231a39c 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,8 +1,10 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.EvaluationReason.ErrorKind; import org.junit.Test; @@ -78,16 +80,34 @@ public void boolVariationSendsEvent() throws Exception { client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null); + checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null, null); } @Test public void boolVariationSendsEventForUnknownFlag() throws Exception { client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null, null); } + @Test + public void boolVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jbool(true)); + featureStore.upsert(FEATURES, flag); + + client.boolVariationDetail("key", user, false); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null, EvaluationReason.off()); + } + + @Test + public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { + client.boolVariationDetail("key", user, false); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void intVariationSendsEvent() throws Exception { FeatureFlag flag = flagWithValue("key", jint(2)); @@ -95,14 +115,32 @@ public void intVariationSendsEvent() throws Exception { client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null); + checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null, null); } @Test public void intVariationSendsEventForUnknownFlag() throws Exception { client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null, null); + } + + @Test + public void intVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jint(2)); + featureStore.upsert(FEATURES, flag); + + client.intVariationDetail("key", user, 1); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null, EvaluationReason.off()); + } + + @Test + public void intVariationDetailSendsEventForUnknownFlag() throws Exception { + client.intVariationDetail("key", user, 1); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test @@ -112,16 +150,34 @@ public void doubleVariationSendsEvent() throws Exception { client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null); + checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null, null); } @Test public void doubleVariationSendsEventForUnknownFlag() throws Exception { client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null, null); } - + + @Test + public void doubleVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + featureStore.upsert(FEATURES, flag); + + client.doubleVariationDetail("key", user, 1.0d); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null, EvaluationReason.off()); + } + + @Test + public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { + client.doubleVariationDetail("key", user, 1.0d); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void stringVariationSendsEvent() throws Exception { FeatureFlag flag = flagWithValue("key", js("b")); @@ -129,16 +185,34 @@ public void stringVariationSendsEvent() throws Exception { client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null); + checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null, null); } @Test public void stringVariationSendsEventForUnknownFlag() throws Exception { client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null, null); } - + + @Test + public void stringVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", js("b")); + featureStore.upsert(FEATURES, flag); + + client.stringVariationDetail("key", user, "a"); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null, EvaluationReason.off()); + } + + @Test + public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { + client.stringVariationDetail("key", user, "a"); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void jsonVariationSendsEvent() throws Exception { JsonObject data = new JsonObject(); @@ -149,7 +223,7 @@ public void jsonVariationSendsEvent() throws Exception { client.jsonVariation("key", user, defaultVal); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null); + checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, null); } @Test @@ -158,7 +232,30 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { client.jsonVariation("key", user, defaultVal); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, null); + } + + @Test + public void jsonVariationDetailSendsEvent() throws Exception { + JsonObject data = new JsonObject(); + data.addProperty("thing", "stuff"); + FeatureFlag flag = flagWithValue("key", data); + featureStore.upsert(FEATURES, flag); + JsonElement defaultVal = new JsonPrimitive(42); + + client.jsonVariationDetail("key", user, defaultVal); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, EvaluationReason.off()); + } + + @Test + public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { + JsonElement defaultVal = new JsonPrimitive(42); + + client.jsonVariationDetail("key", user, defaultVal); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test @@ -183,8 +280,34 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { client.stringVariation("feature0", user, "default"); assertEquals(2, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0"); - checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null); + checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0", null); + checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null, null); + } + + @Test + public void eventIsSentWithReasonForExistingPrererequisiteFlag() 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(FEATURES, f0); + featureStore.upsert(FEATURES, f1); + + client.stringVariationDetail("feature0", user, "default"); + + assertEquals(2, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0", EvaluationReason.fallthrough()); + checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null, EvaluationReason.fallthrough()); } @Test @@ -202,11 +325,30 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { client.stringVariation("feature0", user, "default"); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null); + checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, null); + } + + @Test + public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() 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(); + featureStore.upsert(FEATURES, f0); + + client.stringVariationDetail("feature0", user, "default"); + + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, + EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1"))); } private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, JsonElement defaultVal, - String prereqOf) { + String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(flag.getKey(), fe.key); @@ -215,9 +357,11 @@ private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, Jso assertEquals(value, fe.value); assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); + assertEquals(reason, fe.reason); } - private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf) { + private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf, + EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(key, fe.key); @@ -226,5 +370,6 @@ private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVa assertEquals(defaultVal, fe.value); assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); + assertEquals(reason, fe.reason); } } From 64fe12e88a83ac7ac82bf393cafaa90e4dd73d26 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 15:01:49 -0700 Subject: [PATCH 012/156] add unit test for reason property in full feature event --- .../client/DefaultEventProcessorTest.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index ab63f6432..c8184c38c 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -151,6 +151,24 @@ public void userIsFilteredInFeatureEvent() throws Exception { )); } + @SuppressWarnings("unchecked") + @Test + public void featureEventCanContainReason() throws Exception { + ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); + FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + EvaluationReason reason = EvaluationReason.ruleMatch(1, null); + Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, + new EvaluationDetail(reason, 1, new JsonPrimitive("value")), null); + ep.sendEvent(fe); + + JsonArray output = flushAndGetEvents(new MockResponse()); + assertThat(output, hasItems( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null, reason), + isSummaryEvent() + )); + } + @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { @@ -494,8 +512,13 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - @SuppressWarnings("unchecked") private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser) { + return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); + } + + @SuppressWarnings("unchecked") + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser, + EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), hasJsonProperty("creationDate", (double)sourceEvent.creationDate), @@ -506,7 +529,9 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Fe (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)) + hasJsonProperty("user", nullValue(JsonElement.class)), + (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : + hasJsonProperty("reason", gson.toJsonTree(reason)) ); } From ebfb18a230c82883e194247eeaa3b6ccb11e2fdc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 15:15:05 -0700 Subject: [PATCH 013/156] add javadoc note about reasons in events --- .../launchdarkly/client/LDClientInterface.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index f1d984d86..c851d38ef 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -101,7 +101,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -112,7 +113,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -123,7 +125,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -134,7 +137,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -145,7 +149,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag From 4810c14178584f84a596153106758b0ecfd6d21d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 11:01:46 -0700 Subject: [PATCH 014/156] add unit test to verify that the reason object can return a non-zero rule index --- .../java/com/launchdarkly/client/FeatureFlagTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index ac32c3a89..d77fa3c3d 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -231,11 +231,13 @@ public void flagMatchesUserFromTargets() throws Exception { @Test public void flagMatchesUserFromRules() throws Exception { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), 2, null); + Clause clause0 = new Clause("key", Operator.in, Arrays.asList(js("wrongkey")), false); + Clause clause1 = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); + Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) - .rules(Arrays.asList(rule)) + .rules(Arrays.asList(rule0, rule1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(js("fall"), js("off"), js("on")) @@ -243,7 +245,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "ruleid1"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } From 52951a89ad1fe35d991a5fc5640687f8d8ed489e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:56:49 -0700 Subject: [PATCH 015/156] always include ruleIndex in toString() --- src/main/java/com/launchdarkly/client/EvaluationReason.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 679b007e3..b9c2a8809 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -211,7 +211,7 @@ public int hashCode() { @Override public String toString() { - return getKind().name() + "(" + (ruleId == null ? String.valueOf(ruleIndex) : ruleId + ")"); + return getKind().name() + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")"; } } From fa5df96e796c8662bd72ad54f7ee68e145368bb0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:57:03 -0700 Subject: [PATCH 016/156] make sure kind property gets serialized to JSON --- .../launchdarkly/client/EvaluationReason.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index b9c2a8809..66900cc2f 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -79,18 +79,26 @@ public static enum ErrorKind { EXCEPTION } + private final Kind kind; + /** * Returns an enum indicating the general category of the reason. * @return a {@link Kind} value */ - public abstract Kind getKind(); + public Kind getKind() + { + return kind; + } @Override public String toString() { return getKind().name(); } - private EvaluationReason() { } + protected EvaluationReason(Kind kind) + { + this.kind = kind; + } /** * Returns an instance of {@link Off}. @@ -150,8 +158,8 @@ public static Error error(ErrorKind errorKind) { * @since 4.3.0 */ public static class Off extends EvaluationReason { - public Kind getKind() { - return Kind.OFF; + private Off() { + super(Kind.OFF); } private static final Off instance = new Off(); @@ -163,8 +171,9 @@ public Kind getKind() { * @since 4.3.0 */ public static class TargetMatch extends EvaluationReason { - public Kind getKind() { - return Kind.TARGET_MATCH; + private TargetMatch() + { + super(Kind.TARGET_MATCH); } private static final TargetMatch instance = new TargetMatch(); @@ -179,14 +188,11 @@ public static class RuleMatch extends EvaluationReason { private final String ruleId; private RuleMatch(int ruleIndex, String ruleId) { + super(Kind.RULE_MATCH); this.ruleIndex = ruleIndex; this.ruleId = ruleId; } - public Kind getKind() { - return Kind.RULE_MATCH; - } - public int getRuleIndex() { return ruleIndex; } @@ -224,14 +230,11 @@ public static class PrerequisitesFailed extends EvaluationReason { private final ImmutableList prerequisiteKeys; private PrerequisitesFailed(Iterable prerequisiteKeys) { + super(Kind.PREREQUISITES_FAILED); checkNotNull(prerequisiteKeys); this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); } - public Kind getKind() { - return Kind.PREREQUISITES_FAILED; - } - public Iterable getPrerequisiteKeys() { return prerequisiteKeys; } @@ -262,8 +265,9 @@ public String toString() { * @since 4.3.0 */ public static class Fallthrough extends EvaluationReason { - public Kind getKind() { - return Kind.FALLTHROUGH; + private Fallthrough() + { + super(Kind.FALLTHROUGH); } private static final Fallthrough instance = new Fallthrough(); @@ -277,14 +281,11 @@ public static class Error extends EvaluationReason { private final ErrorKind errorKind; private Error(ErrorKind errorKind) { + super(Kind.ERROR); checkNotNull(errorKind); this.errorKind = errorKind; } - public Kind getKind() { - return Kind.ERROR; - } - public ErrorKind getErrorKind() { return errorKind; } From 849f085a86a05cc5c1a8fd82d351d835a4f255c5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:58:04 -0700 Subject: [PATCH 017/156] version 4.3.0-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3035e6ef6..8c62155b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=4.2.0 +version=4.3.0-SNAPSHOT ossrhUsername= ossrhPassword= From 2bae5ab2ca7ea2100bcd64a8aeda5eabacdaa891 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 10:59:35 -0700 Subject: [PATCH 018/156] better error logging practices --- .../java/com/launchdarkly/client/Clause.java | 6 +-- .../client/DefaultEventProcessor.java | 8 ++-- .../com/launchdarkly/client/FeatureFlag.java | 4 +- .../com/launchdarkly/client/LDClient.java | 38 ++++++++++++------- .../client/NewRelicReflector.java | 3 +- .../launchdarkly/client/PollingProcessor.java | 3 +- .../launchdarkly/client/StreamProcessor.java | 10 +++-- 7 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 65cb756dc..2f69bf79b 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -38,7 +38,7 @@ boolean matchesUserNoSegments(LDUser user) { JsonArray array = userValue.getAsJsonArray(); for (JsonElement jsonElement : array) { if (!jsonElement.isJsonPrimitive()) { - logger.error("Invalid custom attribute value in user object: " + jsonElement); + logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), jsonElement); return false; } if (matchAny(jsonElement.getAsJsonPrimitive())) { @@ -49,8 +49,8 @@ boolean matchesUserNoSegments(LDUser user) { } else if (userValue.isJsonPrimitive()) { return maybeNegate(matchAny(userValue.getAsJsonPrimitive())); } - logger.warn("Got unexpected user attribute type: " + userValue.getClass().getName() + " for user key: " - + user.getKey() + " and attribute: " + attribute); + logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", + userValue.getClass().getName(), user.getKey(), attribute); return false; } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index c165d8f9b..eed973fa2 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -255,8 +255,8 @@ private void runMainLoop(BlockingQueue inputChannel, message.completed(); } catch (InterruptedException e) { } catch (Exception e) { - logger.error("Unexpected error in event processor: " + e); - logger.debug(e.getMessage(), e); + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); } } } @@ -493,8 +493,8 @@ public void run() { postEvents(eventsOut); } } catch (Exception e) { - logger.error("Unexpected error in event processor: " + e); - logger.debug(e.getMessage(), e); + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); } synchronized (activeFlushWorkersCount) { activeFlushWorkersCount.decrementAndGet(); diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index faff33c6b..2b06efe03 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -65,7 +65,7 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + key + "; returning null"); + logger.warn("Null user or null user key when evaluating flag \"{}\"; returning null", key); return new EvalResult(null, prereqEvents); } @@ -87,7 +87,7 @@ private VariationAndValue evaluate(LDUser user, FeatureStore featureStore, List< FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); VariationAndValue prereqEvalResult = null; if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); + logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); return null; } else if (prereqFeatureFlag.isOn()) { prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..9dba6eca0 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -95,7 +95,8 @@ public LDClient(String sdkKey, LDConfig config) { } catch (TimeoutException e) { logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { - logger.error("Exception encountered waiting for LaunchDarkly client initialization", e); + logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); + logger.debug(e.toString(), e); } if (!updateProcessor.initialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); @@ -169,7 +170,8 @@ public Map allFlags(LDUser user) { result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { - logger.error("Exception caught when evaluating all flags:", e); + logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + logger.debug(e.toString(), e); } } return result; @@ -209,9 +211,9 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def public boolean isFlagKnown(String featureKey) { if (!initialized()) { if (featureStore.initialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning false"); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; feature store unavailable, returning false", featureKey); return false; } } @@ -221,7 +223,8 @@ public boolean isFlagKnown(String featureKey) { return true; } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", e.toString()); + logger.debug(e.toString(), e); } return false; @@ -230,23 +233,24 @@ public boolean isFlagKnown(String featureKey) { private JsonElement evaluate(String featureKey, LDUser user, JsonElement defaultValue, VariationType expectedType) { if (!initialized()) { if (featureStore.initialized()) { - logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); return defaultValue; } } + FeatureFlag featureFlag = null; try { - FeatureFlag featureFlag = featureStore.get(FEATURES, featureKey); + featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { - logger.info("Unknown feature flag " + featureKey + "; returning default value"); + logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); return defaultValue; } if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); + logger.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); return defaultValue; } @@ -266,10 +270,15 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default return defaultValue; } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); + logger.debug(e.toString(), e); + if (featureFlag == null) { + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + } else { + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); + } + return defaultValue; } - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; } @Override @@ -314,7 +323,8 @@ public String secureModeHash(LDUser user) { mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); return Hex.encodeHexString(mac.doFinal(user.getKeyAsString().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { - logger.error("Could not generate secure mode hash", e); + logger.error("Could not generate secure mode hash: {}", e.toString()); + logger.debug(e.toString(), e); } return null; } diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java index f01832d6f..8a9c1c0cb 100644 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ b/src/main/java/com/launchdarkly/client/NewRelicReflector.java @@ -28,7 +28,8 @@ static void annotateTransaction(String featureKey, String value) { try { addCustomParameter.invoke(null, featureKey, value); } catch (Exception e) { - logger.error("Unexpected error in LaunchDarkly NewRelic integration"); + logger.error("Unexpected error in LaunchDarkly NewRelic integration: {}", e.toString()); + logger.debug(e.toString(), e); } } } diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index bb261236a..ad8fdaead 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -70,7 +70,8 @@ public void run() { initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client when retrieving update", e); + logger.error("Encountered exception in LaunchDarkly client when retrieving update: {}", e.toString()); + logger.debug(e.toString(), e); } } }, 0L, config.pollingIntervalMillis, TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 1cdf5b7d1..6c98f4ee0 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -133,7 +133,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { logger.info("Initialized LaunchDarkly client."); } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); + logger.debug(e.toString(), e); } break; case INDIRECT_PATCH: @@ -151,7 +152,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { } } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); + logger.debug(e.toString(), e); } break; default: @@ -167,8 +169,8 @@ public void onComment(String comment) { @Override public void onError(Throwable throwable) { - logger.error("Encountered EventSource error: " + throwable.getMessage()); - logger.debug("", throwable); + logger.error("Encountered EventSource error: {}" + throwable.toString()); + logger.debug(throwable.toString(), throwable); } }; From 361849ca42ae4f40c03048a7e324e72ae29b3cc7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 11:40:17 -0700 Subject: [PATCH 019/156] add new version of allFlags() that captures more metadata --- .../client/FeatureFlagsState.java | 106 +++++++++++++++ .../com/launchdarkly/client/LDClient.java | 41 +++--- .../client/LDClientInterface.java | 16 +++ .../client/FeatureFlagsStateTest.java | 65 +++++++++ .../client/LDClientEvaluationTest.java | 123 +++++++++++++++++- .../client/LDClientOfflineTest.java | 29 ++++- 6 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FeatureFlagsState.java create mode 100644 src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java new file mode 100644 index 000000000..a9faa10f2 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -0,0 +1,106 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import java.util.HashMap; +import java.util.Map; + +/** + * A snapshot of the state of all feature flags with regard to a specific user, generated by + * calling {@link LDClientInterface#allFlagsState(LDUser)}. + * + * @since 4.3.0 + */ +public class FeatureFlagsState { + private static final Gson gson = new Gson(); + + private final ImmutableMap flagValues; + private final ImmutableMap flagMetadata; + private final boolean valid; + + static class FlagMetadata { + final Integer variation; + final int version; + final boolean trackEvents; + final Long debugEventsUntilDate; + + FlagMetadata(Integer variation, int version, boolean trackEvents, + Long debugEventsUntilDate) { + this.variation = variation; + this.version = version; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + } + } + + private FeatureFlagsState(Builder builder) { + this.flagValues = builder.flagValues.build(); + this.flagMetadata = builder.flagMetadata.build(); + this.valid = builder.valid; + } + + /** + * Returns true if this object contains a valid snapshot of feature flag state, or false if the + * state could not be computed (for instance, because the client was offline or there was no user). + * @return true if the state is valid + */ + public boolean isValid() { + return valid; + } + + /** + * Returns the value of an individual feature flag at the time the state was recorded. + * @param key the feature flag key + * @return the flag's JSON value; null if the flag returned the default value, or if there was no such flag + */ + public JsonElement getFlagValue(String key) { + return flagValues.get(key); + } + + /** + * Returns a map of flag keys to flag values. If a flag would have evaluated to the default value, + * its value will be null. + * @return an immutable map of flag keys to JSON values + */ + public Map toValuesMap() { + return flagValues; + } + + /** + * Returns a JSON string representation of the entire state map, in the format used by the + * LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that + * will be used to "bootstrap" the JavaScript client. + * @return a JSON representation of the state object + */ + public String toJsonString() { + Map outerMap = new HashMap<>(); + outerMap.putAll(flagValues); + outerMap.put("$flagsState", flagMetadata); + return gson.toJson(outerMap); + } + + static class Builder { + private ImmutableMap.Builder flagValues = ImmutableMap.builder(); + private ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + private boolean valid = true; + + Builder valid(boolean valid) { + this.valid = valid; + return this; + } + + Builder addFlag(FeatureFlag flag, FeatureFlag.VariationAndValue eval) { + flagValues.put(flag.getKey(), eval.getValue()); + FlagMetadata data = new FlagMetadata(eval.getVariation(), + flag.getVersion(), flag.isTrackEvents(), flag.getDebugEventsUntilDate()); + flagMetadata.put(flag.getKey(), data); + return this; + } + + FeatureFlagsState build() { + return new FeatureFlagsState(this); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..75a6499db 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -12,7 +12,6 @@ import java.net.URL; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -142,39 +141,49 @@ private void sendFlagRequestEvent(Event.FeatureRequest event) { @Override public Map allFlags(LDUser user) { - if (isOffline()) { - logger.debug("allFlags() was called when client is in offline mode."); + FeatureFlagsState state = allFlagsState(user); + if (!state.isValid()) { + return null; } + return state.toValuesMap(); + } + @Override + public FeatureFlagsState allFlagsState(LDUser user) { + FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(); + + if (isOffline()) { + logger.debug("allFlagsState() was called when client is in offline mode."); + } + if (!initialized()) { if (featureStore.initialized()) { - logger.warn("allFlags() was called before client initialized; using last known values from feature store"); + logger.warn("allFlagsState() was called before client initialized; using last known values from feature store"); } else { - logger.warn("allFlags() was called before client initialized; feature store unavailable, returning null"); - return null; + logger.warn("allFlagsState() was called before client initialized; feature store unavailable, returning no data"); + return builder.valid(false).build(); } } if (user == null || user.getKey() == null) { - logger.warn("allFlags() was called with null user or null user key! returning null"); - return null; + logger.warn("allFlagsState() was called with null user or null user key! returning no data"); + return builder.valid(false).build(); } Map flags = featureStore.all(FEATURES); - Map result = new HashMap<>(); - for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getResult().getValue(); - result.put(entry.getKey(), evalResult); - + FeatureFlag.VariationAndValue eval = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getResult(); + builder.addFlag(entry.getValue(), eval); } catch (EvaluationException e) { - logger.error("Exception caught when evaluating all flags:", e); + logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + logger.debug(e.toString(), e); + builder.addFlag(entry.getValue(), new FeatureFlag.VariationAndValue(null, null)); } } - return result; + return builder.build(); } - + @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Boolean); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 94ee3f060..33941085a 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -46,9 +46,25 @@ public interface LDClientInterface extends Closeable { * * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user + * + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not generate analytics + * events correctly if you pass the result of {@code allFlags()}. */ + @Deprecated Map allFlags(LDUser user); + /** + * Returns an object that encapsulates the state of all feature flags for a given user, including the flag + * values and, optionally, their {@link EvaluationReason}s. + *

+ * The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. + * + * @param user the end user requesting the feature flags + * @return a {@link FeatureFlagsState} object (will never be null; see {@link FeatureFlagsState#isValid()} + * @since 4.3.0 + */ + FeatureFlagsState allFlagsState(LDUser user); + /** * Calculates the value of a feature flag for a given user. * diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java new file mode 100644 index 000000000..bda54ad46 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -0,0 +1,65 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.junit.Test; + +import static com.launchdarkly.client.TestUtil.js; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class FeatureFlagsStateTest { + private static final Gson gson = new Gson(); + + @Test + public void canGetFlagValue() { + FeatureFlag.VariationAndValue eval = new FeatureFlag.VariationAndValue(1, js("value")); + FeatureFlag flag = new FeatureFlagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(js("value"), state.getFlagValue("key")); + } + + @Test + public void unknownFlagReturnsNullValue() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagValue("key")); + } + + @Test + public void canConvertToValuesMap() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + ImmutableMap expected = ImmutableMap.of("key1", js("value1"), "key2", js("value2")); + assertEquals(expected, state.toValuesMap()); + } + + @Test + public void canConvertToJson() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index f6d175dc0..cfb5fdce0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -1,12 +1,16 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.junit.Test; import java.util.Arrays; +import java.util.Map; +import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -14,11 +18,14 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class LDClientEvaluationTest { private static final LDUser user = new LDUser("userkey"); - + private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); + private static final Gson gson = new Gson(); + private TestFeatureStore featureStore = new TestFeatureStore(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) @@ -112,4 +119,118 @@ public void canMatchUserBySegment() throws Exception { assertTrue(client.boolVariation("test-feature", user, false)); } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsFlagValues() throws Exception { + featureStore.setStringValue("key1","value1"); + featureStore.setStringValue("key2", "value2"); + + Map result = client.allFlags(user); + assertEquals(ImmutableMap.of("key1", js("value1"), "key2", js("value2")), result); + } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsNullForNullUser() throws Exception { + featureStore.setStringValue("key", "value"); + + assertNull(client.allFlags(null)); + } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsNullForNullUserKey() throws Exception { + featureStore.setStringValue("key", "value"); + + assertNull(client.allFlags(userWithNullKey)); + } + + @Test + public void allFlagsStateReturnsState() throws Exception { + FeatureFlag flag1 = new FeatureFlagBuilder("key1") + .version(100) + .trackEvents(false) + .on(false) + .offVariation(0) + .variations(js("value1")) + .build(); + FeatureFlag flag2 = new FeatureFlagBuilder("key2") + .version(200) + .trackEvents(true) + .debugEventsUntilDate(1000L) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } + + @Test + public void allFlagsStateReturnsStateWithReasons() throws Exception { + FeatureFlag flag1 = new FeatureFlagBuilder("key1") + .version(100) + .trackEvents(false) + .on(false) + .offVariation(0) + .variations(js("value1")) + .build(); + FeatureFlag flag2 = new FeatureFlagBuilder("key2") + .version(200) + .trackEvents(true) + .debugEventsUntilDate(1000L) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } + + @Test + public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { + featureStore.setStringValue("key", "value"); + + FeatureFlagsState state = client.allFlagsState(null); + assertFalse(state.isValid()); + assertEquals(0, state.toValuesMap().size()); + } + + @Test + public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { + featureStore.setStringValue("key", "value"); + + FeatureFlagsState state = client.allFlagsState(userWithNullKey); + assertFalse(state.isValid()); + assertEquals(0, state.toValuesMap().size()); + } } diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 5a8369f49..10c3b46b2 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -1,19 +1,21 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import org.junit.Test; import java.io.IOException; import java.util.Map; +import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class LDClientOfflineTest { + private static final LDUser user = new LDUser("user"); + @Test public void offlineClientHasNullUpdateProcessor() throws IOException { LDConfig config = new LDConfig.Builder() @@ -50,7 +52,7 @@ public void offlineClientReturnsDefaultValue() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals("x", client.stringVariation("key", new LDUser("user"), "x")); + assertEquals("x", client.stringVariation("key", user, "x")); } } @@ -63,13 +65,26 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { .build(); testFeatureStore.setFeatureTrue("key"); try (LDClient client = new LDClient("SDK_KEY", config)) { - Map allFlags = client.allFlags(new LDUser("user")); - assertNotNull(allFlags); - assertEquals(1, allFlags.size()); - assertEquals(new JsonPrimitive(true), allFlags.get("key")); + Map allFlags = client.allFlags(user); + assertEquals(ImmutableMap.of("key", jbool(true)), allFlags); } } + @Test + public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { + TestFeatureStore testFeatureStore = new TestFeatureStore(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .build(); + testFeatureStore.setFeatureTrue("key"); + try (LDClient client = new LDClient("SDK_KEY", config)) { + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + assertEquals(ImmutableMap.of("key", jbool(true)), state.toValuesMap()); + } + } + @Test public void testSecureModeHash() throws IOException { LDConfig config = new LDConfig.Builder() From 1889fb5a71f29a0b9224567e1d98ab476cbcc596 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 13:57:11 -0700 Subject: [PATCH 020/156] clarify comment --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 33941085a..e6420ca13 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -47,8 +47,8 @@ public interface LDClientInterface extends Closeable { * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user * - * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not generate analytics - * events correctly if you pass the result of {@code allFlags()}. + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK (2.0.0 and later) + * will not generate analytics events correctly if you pass the result of {@code allFlags()}. */ @Deprecated Map allFlags(LDUser user); From a4a56953d2447b0193a455305c7bcf153d6525d0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 14:05:55 -0700 Subject: [PATCH 021/156] remove FOSSA upload step from CI --- .circleci/config.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2da9f6d9d..d938b76e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,11 +22,3 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit - - run: - name: Upload FOSSA analysis (from master only) - command: | - if [[ ( -n "$FOSSA_API_KEY" ) && ( "$CIRCLE_BRANCH" == "master" ) ]]; then - curl -s -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash; - fossa init; - FOSSA_API_KEY=$FOSSA_API_KEY fossa; - fi From 7e91572dd802680fb3ad8b0f4481ea627b900329 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 14:49:54 -0700 Subject: [PATCH 022/156] rm duplicated test --- .../client/LDClientEvaluationTest.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index cfb5fdce0..e717b3a11 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -181,41 +181,6 @@ public void allFlagsStateReturnsState() throws Exception { assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); } - @Test - public void allFlagsStateReturnsStateWithReasons() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") - .version(100) - .trackEvents(false) - .on(false) - .offVariation(0) - .variations(js("value1")) - .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") - .version(200) - .trackEvents(true) - .debugEventsUntilDate(1000L) - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(js("off"), js("value2")) - .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - - FeatureFlagsState state = client.allFlagsState(user); - assertTrue(state.isValid()); - - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + - "\"$flagsState\":{" + - "\"key1\":{" + - "\"variation\":0,\"version\":100,\"trackEvents\":false" + - "},\"key2\":{" + - "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + - "}" + - "}}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); - } - @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.setStringValue("key", "value"); From a0dcfa72c1bed8d4300dbcd0f788e7546a574169 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 15:28:06 -0700 Subject: [PATCH 023/156] add error explanation for malformed flags --- .../launchdarkly/client/EvaluationDetail.java | 2 +- .../launchdarkly/client/EvaluationReason.java | 9 +- .../com/launchdarkly/client/FeatureFlag.java | 59 +++++------ .../com/launchdarkly/client/LDClient.java | 2 +- .../client/VariationOrRollout.java | 2 + .../launchdarkly/client/FeatureFlagTest.java | 97 +++++++++++++++++-- 6 files changed, 126 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 5ea06b20b..c5535f47b 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -57,7 +57,7 @@ public boolean equals(Object other) { if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); + return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); } return false; } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 66900cc2f..32654afc9 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -47,6 +47,7 @@ public static enum Kind { /** * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected * error. In this case the result value will be the default value that the caller passed to the client. + * Check the errorKind property for more details on the problem. */ ERROR; } @@ -64,6 +65,11 @@ public static enum ErrorKind { * Indicates that the caller provided a flag key that did not match any known flag. */ FLAG_NOT_FOUND, + /** + * Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent + * variation. An error message will always be logged in this case. + */ + MALFORMED_FLAG, /** * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. */ @@ -74,7 +80,8 @@ public static enum ErrorKind { */ WRONG_TYPE, /** - * Indicates that an unexpected exception stopped flag evaluation; check the log for details. + * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged + * in this case. */ EXCEPTION } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index a8b99b7f9..b7be4a6f0 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -63,7 +63,7 @@ static Map fromJsonMap(LDConfig config, String json) { this.deleted = deleted; } - EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) throws EvaluationException { + EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) { List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { @@ -77,15 +77,14 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(details, prereqEvents); } - EvaluationDetail details = new EvaluationDetail<>(EvaluationReason.off(), offVariation, getOffVariationValue()); - return new EvalResult(details, prereqEvents); + return new EvalResult(getOffValue(EvaluationReason.off()), prereqEvents); } private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) throws EvaluationException { + EventFactory eventFactory) { EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { - return new EvaluationDetail<>(prereqFailureReason, offVariation, getOffVariationValue()); + return getOffValue(prereqFailureReason); } // Check to see if targets match @@ -93,8 +92,7 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature for (Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetail<>(EvaluationReason.targetMatch(), - target.getVariation(), getVariation(target.getVariation())); + return getVariation(target.getVariation(), EvaluationReason.targetMatch()); } } } @@ -104,21 +102,18 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature for (int i = 0; i < rules.size(); i++) { Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { - int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetail<>(EvaluationReason.ruleMatch(i, rule.getId()), - index, getVariation(index)); + return getValueForVariationOrRollout(rule, user, EvaluationReason.ruleMatch(i, rule.getId())); } } } // Walk through the fallthrough and see if it matches - int index = fallthrough.variationIndexForUser(user, key, salt); - return new EvaluationDetail<>(EvaluationReason.fallthrough(), index, getVariation(index)); + return getValueForVariationOrRollout(fallthrough, user, EvaluationReason.fallthrough()); } // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to // short-circuit due to a prerequisite failure. private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) throws EvaluationException { + EventFactory eventFactory) { if (prerequisites == null) { return null; } @@ -156,34 +151,30 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto return null; } - JsonElement getOffVariationValue() throws EvaluationException { - if (offVariation == null) { - return null; + private EvaluationDetail getVariation(int variation, EvaluationReason reason) { + if (variation < 0 || variation >= variations.size()) { + logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", key); + return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null); } + return new EvaluationDetail(reason, variation, variations.get(variation)); + } - if (offVariation >= variations.size()) { - throw new EvaluationException("Invalid off variation index"); + private EvaluationDetail getOffValue(EvaluationReason reason) { + if (offVariation == null) { // off variation unspecified - return default value + return new EvaluationDetail(reason, null, null); } - - return variations.get(offVariation); + return getVariation(offVariation, reason); } - - private JsonElement getVariation(Integer index) throws EvaluationException { - // If the supplied index is null, then rules didn't match, and we want to return - // the off variation + + private EvaluationDetail getValueForVariationOrRollout(VariationOrRollout vr, LDUser user, EvaluationReason reason) { + Integer index = vr.variationIndexForUser(user, key, salt); if (index == null) { - return null; - } - // If the index doesn't refer to a valid variation, that's an unexpected exception and we will - // return the default variation - else if (index >= variations.size()) { - throw new EvaluationException("Invalid index"); - } - else { - return variations.get(index); + logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", key); + return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null); } + return getVariation(index, reason); } - + public int getVersion() { return version; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 3c22a629a..f0eff55d5 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -167,7 +167,7 @@ public Map allFlags(LDUser user) { try { JsonElement evalResult = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue(); result.put(entry.getKey(), evalResult); - } catch (EvaluationException e) { + } catch (Exception e) { logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); logger.debug(e.toString(), e); } diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java index 56537e498..41f58a676 100644 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ b/src/main/java/com/launchdarkly/client/VariationOrRollout.java @@ -24,6 +24,8 @@ class VariationOrRollout { this.rollout = rollout; } + // Attempt to determine the variation index for a given user. Returns null if no index can be computed + // due to internal inconsistency of the data (i.e. a malformed flag). Integer variationIndexForUser(LDUser user, String key, String salt) { if (variation != null) { return variation; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index d77fa3c3d..f3f55e459 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -57,6 +57,34 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(false) + .offVariation(999) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() 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, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") @@ -230,18 +258,12 @@ public void flagMatchesUserFromTargets() throws Exception { } @Test - public void flagMatchesUserFromRules() throws Exception { + public void flagMatchesUserFromRules() { Clause clause0 = new Clause("key", Operator.in, Arrays.asList(js("wrongkey")), false); Clause clause1 = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .rules(Arrays.asList(rule0, rule1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(js("fall"), js("off"), js("on")) - .build(); + FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); @@ -249,6 +271,55 @@ public void flagMatchesUserFromRules() throws Exception { assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void ruleWithTooHighVariationReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), 999, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithNegativeVariationReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), -1, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), null, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), null, + new VariationOrRollout.Rollout(ImmutableList.of(), null)); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void clauseCanMatchBuiltInAttribute() throws Exception { Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); @@ -352,6 +423,16 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti assertEquals(jbool(false), result.getDetails().getValue()); } + private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { + return new FeatureFlagBuilder(flagKey) + .on(true) + .rules(Arrays.asList(rules)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .build(); + } + private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false); return booleanFlagWithClauses("flag", clause); From 712bed4796b0ec66b2fcc45bbb5dbb375c0fac1e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 15:39:51 -0700 Subject: [PATCH 024/156] add tests for more error conditions --- .../launchdarkly/client/FeatureFlagTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index f3f55e459..db0d310a2 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -85,6 +85,77 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(999)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(-1)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new VariationOrRollout(null, null)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new VariationOrRollout(null, + new VariationOrRollout.Rollout(ImmutableList.of(), null))) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") From 564db864446a0a115c357a6798389dad34b8f787 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 10:59:36 -0700 Subject: [PATCH 025/156] change options to be more enum-like --- .../client/FeatureFlagsState.java | 2 +- .../launchdarkly/client/FlagsStateOption.java | 23 ++++++------------- .../client/FeatureFlagsStateTest.java | 4 ++-- .../client/LDClientEvaluationTest.java | 2 +- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 4ba3f0a7b..752faa60b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -100,7 +100,7 @@ static class Builder { private boolean valid = true; Builder(FlagsStateOption... options) { - saveReasons = FlagsStateOption.isWithReasons(options); + saveReasons = FlagsStateOption.hasOption(options, FlagsStateOption.WITH_REASONS); } Builder valid(boolean valid) { diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index c1ad9f757..96cc96995 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -6,25 +6,16 @@ */ public abstract class FlagsStateOption { /** - * Specifies whether {@link EvaluationReason} data should be captured in the state object. By default, it is not. - * @param value true if evaluation reasons should be stored - * @return an option object + * Specifies that {@link EvaluationReason} data should be captured in the state object. By default, it is not. */ - public static FlagsStateOption withReasons(boolean value) { - return new WithReasons(value); - } + public static final FlagsStateOption WITH_REASONS = new WithReasons(); - private static class WithReasons extends FlagsStateOption { - final boolean value; - WithReasons(boolean value) { - this.value = value; - } - } - - static boolean isWithReasons(FlagsStateOption[] options) { + private static class WithReasons extends FlagsStateOption { } + + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { for (FlagsStateOption o: options) { - if (o instanceof WithReasons) { - return ((WithReasons)o).value; + if (o.equals(option)) { + return true; } } return false; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 65398fc62..64797ced0 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -33,7 +33,7 @@ public void unknownFlagReturnsNullValue() { public void canGetFlagReason() { EvaluationDetail eval = new EvaluationDetail(EvaluationReason.off(), 1, js("value")); FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.withReasons(true)) + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); assertEquals(EvaluationReason.off(), state.getFlagReason("key")); @@ -74,7 +74,7 @@ public void canConvertToJson() { FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); EvaluationDetail eval2 = new EvaluationDetail(EvaluationReason.fallthrough(), 1, js("value2")); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.withReasons(true)) + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 7a844f083..cf41ba1de 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -261,7 +261,7 @@ public void allFlagsStateReturnsStateWithReasons() throws Exception { featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); - FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.withReasons(true)); + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + From e29c2e059698dfd04d4bd47fbb67c69ee007adef Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Fri, 17 Aug 2018 11:15:23 -0700 Subject: [PATCH 026/156] version 4.2.2 (#88) --- CHANGELOG.md | 5 +++++ gradle.properties | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 852f409bf..f23b75b19 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). +## [4.2.2] - 2018-08-17 +### Fixed: +- When logging errors related to the evaluation of a specific flag, the log message now always includes the flag key. +- Exception stacktraces are now logged only at DEBUG level. Previously, some were being logged at ERROR level. + ## [4.2.1] - 2018-07-16 ### Fixed: - Should not permanently give up on posting events if the server returns a 400 error. diff --git a/gradle.properties b/gradle.properties index ebf7f6ded..e95b27cb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=4.2.1 +version=4.2.2 ossrhUsername= ossrhPassword= From 91f9cb2f83c7aa2f400e3653a50f972702cd63a8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 13:27:57 -0700 Subject: [PATCH 027/156] add option to select only client-side flags in allFlagsState() --- .../com/launchdarkly/client/FeatureFlag.java | 8 ++++- .../client/FeatureFlagBuilder.java | 10 ++++-- .../launchdarkly/client/FlagsStateOption.java | 34 +++++++++++++++++++ .../com/launchdarkly/client/LDClient.java | 7 +++- .../client/LDClientInterface.java | 7 ++-- .../client/LDClientEvaluationTest.java | 20 +++++++++++ 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FlagsStateOption.java diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 2b06efe03..2dc9b08da 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -28,6 +28,7 @@ class FeatureFlag implements VersionedData { private VariationOrRollout fallthrough; private Integer offVariation; //optional private List variations; + private boolean clientSide; private boolean trackEvents; private Long debugEventsUntilDate; private boolean deleted; @@ -45,7 +46,7 @@ static Map fromJsonMap(LDConfig config, String json) { FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, - boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { + boolean clientSide, boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { this.key = key; this.version = version; this.on = on; @@ -56,6 +57,7 @@ static Map fromJsonMap(LDConfig config, String json) { this.fallthrough = fallthrough; this.offVariation = offVariation; this.variations = variations; + this.clientSide = clientSide; this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; this.deleted = deleted; @@ -209,6 +211,10 @@ List getVariations() { Integer getOffVariation() { return offVariation; } + boolean isClientSide() { + return clientSide; + } + static class VariationAndValue { private final Integer variation; private final JsonElement value; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index 84f769966..2d7d86832 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -17,6 +17,7 @@ class FeatureFlagBuilder { private VariationOrRollout fallthrough; private Integer offVariation; private List variations = new ArrayList<>(); + private boolean clientSide; private boolean trackEvents; private Long debugEventsUntilDate; private boolean deleted; @@ -37,13 +38,13 @@ class FeatureFlagBuilder { this.fallthrough = f.getFallthrough(); this.offVariation = f.getOffVariation(); this.variations = f.getVariations(); + this.clientSide = f.isClientSide(); this.trackEvents = f.isTrackEvents(); this.debugEventsUntilDate = f.getDebugEventsUntilDate(); this.deleted = f.isDeleted(); } } - FeatureFlagBuilder version(int version) { this.version = version; return this; @@ -93,6 +94,11 @@ FeatureFlagBuilder variations(JsonElement... variations) { return variations(Arrays.asList(variations)); } + FeatureFlagBuilder clientSide(boolean clientSide) { + this.clientSide = clientSide; + return this; + } + FeatureFlagBuilder trackEvents(boolean trackEvents) { this.trackEvents = trackEvents; return this; @@ -110,6 +116,6 @@ FeatureFlagBuilder deleted(boolean deleted) { FeatureFlag build() { return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - trackEvents, debugEventsUntilDate, deleted); + clientSide, trackEvents, debugEventsUntilDate, deleted); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java new file mode 100644 index 000000000..edc90f0a1 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +/** + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...). + * + * @since 4.3.0 + */ +public class FlagsStateOption { + private final String description; + + private FlagsStateOption(String description) { + this.description = description; + } + + @Override + public String toString() { + return description; + } + + /** + * Specifies that only flags marked for use with the client-side SDK should be included in the state object. + * By default, all flags are included. + */ + public static final FlagsStateOption CLIENT_SIDE_ONLY = new FlagsStateOption("CLIENT_SIDE_ONLY"); + + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { + for (FlagsStateOption o: options) { + if (o == option) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index c430e4474..433cb124e 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -150,7 +150,7 @@ public Map allFlags(LDUser user) { } @Override - public FeatureFlagsState allFlagsState(LDUser user) { + public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) { FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(); if (isOffline()) { @@ -171,8 +171,13 @@ public FeatureFlagsState allFlagsState(LDUser user) { return builder.valid(false).build(); } + boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); Map flags = featureStore.all(FEATURES); for (Map.Entry entry : flags.entrySet()) { + FeatureFlag flag = entry.getValue(); + if (clientSideOnly && !flag.isClientSide()) { + continue; + } try { FeatureFlag.VariationAndValue eval = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getResult(); builder.addFlag(entry.getValue(), eval); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index e6420ca13..77db52731 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -54,16 +54,17 @@ public interface LDClientInterface extends Closeable { Map allFlags(LDUser user); /** - * Returns an object that encapsulates the state of all feature flags for a given user, including the flag - * values and, optionally, their {@link EvaluationReason}s. + * Returns an object that encapsulates the state of all feature flags for a given user. *

* The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. * * @param user the end user requesting the feature flags + * @param options optional {@link FlagsStateOption} values affecting how the state is computed - for + * instance, to filter the set of flags to only include the client-side-enabled ones * @return a {@link FeatureFlagsState} object (will never be null; see {@link FeatureFlagsState#isValid()} * @since 4.3.0 */ - FeatureFlagsState allFlagsState(LDUser user); + FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options); /** * Calculates the value of a feature flag for a given user. diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e717b3a11..c8112c49f 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -181,6 +181,26 @@ public void allFlagsStateReturnsState() throws Exception { assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); } + @Test + public void allFlagsStateCanFilterForOnlyClientSideFlags() { + FeatureFlag flag1 = new FeatureFlagBuilder("server-side-1").build(); + FeatureFlag flag2 = new FeatureFlagBuilder("server-side-2").build(); + FeatureFlag flag3 = new FeatureFlagBuilder("client-side-1").clientSide(true) + .variations(js("value1")).offVariation(0).build(); + FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) + .variations(js("value2")).offVariation(0).build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + featureStore.upsert(FEATURES, flag3); + featureStore.upsert(FEATURES, flag4); + + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); + assertTrue(state.isValid()); + + Map allValues = state.toValuesMap(); + assertEquals(ImmutableMap.of("client-side-1", js("value1"), "client-side-2", js("value2")), allValues); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.setStringValue("key", "value"); From a060bcc8bb7eb18b39d76e332c0d491c28630095 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 14:36:58 -0700 Subject: [PATCH 028/156] fix comment --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index e6420ca13..a04d794ac 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -55,7 +55,8 @@ public interface LDClientInterface extends Closeable { /** * Returns an object that encapsulates the state of all feature flags for a given user, including the flag - * values and, optionally, their {@link EvaluationReason}s. + * values and also metadata that can be used on the front end. This method does not send analytics events + * back to LaunchDarkly. *

* The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. * From 8939c8b375aa305b726699ce0f981960eb34077c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 11:55:26 -0700 Subject: [PATCH 029/156] serialize FeatureFlagsState to a JsonElement, not a string --- .../client/FeatureFlagsState.java | 23 +++++++++++++------ .../client/LDClientInterface.java | 4 ++-- .../client/FeatureFlagsStateTest.java | 2 +- .../client/LDClientEvaluationTest.java | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index a9faa10f2..3f2f1d624 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import java.util.HashMap; import java.util.Map; @@ -62,6 +63,9 @@ public JsonElement getFlagValue(String key) { /** * Returns a map of flag keys to flag values. If a flag would have evaluated to the default value, * its value will be null. + *

+ * Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. + * Instead, use {@link #toJson()}. * @return an immutable map of flag keys to JSON values */ public Map toValuesMap() { @@ -69,16 +73,21 @@ public Map toValuesMap() { } /** - * Returns a JSON string representation of the entire state map, in the format used by the - * LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that + * Returns a JSON representation of the entire state map (as a Gson object), in the format used by + * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that * will be used to "bootstrap" the JavaScript client. + *

+ * Do not rely on the exact shape of this data, as it may change in future to support the needs of + * the JavaScript client. * @return a JSON representation of the state object */ - public String toJsonString() { - Map outerMap = new HashMap<>(); - outerMap.putAll(flagValues); - outerMap.put("$flagsState", flagMetadata); - return gson.toJson(outerMap); + public JsonElement toJson() { + JsonObject outerMap = new JsonObject(); + for (Map.Entry entry: flagValues.entrySet()) { + outerMap.add(entry.getKey(), entry.getValue()); + } + outerMap.add("$flagsState", gson.toJsonTree(flagMetadata)); + return outerMap; } static class Builder { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index a04d794ac..bc409c00e 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -47,8 +47,8 @@ public interface LDClientInterface extends Closeable { * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user * - * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK (2.0.0 and later) - * will not generate analytics events correctly if you pass the result of {@code allFlags()}. + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not + * generate analytics events correctly if you pass the result of {@code allFlags()}. */ @Deprecated Map allFlags(LDUser user); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index bda54ad46..3b049dbb7 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -60,6 +60,6 @@ public void canConvertToJson() { "}" + "}}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + assertEquals(expected, state.toJson()); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e717b3a11..eaf4677c7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -178,7 +178,7 @@ public void allFlagsStateReturnsState() throws Exception { "}" + "}}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + assertEquals(expected, state.toJson()); } @Test From 78611fbbb30bbbacd1007fdc25bda2f7c6539000 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 11:57:41 -0700 Subject: [PATCH 030/156] rm unused import --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 3f2f1d624..386a5d572 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -5,7 +5,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import java.util.HashMap; import java.util.Map; /** From 8200e44ee4e18caeca690c24614a727f6a6c0526 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 12:45:21 -0700 Subject: [PATCH 031/156] edit comment --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 386a5d572..f6f07cf5d 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -73,8 +73,8 @@ public Map toValuesMap() { /** * Returns a JSON representation of the entire state map (as a Gson object), in the format used by - * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that - * will be used to "bootstrap" the JavaScript client. + * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end in + * order to "bootstrap" the JavaScript client. *

* Do not rely on the exact shape of this data, as it may change in future to support the needs of * the JavaScript client. From bb209336ecf5d517ccf36006a5db6eedcf297ef7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 19:39:09 -0700 Subject: [PATCH 032/156] use custom Gson serializer --- .../client/FeatureFlagsState.java | 113 ++++++++++++++---- .../client/FeatureFlagsStateTest.java | 20 +++- .../client/LDClientEvaluationTest.java | 6 +- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index f6f07cf5d..22ba3e9b7 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -1,18 +1,27 @@ package com.launchdarkly.client; +import com.google.common.base.Objects; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; import java.util.Map; /** * A snapshot of the state of all feature flags with regard to a specific user, generated by * calling {@link LDClientInterface#allFlagsState(LDUser)}. + *

+ * Serializing this object to JSON using Gson will produce the appropriate data structure for + * bootstrapping the LaunchDarkly JavaScript client. * * @since 4.3.0 */ +@JsonAdapter(FeatureFlagsState.JsonSerialization.class) public class FeatureFlagsState { private static final Gson gson = new Gson(); @@ -33,12 +42,30 @@ static class FlagMetadata { this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; } + + @Override + public boolean equals(Object other) { + if (other instanceof FlagMetadata) { + FlagMetadata o = (FlagMetadata)other; + return Objects.equal(variation, o.variation) && + version == o.version && + trackEvents == o.trackEvents && + Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(variation, version, trackEvents, debugEventsUntilDate); + } } - private FeatureFlagsState(Builder builder) { - this.flagValues = builder.flagValues.build(); - this.flagMetadata = builder.flagMetadata.build(); - this.valid = builder.valid; + private FeatureFlagsState(ImmutableMap flagValues, + ImmutableMap flagMetadata, boolean valid) { + this.flagValues = flagValues; + this.flagMetadata = flagMetadata; + this.valid = valid; } /** @@ -64,29 +91,27 @@ public JsonElement getFlagValue(String key) { * its value will be null. *

* Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. - * Instead, use {@link #toJson()}. + * Instead, serialize the FeatureFlagsState object to JSON using {@code Gson.toJson()} or {@code Gson.toJsonTree()}. * @return an immutable map of flag keys to JSON values */ public Map toValuesMap() { return flagValues; } - /** - * Returns a JSON representation of the entire state map (as a Gson object), in the format used by - * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end in - * order to "bootstrap" the JavaScript client. - *

- * Do not rely on the exact shape of this data, as it may change in future to support the needs of - * the JavaScript client. - * @return a JSON representation of the state object - */ - public JsonElement toJson() { - JsonObject outerMap = new JsonObject(); - for (Map.Entry entry: flagValues.entrySet()) { - outerMap.add(entry.getKey(), entry.getValue()); + @Override + public boolean equals(Object other) { + if (other instanceof FeatureFlagsState) { + FeatureFlagsState o = (FeatureFlagsState)other; + return flagValues.equals(o.flagValues) && + flagMetadata.equals(o.flagMetadata) && + valid == o.valid; } - outerMap.add("$flagsState", gson.toJsonTree(flagMetadata)); - return outerMap; + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(flagValues, flagMetadata, valid); } static class Builder { @@ -108,7 +133,51 @@ Builder addFlag(FeatureFlag flag, FeatureFlag.VariationAndValue eval) { } FeatureFlagsState build() { - return new FeatureFlagsState(this); + return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + } + } + + static class JsonSerialization extends TypeAdapter { + @Override + public void write(JsonWriter out, FeatureFlagsState state) throws IOException { + out.beginObject(); + for (Map.Entry entry: state.flagValues.entrySet()) { + out.name(entry.getKey()); + gson.toJson(entry.getValue(), out); + } + out.name("$flagsState"); + gson.toJson(state.flagMetadata, Map.class, out); + out.name("$valid"); + out.value(state.valid); + out.endObject(); + } + + // There isn't really a use case for deserializing this, but we have to implement it + @Override + public FeatureFlagsState read(JsonReader in) throws IOException { + ImmutableMap.Builder flagValues = ImmutableMap.builder(); + ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + boolean valid = true; + in.beginObject(); + while (in.hasNext()) { + String name = in.nextName(); + if (name.equals("$flagsState")) { + in.beginObject(); + while (in.hasNext()) { + String metaName = in.nextName(); + FlagMetadata meta = gson.fromJson(in, FlagMetadata.class); + flagMetadata.put(metaName, meta); + } + in.endObject(); + } else if (name.equals("$valid")) { + valid = in.nextBoolean(); + } else { + JsonElement value = gson.fromJson(in, JsonElement.class); + flagValues.put(name, value); + } + } + in.endObject(); + return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); } } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 3b049dbb7..f00722fdf 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -58,8 +58,24 @@ public void canConvertToJson() { "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + - "}}"; + "}," + + "\"$valid\":true" + + "}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, state.toJson()); + assertEquals(expected, gson.toJsonTree(state)); + } + + @Test + public void canConvertFromJson() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + String json = gson.toJson(state); + FeatureFlagsState state1 = gson.fromJson(json, FeatureFlagsState.class); + assertEquals(state, state1); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index eaf4677c7..ab2ce9fa0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -176,9 +176,11 @@ public void allFlagsStateReturnsState() throws Exception { "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + - "}}"; + "}," + + "\"$valid\":true" + + "}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, state.toJson()); + assertEquals(expected, gson.toJsonTree(state)); } @Test From 190cdb980a9ceae92fbdcf1982d57ffebd5fb59d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 11:13:37 -0700 Subject: [PATCH 033/156] add tests for JSON serialization of evaluation reasons --- .../launchdarkly/client/EvaluationReason.java | 2 +- .../client/EvaluationReasonTest.java | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/launchdarkly/client/EvaluationReasonTest.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 66900cc2f..48f877af0 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -205,7 +205,7 @@ public String getRuleId() { public boolean equals(Object other) { if (other instanceof RuleMatch) { RuleMatch o = (RuleMatch)other; - return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); + return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); } return false; } diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java new file mode 100644 index 000000000..c5a74035d --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -0,0 +1,67 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class EvaluationReasonTest { + private static final Gson gson = new Gson(); + + @Test + public void testOffReasonSerialization() { + EvaluationReason reason = EvaluationReason.off(); + String json = "{\"kind\":\"OFF\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("OFF", reason.toString()); + } + + @Test + public void testTargetMatchSerialization() { + EvaluationReason reason = EvaluationReason.targetMatch(); + String json = "{\"kind\":\"TARGET_MATCH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("TARGET_MATCH", reason.toString()); + } + + @Test + public void testRuleMatchSerialization() { + EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + String json = "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("RULE_MATCH(1,id)", reason.toString()); + } + + @Test + public void testPrerequisitesFailedSerialization() { + EvaluationReason reason = EvaluationReason.prerequisitesFailed(ImmutableList.of("key1", "key2")); + String json = "{\"kind\":\"PREREQUISITES_FAILED\",\"prerequisiteKeys\":[\"key1\",\"key2\"]}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("PREREQUISITES_FAILED(key1,key2)", reason.toString()); + } + + @Test + public void testFallthrougSerialization() { + EvaluationReason reason = EvaluationReason.fallthrough(); + String json = "{\"kind\":\"FALLTHROUGH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("FALLTHROUGH", reason.toString()); + } + + @Test + public void testErrorSerialization() { + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); + String json = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("ERROR(EXCEPTION)", reason.toString()); + } + + private void assertJsonEqual(String expectedString, String actualString) { + JsonElement expected = gson.fromJson(expectedString, JsonElement.class); + JsonElement actual = gson.fromJson(actualString, JsonElement.class); + assertEquals(expected, actual); + } +} From 8f90c71aec1b468e2ee3ff5431a2d99355820fb2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 18:10:11 -0700 Subject: [PATCH 034/156] misc cleanup --- .../launchdarkly/client/EvaluationReason.java | 8 ++++---- .../client/EvaluationReasonTest.java | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index d092c8677..164047866 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -27,6 +27,10 @@ public static enum Kind { * Indicates that the flag was off and therefore returned its configured off value. */ OFF, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, /** * Indicates that the user key was specifically targeted for this flag. */ @@ -40,10 +44,6 @@ public static enum Kind { * that either was off or did not return the desired variation. */ PREREQUISITES_FAILED, - /** - * Indicates that the flag was on but the user did not match any targets or rules. - */ - FALLTHROUGH, /** * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected * error. In this case the result value will be the default value that the caller passed to the client. diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index c5a74035d..f37392d02 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -18,6 +18,14 @@ public void testOffReasonSerialization() { assertJsonEqual(json, gson.toJson(reason)); assertEquals("OFF", reason.toString()); } + + @Test + public void testFallthroughSerialization() { + EvaluationReason reason = EvaluationReason.fallthrough(); + String json = "{\"kind\":\"FALLTHROUGH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("FALLTHROUGH", reason.toString()); + } @Test public void testTargetMatchSerialization() { @@ -43,14 +51,6 @@ public void testPrerequisitesFailedSerialization() { assertEquals("PREREQUISITES_FAILED(key1,key2)", reason.toString()); } - @Test - public void testFallthrougSerialization() { - EvaluationReason reason = EvaluationReason.fallthrough(); - String json = "{\"kind\":\"FALLTHROUGH\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("FALLTHROUGH", reason.toString()); - } - @Test public void testErrorSerialization() { EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); From ccaddda15b3f0cd4ca5e38db4dbb1ebbfa4c8782 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 20:36:57 -0700 Subject: [PATCH 035/156] don't keep evaluating prerequisites if one fails --- .../launchdarkly/client/EvaluationReason.java | 39 +++++++---------- .../com/launchdarkly/client/FeatureFlag.java | 9 +--- .../launchdarkly/client/FeatureFlagTest.java | 42 +------------------ .../client/LDClientEventTest.java | 3 +- 4 files changed, 20 insertions(+), 73 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 32654afc9..d63f03d4d 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -1,8 +1,5 @@ package com.launchdarkly.client; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; - import java.util.Objects; import static com.google.common.base.Preconditions.checkNotNull; @@ -39,7 +36,7 @@ public static enum Kind { * Indicates that the flag was considered off because it had at least one prerequisite flag * that either was off or did not return the desired variation. */ - PREREQUISITES_FAILED, + PREREQUISITE_FAILED, /** * Indicates that the flag was on but the user did not match any targets or rules. */ @@ -134,12 +131,12 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisitesFailed}. - * @param prerequisiteKeys the list of flag keys of prerequisites that failed + * Returns an instance of {@link PrerequisiteFailed}. + * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ - public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { - return new PrerequisitesFailed(prerequisiteKeys); + public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { + return new PrerequisiteFailed(prerequisiteKey); } /** @@ -233,36 +230,32 @@ public String toString() { * had at least one prerequisite flag that either was off or did not return the desired variation. * @since 4.3.0 */ - public static class PrerequisitesFailed extends EvaluationReason { - private final ImmutableList prerequisiteKeys; + public static class PrerequisiteFailed extends EvaluationReason { + private final String prerequisiteKey; - private PrerequisitesFailed(Iterable prerequisiteKeys) { - super(Kind.PREREQUISITES_FAILED); - checkNotNull(prerequisiteKeys); - this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); + private PrerequisiteFailed(String prerequisiteKey) { + super(Kind.PREREQUISITE_FAILED); + this.prerequisiteKey = checkNotNull(prerequisiteKey); } - public Iterable getPrerequisiteKeys() { - return prerequisiteKeys; + public String getPrerequisiteKey() { + return prerequisiteKey; } @Override public boolean equals(Object other) { - if (other instanceof PrerequisitesFailed) { - PrerequisitesFailed o = (PrerequisitesFailed)other; - return prerequisiteKeys.equals(o.prerequisiteKeys); - } - return false; + return (other instanceof PrerequisiteFailed) && + ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); } @Override public int hashCode() { - return prerequisiteKeys.hashCode(); + return prerequisiteKey.hashCode(); } @Override public String toString() { - return getKind().name() + "(" + Joiner.on(",").join(prerequisiteKeys) + ")"; + return getKind().name() + "(" + prerequisiteKey + ")"; } } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 614a776e3..c13464c7a 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -119,7 +119,6 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto if (prerequisites == null) { return null; } - List failedPrereqs = null; for (int i = 0; i < prerequisites.size(); i++) { boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); @@ -141,15 +140,9 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { - if (failedPrereqs == null) { - failedPrereqs = new ArrayList<>(); - } - failedPrereqs.add(prereq.getKey()); + return EvaluationReason.prerequisiteFailed(prereq.getKey()); } } - if (failedPrereqs != null && !failedPrereqs.isEmpty()) { - return EvaluationReason.prerequisitesFailed(failedPrereqs); - } return null; } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index db0d310a2..6ba5317b5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -167,7 +167,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -191,7 +191,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); @@ -274,44 +274,6 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio assertEquals(f0.getKey(), event1.prereqOf); } - @Test - public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 0), new Prerequisite("feature2", 0))) - .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(); - FeatureFlag f2 = new FeatureFlagBuilder("feature2") - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) - .version(3) - .build(); - featureStore.upsert(FEATURES, f1); - featureStore.upsert(FEATURES, f2); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); - assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); - - assertEquals(2, result.getPrerequisiteEvents().size()); - - Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); - assertEquals(f1.getKey(), event0.key); - - Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); - assertEquals(f2.getKey(), event1.key); - } - @Test public void flagMatchesUserFromTargets() throws Exception { FeatureFlag f = new FeatureFlagBuilder("feature") diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 7c231a39c..ded8dbb7b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableList; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -344,7 +343,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest assertEquals(1, eventSink.events.size()); checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, - EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1"))); + EvaluationReason.prerequisiteFailed("feature1")); } private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, JsonElement defaultVal, From 9a00c078eae4eb594e41e246ae5463663571d029 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 14:04:49 -0700 Subject: [PATCH 036/156] avoid some inappropriate uses of Guava's ImmutableMap --- .../client/FeatureFlagsState.java | 26 ++++++++++--------- .../java/com/launchdarkly/client/LDUser.java | 18 ++++++++----- .../client/FeatureFlagsStateTest.java | 9 +++++++ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 92ee4dd5e..40c8aa22a 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -10,6 +10,8 @@ import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; /** @@ -25,8 +27,8 @@ public class FeatureFlagsState { private static final Gson gson = new Gson(); - private final ImmutableMap flagValues; - private final ImmutableMap flagMetadata; + private final Map flagValues; + private final Map flagMetadata; private final boolean valid; static class FlagMetadata { @@ -61,10 +63,10 @@ public int hashCode() { } } - private FeatureFlagsState(ImmutableMap flagValues, - ImmutableMap flagMetadata, boolean valid) { - this.flagValues = flagValues; - this.flagMetadata = flagMetadata; + private FeatureFlagsState(Map flagValues, + Map flagMetadata, boolean valid) { + this.flagValues = Collections.unmodifiableMap(flagValues); + this.flagMetadata = Collections.unmodifiableMap(flagMetadata); this.valid = valid; } @@ -115,8 +117,8 @@ public int hashCode() { } static class Builder { - private ImmutableMap.Builder flagValues = ImmutableMap.builder(); - private ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + private Map flagValues = new HashMap<>(); + private Map flagMetadata = new HashMap<>(); private boolean valid = true; Builder valid(boolean valid) { @@ -133,7 +135,7 @@ Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { } FeatureFlagsState build() { - return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + return new FeatureFlagsState(flagValues, flagMetadata, valid); } } @@ -155,8 +157,8 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { // There isn't really a use case for deserializing this, but we have to implement it @Override public FeatureFlagsState read(JsonReader in) throws IOException { - ImmutableMap.Builder flagValues = ImmutableMap.builder(); - ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + Map flagValues = new HashMap<>(); + Map flagMetadata = new HashMap<>(); boolean valid = true; in.beginObject(); while (in.hasNext()) { @@ -177,7 +179,7 @@ public FeatureFlagsState read(JsonReader in) throws IOException { } } in.endObject(); - return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + return new FeatureFlagsState(flagValues, flagMetadata, valid); } } } diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index adde857bc..5bf6b06c5 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -1,16 +1,22 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableSet; -import com.google.gson.*; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; import com.google.gson.internal.Streams; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.google.common.collect.ImmutableMap; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; /** @@ -350,8 +356,8 @@ public Builder(LDUser user) { this.avatar = user.getAvatar() != null ? user.getAvatar().getAsString() : null; this.anonymous = user.getAnonymous() != null ? user.getAnonymous().getAsBoolean() : null; this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry().getAsString()) : null; - this.custom = ImmutableMap.copyOf(user.custom); - this.privateAttrNames = ImmutableSet.copyOf(user.privateAttributeNames); + this.custom = new HashMap<>(user.custom); + this.privateAttrNames = new HashSet<>(user.privateAttributeNames); } /** diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 2e478a3ba..5885cc850 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -29,6 +29,15 @@ public void unknownFlagReturnsNullValue() { assertNull(state.getFlagValue("key")); } + @Test + public void flagCanHaveNullValue() { + EvaluationDetail eval = new EvaluationDetail(null, 1, null); + FeatureFlag flag = new FeatureFlagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertNull(state.getFlagValue("key")); + } + @Test public void canConvertToValuesMap() { EvaluationDetail eval1 = new EvaluationDetail(null, 0, js("value1")); From d026e18e4d0c5f3abad5761cbf097f1f66638acb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 14:14:56 -0700 Subject: [PATCH 037/156] make map & set in the User immutable --- src/main/java/com/launchdarkly/client/LDUser.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 5bf6b06c5..6dc26e836 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -61,8 +63,8 @@ protected LDUser(Builder builder) { this.name = builder.name == null ? null : new JsonPrimitive(builder.name); this.avatar = builder.avatar == null ? null : new JsonPrimitive(builder.avatar); this.anonymous = builder.anonymous == null ? null : new JsonPrimitive(builder.anonymous); - this.custom = new HashMap<>(builder.custom); - this.privateAttributeNames = new HashSet<>(builder.privateAttrNames); + this.custom = ImmutableMap.copyOf(builder.custom); + this.privateAttributeNames = ImmutableSet.copyOf(builder.privateAttrNames); } /** From 18ed2d67a63c8fa0a597eac2c5c146a7b3dcdcfa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 17:04:31 -0700 Subject: [PATCH 038/156] fix default value logic --- .../launchdarkly/client/EvaluationDetail.java | 9 +++++++ .../com/launchdarkly/client/LDClient.java | 12 ++++++--- .../client/LDClientEvaluationTest.java | 27 ++++++++++++++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 7469f9ea3..0bb2880b9 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -49,6 +49,15 @@ public T getValue() { return value; } + /** + * Returns true if the flag evaluation returned the default value, rather than one of the flag's + * variations. + * @return true if this is the default value + */ + public boolean isDefaultValue() { + return variationIndex == null; + } + @Override public boolean equals(Object other) { if (other instanceof EvaluationDetail) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index d02bcdd22..253b5be2d 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -274,10 +274,10 @@ private T evaluate(String featureKey, LDUser user, T defaultValue, JsonEleme private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType, EventFactory eventFactory) { EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson, eventFactory); - T resultValue; + T resultValue = null; if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; - } else { + } else if (details.getValue() != null) { try { resultValue = expectedType.coerceValue(details.getValue()); } catch (EvaluationException e) { @@ -322,8 +322,12 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); - return evalResult.getDetails(); + EvaluationDetail details = evalResult.getDetails(); + if (details.isDefaultValue()) { + details = new EvaluationDetail(details.getReason(), null, defaultValue); + } + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); + return details; } catch (Exception e) { logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); logger.debug(e.toString(), e); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index afdb9f54d..e4631b3ab 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -128,6 +128,25 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); } + @Test + public void variationReturnsDefaultIfFlagEvaluatesToNull() { + FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + featureStore.upsert(FEATURES, flag); + + assertEquals("default", client.stringVariation("key", user, "default")); + } + + @Test + public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { + FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + featureStore.upsert(FEATURES, flag); + + EvaluationDetail expected = new EvaluationDetail(EvaluationReason.off(), null, "default"); + EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); + assertEquals(expected, actual); + assertTrue(actual.isDefaultValue()); + } + @Test public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); @@ -145,16 +164,16 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, false); - assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, "default"); + assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @Test public void appropriateErrorIfUserNotSpecified() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, false); - assertEquals(expectedResult, client.boolVariationDetail("key", null, false)); + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, "default"); + assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @Test From d21466cdfaf9e842ca97dfa697d41dc51361d19f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Aug 2018 13:26:56 -0700 Subject: [PATCH 039/156] javadoc fix --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 2 +- src/main/java/com/launchdarkly/client/FlagsStateOption.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 97df3c5fc..bd962538b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -15,7 +15,7 @@ /** * A snapshot of the state of all feature flags with regard to a specific user, generated by - * calling {@link LDClientInterface#allFlagsState(LDUser)}. + * calling {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. *

* Serializing this object to JSON using Gson will produce the appropriate data structure for * bootstrapping the LaunchDarkly JavaScript client. diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index 1cda057be..f519f1bc8 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; /** - * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser)}. + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. * @since 4.3.0 */ public final class FlagsStateOption { From e0a41a5ce0acfb4ce6a2b5c610cff0fad338a19d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Aug 2018 17:46:21 -0700 Subject: [PATCH 040/156] make LDUser serialize correctly as JSON and add more test coverage --- .../com/launchdarkly/client/LDConfig.java | 2 +- .../java/com/launchdarkly/client/LDUser.java | 135 +++--- .../com/launchdarkly/client/LDUserTest.java | 405 ++++++++++++------ 3 files changed, 341 insertions(+), 201 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index cc753e6e8..32409a07f 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -29,7 +29,7 @@ */ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); - final Gson gson = new GsonBuilder().registerTypeAdapter(LDUser.class, new LDUser.UserAdapter(this)).create(); + final Gson gson = new GsonBuilder().registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(this)).create(); private static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); private static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 6dc26e836..8fcada638 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -2,11 +2,11 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; -import com.google.gson.internal.Streams; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -18,6 +18,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; @@ -32,8 +33,15 @@ *

* Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, a custom attribute such as "customer_ranking" can be used to * launch a feature to the top 10% of users on a site. + *

+ * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, simply call Gson.toJson() or + * Gson.toJsonTree() on it. */ public class LDUser { + private static final Logger logger = LoggerFactory.getLogger(LDUser.class); + + // Note that these fields are all stored internally as JsonPrimitive rather than String so that + // we don't waste time repeatedly converting them to JsonPrimitive in the rule evaluation logic. private final JsonPrimitive key; private JsonPrimitive secondary; private JsonPrimitive ip; @@ -44,10 +52,8 @@ public class LDUser { private JsonPrimitive lastName; private JsonPrimitive anonymous; private JsonPrimitive country; - protected Map custom; - // This is set as transient as we'll use a custom serializer to marshal it - protected transient Set privateAttributeNames; - private static final Logger logger = LoggerFactory.getLogger(LDUser.class); + private Map custom; + Set privateAttributeNames; protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { @@ -63,8 +69,8 @@ protected LDUser(Builder builder) { this.name = builder.name == null ? null : new JsonPrimitive(builder.name); this.avatar = builder.avatar == null ? null : new JsonPrimitive(builder.avatar); this.anonymous = builder.anonymous == null ? null : new JsonPrimitive(builder.anonymous); - this.custom = ImmutableMap.copyOf(builder.custom); - this.privateAttributeNames = ImmutableSet.copyOf(builder.privateAttrNames); + this.custom = builder.custom == null ? null : ImmutableMap.copyOf(builder.custom); + this.privateAttributeNames = builder.privateAttrNames == null ? null : ImmutableSet.copyOf(builder.privateAttrNames); } /** @@ -74,8 +80,8 @@ protected LDUser(Builder builder) { */ public LDUser(String key) { this.key = new JsonPrimitive(key); - this.custom = new HashMap<>(); - this.privateAttributeNames = new HashSet(); + this.custom = null; + this.privateAttributeNames = null; } protected JsonElement getValueForEvaluation(String attribute) { @@ -142,7 +148,7 @@ JsonElement getCustom(String key) { } return null; } - + @Override public boolean equals(Object o) { if (this == o) return true; @@ -150,41 +156,31 @@ public boolean equals(Object o) { LDUser ldUser = (LDUser) o; - if (key != null ? !key.equals(ldUser.key) : ldUser.key != null) return false; - if (secondary != null ? !secondary.equals(ldUser.secondary) : ldUser.secondary != null) return false; - if (ip != null ? !ip.equals(ldUser.ip) : ldUser.ip != null) return false; - if (email != null ? !email.equals(ldUser.email) : ldUser.email != null) return false; - if (name != null ? !name.equals(ldUser.name) : ldUser.name != null) return false; - if (avatar != null ? !avatar.equals(ldUser.avatar) : ldUser.avatar != null) return false; - if (firstName != null ? !firstName.equals(ldUser.firstName) : ldUser.firstName != null) return false; - if (lastName != null ? !lastName.equals(ldUser.lastName) : ldUser.lastName != null) return false; - if (anonymous != null ? !anonymous.equals(ldUser.anonymous) : ldUser.anonymous != null) return false; - if (country != null ? !country.equals(ldUser.country) : ldUser.country != null) return false; - if (custom != null ? !custom.equals(ldUser.custom) : ldUser.custom != null) return false; - return privateAttributeNames != null ? privateAttributeNames.equals(ldUser.privateAttributeNames) : ldUser.privateAttributeNames == null; + return Objects.equals(key, ldUser.key) && + Objects.equals(secondary, ldUser.secondary) && + Objects.equals(ip, ldUser.ip) && + Objects.equals(email, ldUser.email) && + Objects.equals(name, ldUser.name) && + Objects.equals(avatar, ldUser.avatar) && + Objects.equals(firstName, ldUser.firstName) && + Objects.equals(lastName, ldUser.lastName) && + Objects.equals(anonymous, ldUser.anonymous) && + Objects.equals(country, ldUser.country) && + Objects.equals(custom, ldUser.custom) && + Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); } @Override public int hashCode() { - int result = key != null ? key.hashCode() : 0; - result = 31 * result + (secondary != null ? secondary.hashCode() : 0); - result = 31 * result + (ip != null ? ip.hashCode() : 0); - result = 31 * result + (email != null ? email.hashCode() : 0); - result = 31 * result + (name != null ? name.hashCode() : 0); - result = 31 * result + (avatar != null ? avatar.hashCode() : 0); - result = 31 * result + (firstName != null ? firstName.hashCode() : 0); - result = 31 * result + (lastName != null ? lastName.hashCode() : 0); - result = 31 * result + (anonymous != null ? anonymous.hashCode() : 0); - result = 31 * result + (country != null ? country.hashCode() : 0); - result = 31 * result + (custom != null ? custom.hashCode() : 0); - result = 31 * result + (privateAttributeNames != null ? privateAttributeNames.hashCode() : 0); - return result; + return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } - static class UserAdapter extends TypeAdapter { + // Used internally when including users in analytics events, to ensure that private attributes are stripped out. + static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { + private static final Gson gson = new Gson(); private final LDConfig config; - public UserAdapter(LDConfig config) { + public UserAdapterWithPrivateAttributeBehavior(LDConfig config) { this.config = config; } @@ -284,10 +280,7 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt beganObject = true; } out.name(entry.getKey()); - // NB: this accesses part of the internal GSON api. However, it's likely - // the only way to write a JsonElement directly: - // https://groups.google.com/forum/#!topic/google-gson/JpHbpZ9mTOk - Streams.write(entry.getValue(), out); + gson.toJson(entry.getValue(), JsonElement.class, out); } } if (beganObject) { @@ -333,8 +326,6 @@ public static class Builder { */ public Builder(String key) { this.key = key; - this.custom = new HashMap<>(); - this.privateAttrNames = new HashSet<>(); } /** @@ -358,8 +349,8 @@ public Builder(LDUser user) { this.avatar = user.getAvatar() != null ? user.getAvatar().getAsString() : null; this.anonymous = user.getAnonymous() != null ? user.getAnonymous().getAsBoolean() : null; this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry().getAsString()) : null; - this.custom = new HashMap<>(user.custom); - this.privateAttrNames = new HashSet<>(user.privateAttributeNames); + this.custom = user.custom == null ? null : new HashMap<>(user.custom); + this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } /** @@ -380,7 +371,7 @@ public Builder ip(String s) { * @return the builder */ public Builder privateIp(String s) { - privateAttrNames.add("ip"); + addPrivate("ip"); return ip(s); } @@ -404,7 +395,7 @@ public Builder secondary(String s) { * @return the builder */ public Builder privateSecondary(String s) { - privateAttrNames.add("secondary"); + addPrivate("secondary"); return secondary(s); } @@ -455,7 +446,7 @@ public Builder country(String s) { * @return the builder */ public Builder privateCountry(String s) { - privateAttrNames.add("country"); + addPrivate("country"); return country(s); } @@ -477,7 +468,7 @@ public Builder country(LDCountryCode country) { * @return the builder */ public Builder privateCountry(LDCountryCode country) { - privateAttrNames.add("country"); + addPrivate("country"); return country(country); } @@ -500,7 +491,7 @@ public Builder firstName(String firstName) { * @return the builder */ public Builder privateFirstName(String firstName) { - privateAttrNames.add("firstName"); + addPrivate("firstName"); return firstName(firstName); } @@ -534,7 +525,7 @@ public Builder lastName(String lastName) { * @return the builder */ public Builder privateLastName(String lastName) { - privateAttrNames.add("lastName"); + addPrivate("lastName"); return lastName(lastName); } @@ -557,7 +548,7 @@ public Builder name(String name) { * @return the builder */ public Builder privateName(String name) { - privateAttrNames.add("name"); + addPrivate("name"); return name(name); } @@ -579,7 +570,7 @@ public Builder avatar(String avatar) { * @return the builder */ public Builder privateAvatar(String avatar) { - privateAttrNames.add("avatar"); + addPrivate("avatar"); return avatar(avatar); } @@ -602,7 +593,7 @@ public Builder email(String email) { * @return the builder */ public Builder privateEmail(String email) { - privateAttrNames.add("email"); + addPrivate("email"); return email(email); } @@ -657,6 +648,9 @@ public Builder custom(String k, Boolean b) { public Builder custom(String k, JsonElement v) { checkCustomAttribute(k); if (k != null && v != null) { + if (custom == null) { + custom = new HashMap<>(); + } custom.put(k, v); } return this; @@ -672,15 +666,13 @@ public Builder custom(String k, JsonElement v) { * @return the builder */ public Builder customString(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (String v : vs) { if (v != null) { array.add(new JsonPrimitive(v)); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -693,15 +685,13 @@ public Builder customString(String k, List vs) { * @return the builder */ public Builder customNumber(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (Number v : vs) { if (v != null) { array.add(new JsonPrimitive(v)); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -714,15 +704,13 @@ public Builder customNumber(String k, List vs) { * @return the builder */ public Builder customValues(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (JsonElement v : vs) { if (v != null) { array.add(v); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -736,7 +724,7 @@ public Builder customValues(String k, List vs) { * @return the builder */ public Builder privateCustom(String k, String v) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, v); } @@ -751,7 +739,7 @@ public Builder privateCustom(String k, String v) { * @return the builder */ public Builder privateCustom(String k, Number n) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, n); } @@ -766,7 +754,7 @@ public Builder privateCustom(String k, Number n) { * @return the builder */ public Builder privateCustom(String k, Boolean b) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, b); } @@ -781,7 +769,7 @@ public Builder privateCustom(String k, Boolean b) { * @return the builder */ public Builder privateCustom(String k, JsonElement v) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, v); } @@ -796,7 +784,7 @@ public Builder privateCustom(String k, JsonElement v) { * @return the builder */ public Builder privateCustomString(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customString(k, vs); } @@ -811,7 +799,7 @@ public Builder privateCustomString(String k, List vs) { * @return the builder */ public Builder privateCustomNumber(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customNumber(k, vs); } @@ -826,7 +814,7 @@ public Builder privateCustomNumber(String k, List vs) { * @return the builder */ public Builder privateCustomValues(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customValues(k, vs); } @@ -839,6 +827,13 @@ private void checkCustomAttribute(String key) { } } + private void addPrivate(String key) { + if (privateAttrNames == null) { + privateAttrNames = new HashSet<>(); + } + privateAttrNames.add(key); + } + /** * Builds the configured {@link com.launchdarkly.client.LDUser} object. * diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index ca8a9f7f4..ccbfe8c95 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -11,20 +13,22 @@ import org.junit.Test; import java.lang.reflect.Type; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; 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.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; public class LDUserTest { - - private JsonPrimitive us = new JsonPrimitive(LDCountryCode.US.getAlpha2()); - + private static final Gson defaultGson = new Gson(); + @Test public void testLDUserConstructor() { LDUser user = new LDUser.Builder("key") @@ -44,169 +48,324 @@ public void testLDUserConstructor() { } @Test - public void testValidCountryCodeSetsCountry() { - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); + public void canSetKey() { + LDUser user = new LDUser.Builder("k").build(); + assertEquals("k", user.getKeyAsString()); + } + + @Test + public void canSetSecondary() { + LDUser user = new LDUser.Builder("key").secondary("s").build(); + assertEquals("s", user.getSecondary().getAsString()); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetPrivateSecondary() { + LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); + assertEquals("s", user.getSecondary().getAsString()); + assertEquals(ImmutableSet.of("secondary"), user.privateAttributeNames); + } + + @Test + public void canSetIp() { + LDUser user = new LDUser.Builder("key").ip("i").build(); + assertEquals("i", user.getIp().getAsString()); + } + + @Test + public void canSetPrivateIp() { + LDUser user = new LDUser.Builder("key").privateIp("i").build(); + assertEquals("i", user.getIp().getAsString()); + assertEquals(ImmutableSet.of("ip"), user.privateAttributeNames); } + @Test + public void canSetEmail() { + LDUser user = new LDUser.Builder("key").email("e").build(); + assertEquals("e", user.getEmail().getAsString()); + } + + @Test + public void canSetPrivateEmail() { + LDUser user = new LDUser.Builder("key").privateEmail("e").build(); + assertEquals("e", user.getEmail().getAsString()); + assertEquals(ImmutableSet.of("email"), user.privateAttributeNames); + } @Test - public void testValidCountryCodeStringSetsCountry() { - LDUser user = new LDUser.Builder("key").country("US").build(); + public void canSetName() { + LDUser user = new LDUser.Builder("key").name("n").build(); + assertEquals("n", user.getName().getAsString()); + } + + @Test + public void canSetPrivateName() { + LDUser user = new LDUser.Builder("key").privateName("n").build(); + assertEquals("n", user.getName().getAsString()); + assertEquals(ImmutableSet.of("name"), user.privateAttributeNames); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetAvatar() { + LDUser user = new LDUser.Builder("key").avatar("a").build(); + assertEquals("a", user.getAvatar().getAsString()); + } + + @Test + public void canSetPrivateAvatar() { + LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); + assertEquals("a", user.getAvatar().getAsString()); + assertEquals(ImmutableSet.of("avatar"), user.privateAttributeNames); } @Test - public void testValidCountryCode3SetsCountry() { - LDUser user = new LDUser.Builder("key").country("USA").build(); + public void canSetFirstName() { + LDUser user = new LDUser.Builder("key").firstName("f").build(); + assertEquals("f", user.getFirstName().getAsString()); + } + + @Test + public void canSetPrivateFirstName() { + LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); + assertEquals("f", user.getFirstName().getAsString()); + assertEquals(ImmutableSet.of("firstName"), user.privateAttributeNames); + } + + @Test + public void canSetLastName() { + LDUser user = new LDUser.Builder("key").lastName("l").build(); + assertEquals("l", user.getLastName().getAsString()); + } + + @Test + public void canSetPrivateLastName() { + LDUser user = new LDUser.Builder("key").privateLastName("l").build(); + assertEquals("l", user.getLastName().getAsString()); + assertEquals(ImmutableSet.of("lastName"), user.privateAttributeNames); + } + + @Test + public void canSetAnonymous() { + LDUser user = new LDUser.Builder("key").anonymous(true).build(); + assertEquals(true, user.getAnonymous().getAsBoolean()); + } + + @Test + public void canSetCountry() { + LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); + assertEquals("US", user.getCountry().getAsString()); + } + + @Test + public void canSetCountryAsString() { + LDUser user = new LDUser.Builder("key").country("US").build(); + assertEquals("US", user.getCountry().getAsString()); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetCountryAs3CharacterString() { + LDUser user = new LDUser.Builder("key").country("USA").build(); + assertEquals("US", user.getCountry().getAsString()); } @Test - public void testAmbiguousCountryNameSetsCountryWithExactMatch() { + public void ambiguousCountryNameSetsCountryWithExactMatch() { // "United States" is ambiguous: can also match "United States Minor Outlying Islands" LDUser user = new LDUser.Builder("key").country("United States").build(); - assert(user.getCountry().equals(us)); + assertEquals("US", user.getCountry().getAsString()); } @Test - public void testAmbiguousCountryNameSetsCountryWithPartialMatch() { + public void ambiguousCountryNameSetsCountryWithPartialMatch() { // For an ambiguous match, we return the first match LDUser user = new LDUser.Builder("key").country("United St").build(); - assert(user.getCountry() != null); + assertNotNull(user.getCountry()); } - @Test - public void testPartialUniqueMatchSetsCountry() { + public void partialUniqueMatchSetsCountry() { LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assert(user.getCountry().equals(new JsonPrimitive(LDCountryCode.UM.getAlpha2()))); + assertEquals("UM", user.getCountry().getAsString()); } @Test - public void testInvalidCountryNameDoesNotSetCountry() { + public void invalidCountryNameDoesNotSetCountry() { LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assert(user.getCountry() == null); + assertNull(user.getCountry()); } @Test - public void testLDUserJsonSerializationContainsCountryAsTwoDigitCode() { - LDConfig config = LDConfig.DEFAULT; - Gson gson = config.gson; - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - - String jsonStr = gson.toJson(user); - Type type = new TypeToken>(){}.getType(); - Map json = gson.fromJson(jsonStr, type); - - assert(json.get("country").equals(us)); + public void canSetPrivateCountry() { + LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); + assertEquals("US", user.getCountry().getAsString()); + assertEquals(ImmutableSet.of("country"), user.privateAttributeNames); } @Test - public void testLDUserCustomMarshalWithPrivateAttrsProducesEquivalentLDUserIfNoAttrsArePrivate() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .anonymous(true) - .avatar("avatar") - .country(LDCountryCode.AC) - .ip("127.0.0.1") - .firstName("bob") - .lastName("loblaw") - .email("bob@example.com") - .custom("foo", 42) - .build(); - - String jsonStr = new Gson().toJson(user); - Type type = new TypeToken>(){}.getType(); - Map json = config.gson.fromJson(jsonStr, type); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertEquals(json, privateJson); + public void canSetCustomString() { + LDUser user = new LDUser.Builder("key").custom("thing", "value").build(); + assertEquals("value", user.getCustom("thing").getAsString()); } - + @Test - public void testLDUserCustomMarshalWithAllPrivateAttributesReturnsKey() { - LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); - LDUser user = new LDUser.Builder("key") - .email("foo@bar.com") - .custom("bar", 43) - .build(); + public void canSetPrivateCustomString() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); + assertEquals("value", user.getCustom("thing").getAsString()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); + @Test + public void canSetCustomInt() { + LDUser user = new LDUser.Builder("key").custom("thing", 1).build(); + assertEquals(1, user.getCustom("thing").getAsInt()); + } + + @Test + public void canSetPrivateCustomInt() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); + assertEquals(1, user.getCustom("thing").getAsInt()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } + + @Test + public void canSetCustomBoolean() { + LDUser user = new LDUser.Builder("key").custom("thing", true).build(); + assertEquals(true, user.getCustom("thing").getAsBoolean()); + } + + @Test + public void canSetPrivateCustomBoolean() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); + assertEquals(true, user.getCustom("thing").getAsBoolean()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } + + @Test + public void canSetCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").custom("thing", value).build(); + assertEquals(value, user.getCustom("thing")); + } - assertNull(privateJson.get("custom")); - assertEquals(privateJson.get("key").getAsString(), "key"); + @Test + public void canSetPrivateCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); + assertEquals(value, user.getCustom("thing")); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } - // email and custom are private - assert(privateJson.get("privateAttrs").getAsJsonArray().size() == 2); - assertNull(privateJson.get("email")); + @Test + public void testAllPropertiesInDefaultEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = defaultGson.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = defaultGson.toJsonTree(e.getKey()); + assertEquals(expected, actual); + } + } + + @Test + public void testAllPropertiesInPrivateAttributeEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = defaultGson.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = LDConfig.DEFAULT.gson.toJsonTree(e.getKey()); + assertEquals(expected, actual); + } } + private Map getUserPropertiesJsonMap() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.put(new LDUser.Builder("userkey").build(), "{\"key\":\"userkey\"}"); + builder.put(new LDUser.Builder("userkey").secondary("value").build(), + "{\"key\":\"userkey\",\"secondary\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").ip("value").build(), + "{\"key\":\"userkey\",\"ip\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").email("value").build(), + "{\"key\":\"userkey\",\"email\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").name("value").build(), + "{\"key\":\"userkey\",\"name\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").avatar("value").build(), + "{\"key\":\"userkey\",\"avatar\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").firstName("value").build(), + "{\"key\":\"userkey\",\"firstName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").lastName("value").build(), + "{\"key\":\"userkey\",\"lastName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").anonymous(true).build(), + "{\"key\":\"userkey\",\"anonymous\":true}"); + builder.put(new LDUser.Builder("userkey").country(LDCountryCode.US).build(), + "{\"key\":\"userkey\",\"country\":\"US\"}"); + builder.put(new LDUser.Builder("userkey").custom("thing", "value").build(), + "{\"key\":\"userkey\",\"custom\":{\"thing\":\"value\"}}"); + return builder.build(); + } + + @Test + public void defaultJsonEncodingHasPrivateAttributeNames() { + LDUser user = new LDUser.Builder("userkey").privateName("x").privateEmail("y").build(); + String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"email\":\"y\",\"privateAttributeNames\":[\"name\",\"email\"]}"; + assertEquals(defaultGson.fromJson(expected, JsonElement.class), defaultGson.toJsonTree(user)); + } + @Test - public void testLDUserAnonymousAttributeIsNeverPrivate() { + public void privateAttributeEncodingRedactsAllPrivateAttributes() { LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); - LDUser user = new LDUser.Builder("key") + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") .anonymous(true) + .country(LDCountryCode.US) + .custom("thing", "value") .build(); + Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertEquals(privateJson.get("anonymous").getAsBoolean(), true); - assertNull(privateJson.get("privateAttrs")); + JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("userkey", o.get("key").getAsString()); + assertEquals(true, o.get("anonymous").getAsBoolean()); + for (String attr: redacted) { + assertNull(o.get(attr)); + } + assertNull(o.get("custom")); + assertEquals(redacted, getPrivateAttrs(o)); } - + @Test - public void testLDUserCustomMarshalWithPrivateAttrsRedactsCorrectAttrs() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .privateCustom("foo", 42) + public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") .custom("bar", 43) - .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertNull(privateJson.get("custom").getAsJsonObject().get("foo")); - assertEquals(privateJson.get("key").getAsString(), "key"); - assertEquals(privateJson.get("custom").getAsJsonObject().get("bar"), new JsonPrimitive(43)); - } - - @Test - public void testLDUserCustomMarshalWithPrivateGlobalAttributesRedactsCorrectAttrs() { - LDConfig config = new LDConfig.Builder().privateAttributeNames("foo", "bar").build(); - - LDUser user = new LDUser.Builder("key") .privateCustom("foo", 42) - .custom("bar", 43) - .custom("baz", 44) - .privateCustom("bum", 45) .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertNull(privateJson.get("custom").getAsJsonObject().get("foo")); - assertNull(privateJson.get("custom").getAsJsonObject().get("bar")); - assertNull(privateJson.get("custom").getAsJsonObject().get("bum")); - assertEquals(privateJson.get("custom").getAsJsonObject().get("baz"), new JsonPrimitive(44)); + + JsonObject o = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); } @Test - public void testLDUserCustomMarshalWithBuiltInAttributesRedactsCorrectAttrs() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .privateEmail("foo@bar.com") + public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { + LDConfig config = new LDConfig.Builder().privateAttributeNames("name", "foo").build(); + LDUser user = new LDUser.Builder("userkey") + .email("e") + .name("n") .custom("bar", 43) + .custom("foo", 42) .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - assertNull(privateJson.get("email")); + + JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); } @Test @@ -242,25 +401,6 @@ public void getValueReturnsNullIfNotFound() { assertNull(user.getValueForEvaluation("height")); } - @Test - public void canAddCustomAttrWithJsonValue() { - JsonElement value = new JsonPrimitive("x"); - LDUser user = new LDUser.Builder("key") - .custom("foo", value) - .build(); - assertEquals(value, user.getCustom("foo")); - } - - @Test - public void canAddPrivateCustomAttrWithJsonValue() { - JsonElement value = new JsonPrimitive("x"); - LDUser user = new LDUser.Builder("key") - .privateCustom("foo", value) - .build(); - assertEquals(value, user.getCustom("foo")); - assertTrue(user.privateAttributeNames.contains("foo")); - } - @Test public void canAddCustomAttrWithListOfStrings() { LDUser user = new LDUser.Builder("key") @@ -300,4 +440,9 @@ private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... v ret.add(name, a); return ret; } + + private Set getPrivateAttrs(JsonObject o) { + Type type = new TypeToken>(){}.getType(); + return new HashSet(defaultGson.>fromJson(o.get("privateAttrs"), type)); + } } From 6abbb228a73c2921a9a2486065878e73e65d1d8e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Aug 2018 11:05:58 -0700 Subject: [PATCH 041/156] preserve prerequisite flag value in event even if flag was off --- .../com/launchdarkly/client/FeatureFlag.java | 26 ++++++-------- .../launchdarkly/client/FeatureFlagTest.java | 34 ++++++++++++++++++- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index c13464c7a..7cc7dde91 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -74,16 +74,16 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), prereqEvents); } - if (isOn()) { - EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); - return new EvalResult(details, prereqEvents); - } - - return new EvalResult(getOffValue(EvaluationReason.off()), prereqEvents); + EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); + return new EvalResult(details, prereqEvents); } private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) { + if (!isOn()) { + return getOffValue(EvaluationReason.off()); + } + EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { return getOffValue(prereqFailureReason); @@ -123,20 +123,16 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - EvaluationDetail prereqEvalResult = null; if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); prereqOk = false; - } else if (prereqFeatureFlag.isOn()) { - prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); - if (prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { + } else { + EvaluationDetail prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); + // Note that if the prerequisite flag is off, we don't consider it a match no matter what its + // off variation was. But we still need to evaluate it in order to generate an event. + if (!prereqFeatureFlag.isOn() || prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { prereqOk = false; } - } else { - prereqOk = false; - } - // We continue to evaluate all prerequisites even if one failed. - if (prereqFeatureFlag != null) { events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 6ba5317b5..1d3c800c5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -171,7 +171,39 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } - + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() 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(false) + .offVariation(1) + // note that even though it returns the desired variation, it is still off and therefore not a match + .fallthrough(fallthroughVariation(0)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + featureStore.upsert(FEATURES, f1); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); + + assertEquals(1, result.getPrerequisiteEvents().size()); + Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event.key); + assertEquals(js("go"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); + } + @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") From c18a1e17daa091289cf7afddc690bde065b0a5b8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Aug 2018 16:26:31 -0700 Subject: [PATCH 042/156] more test coverage for requesting values of different types --- .../client/LDClientEvaluationTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 201978b9f..e07777647 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -54,6 +54,13 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertFalse(client.boolVariation("key", user, false)); } + @Test + public void boolVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertFalse(client.boolVariation("key", user, false)); + } + @Test public void intVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); @@ -61,11 +68,25 @@ public void intVariationReturnsFlagValue() throws Exception { assertEquals(new Integer(2), client.intVariation("key", user, 1)); } + @Test + public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.0))); + + assertEquals(new Integer(2), client.intVariation("key", user, 1)); + } + @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(new Integer(1), client.intVariation("key", user, 1)); } + @Test + public void intVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertEquals(new Integer(1), client.intVariation("key", user, 1)); + } + @Test public void doubleVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.5d))); @@ -73,11 +94,25 @@ public void doubleVariationReturnsFlagValue() throws Exception { assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } + @Test + public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); + + assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); + } + @Test public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); } + @Test + public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + } + @Test public void stringVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", js("b"))); @@ -90,6 +125,13 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception assertEquals("a", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + assertEquals("a", client.stringVariation("key", user, "a")); + } + @Test public void jsonVariationReturnsFlagValue() throws Exception { JsonObject data = new JsonObject(); From 00ad42866516759d2411dac7be9412927e3c8b9e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 11 Sep 2018 11:09:18 -0700 Subject: [PATCH 043/156] send correct event schema version --- .../launchdarkly/client/DefaultEventProcessor.java | 2 +- .../client/DefaultEventProcessorTest.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index eed973fa2..d242ed623 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -37,7 +37,7 @@ final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); private static final int CHANNEL_BLOCK_MILLIS = 1000; private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; - private static final String EVENT_SCHEMA_VERSION = "2"; + private static final String EVENT_SCHEMA_VERSION = "3"; private final BlockingQueue inputChannel; private final ScheduledExecutorService scheduler; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index c8184c38c..03cba9c09 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -410,6 +410,19 @@ public void sdkKeyIsSent() throws Exception { assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); } + @Test + public void eventSchemaIsSent() throws Exception { + ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + server.enqueue(new MockResponse()); + ep.close(); + RecordedRequest req = server.takeRequest(); + + assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); From 7c24d76bb1edbb0ac9af7a9dc340a2f3ab863bdf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 26 Sep 2018 11:18:19 -0700 Subject: [PATCH 044/156] new option for sending less bootstrap data based on event tracking status --- .../client/FeatureFlagsState.java | 23 +++++--- .../launchdarkly/client/FlagsStateOption.java | 8 +++ .../client/FeatureFlagsStateTest.java | 2 +- .../client/LDClientEvaluationTest.java | 52 ++++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index bd962538b..559c219ff 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -33,16 +33,16 @@ public class FeatureFlagsState { static class FlagMetadata { final Integer variation; final EvaluationReason reason; - final int version; - final boolean trackEvents; + final Integer version; + final Boolean trackEvents; final Long debugEventsUntilDate; - FlagMetadata(Integer variation, EvaluationReason reason, int version, boolean trackEvents, + FlagMetadata(Integer variation, EvaluationReason reason, Integer version, boolean trackEvents, Long debugEventsUntilDate) { this.variation = variation; this.reason = reason; this.version = version; - this.trackEvents = trackEvents; + this.trackEvents = trackEvents ? Boolean.TRUE : null; this.debugEventsUntilDate = debugEventsUntilDate; } @@ -51,8 +51,8 @@ public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; return Objects.equal(variation, o.variation) && - version == o.version && - trackEvents == o.trackEvents && + Objects.equal(version, o.version) && + Objects.equal(trackEvents, o.trackEvents) && Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); } return false; @@ -131,10 +131,12 @@ static class Builder { private Map flagValues = new HashMap<>(); private Map flagMetadata = new HashMap<>(); private final boolean saveReasons; + private final boolean detailsOnlyForTrackedFlags; private boolean valid = true; Builder(FlagsStateOption... options) { saveReasons = FlagsStateOption.hasOption(options, FlagsStateOption.WITH_REASONS); + detailsOnlyForTrackedFlags = FlagsStateOption.hasOption(options, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); } Builder valid(boolean valid) { @@ -144,9 +146,14 @@ Builder valid(boolean valid) { Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { flagValues.put(flag.getKey(), eval.getValue()); + final boolean flagIsTracked = flag.isTrackEvents() || + (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); + final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; FlagMetadata data = new FlagMetadata(eval.getVariationIndex(), - saveReasons ? eval.getReason() : null, - flag.getVersion(), flag.isTrackEvents(), flag.getDebugEventsUntilDate()); + (saveReasons && wantDetails) ? eval.getReason() : null, + wantDetails ? flag.getVersion() : null, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate()); flagMetadata.put(flag.getKey(), data); return this; } diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index f519f1bc8..71cb14829 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -27,6 +27,14 @@ public String toString() { */ public static final FlagsStateOption WITH_REASONS = new FlagsStateOption("WITH_REASONS"); + /** + * Specifies that any flag metadata that is normally only used for event generation - such as flag versions and + * evaluation reasons - should be omitted for any flag that does not have event tracking or debugging turned on. + * This reduces the size of the JSON data if you are passing the flag state to the front end. + * @since 4.4.0 + */ + public static final FlagsStateOption DETAILS_ONLY_FOR_TRACKED_FLAGS = new FlagsStateOption("DETAILS_ONLY_FOR_TRACKED_FLAGS"); + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { for (FlagsStateOption o: options) { if (o == option) { diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 86944409c..bff704643 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -89,7 +89,7 @@ public void canConvertToJson() { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":false" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted "},\"key2\":{" + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e07777647..8a5ef6586 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -293,7 +293,7 @@ public void allFlagsStateReturnsState() throws Exception { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "\"variation\":0,\"version\":100" + "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + @@ -350,7 +350,7 @@ public void allFlagsStateReturnsStateWithReasons() { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":false" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + "},\"key2\":{" + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + @@ -361,6 +361,54 @@ public void allFlagsStateReturnsStateWithReasons() { assertEquals(expected, gson.toJsonTree(state)); } + @Test + public void allFlagsStateCanOmitDetailsForUntrackedFlags() { + long futureTime = System.currentTimeMillis() + 1000000; + FeatureFlag flag1 = new FeatureFlagBuilder("key1") + .version(100) + .trackEvents(false) + .on(false) + .offVariation(0) + .variations(js("value1")) + .build(); + FeatureFlag flag2 = new FeatureFlagBuilder("key2") + .version(200) + .trackEvents(true) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + FeatureFlag flag3 = new FeatureFlagBuilder("key3") + .version(300) + .trackEvents(false) + .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false + .on(false) + .offVariation(0) + .variations(js("value3")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + featureStore.upsert(FEATURES, flag3); + + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0" + // note, version and reason are omitted, and so is trackEvents: false + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true" + + "},\"key3\":{" + + "\"variation\":0,\"version\":300,\"reason\":{\"kind\":\"OFF\"},\"debugEventsUntilDate\":" + futureTime + + "}" + + "}," + + "\"$valid\":true" + + "}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.toJsonTree(state)); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); From dcdad5e00df8f16cb7ead91fd6627fad74134082 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 16:41:49 -0700 Subject: [PATCH 045/156] change Gradle build to fix pom --- build.gradle | 116 +++++++++++++++++++++++++----------------------- settings.gradle | 1 + 2 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 settings.gradle diff --git a/build.gradle b/build.gradle index 192251f42..ba7eaec9c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ apply plugin: 'java' -apply plugin: 'maven' +apply plugin: 'maven-publish' apply plugin: 'org.ajoberstar.github-pages' apply plugin: 'signing' apply plugin: 'idea' @@ -27,7 +27,7 @@ allprojects { ext.libraries = [:] -libraries.shaded = [ +libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", @@ -35,27 +35,25 @@ libraries.shaded = [ "redis.clients:jedis:2.9.0" ] -libraries.unshaded = [ +libraries.external = [ "com.google.code.gson:gson:2.7", "org.slf4j:slf4j-api:1.7.21" ] -libraries.testCompile = [ +libraries.test = [ "com.squareup.okhttp3:mockwebserver:3.10.0", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", - "junit:junit:4.12" -] - -libraries.testRuntime = [ + "junit:junit:4.12", "ch.qos.logback:logback-classic:1.1.7" ] dependencies { - compile libraries.shaded, libraries.unshaded - testCompile libraries.testCompile - testRuntime libraries.testRuntime - shadow libraries.unshaded + implementation libraries.internal + compileClasspath libraries.external + runtime libraries.internal, libraries.external + testImplementation libraries.test, libraries.internal, libraries.external + shadow libraries.external } jar { @@ -96,11 +94,6 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } -// add javadoc/source jar tasks as artifacts -artifacts { - archives sourcesJar, javadocJar, shadowJar -} - githubPages { repoUri = 'https://github.com/launchdarkly/java-client.git' pages { @@ -185,7 +178,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ } artifacts { - archives shadowJarAll + archives sourcesJar, javadocJar, shadowJar, shadowJarAll } test { @@ -200,7 +193,6 @@ signing { sign configurations.archives } - idea { module { downloadJavadoc = true @@ -213,47 +205,59 @@ nexusStaging { packageGroup = "com.launchdarkly" } -uploadArchives { - repositories { - mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } +def pomConfig = { + name 'LaunchDarkly SDK for Java' + packaging 'jar' + url 'https://github.com/launchdarkly/java-client' - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } + licenses { + license { + name 'The Apache License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } - snapshotRepository(url: "https://oss.sonatype.org/content/repositories/snapshots/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } + developers { + developer { + id 'jkodumal' + name 'John Kodumal' + email 'john@catamorphic.com' + } + } + + scm { + connection 'scm:git:git://github.com/launchdarkly/java-client.git' + developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-client.git' + url 'https://github.com/launchdarkly/java-client' + } +} - pom.project { - name 'LaunchDarkly SDK for Java' - packaging 'jar' - description 'Official LaunchDarkly SDK for Java' - url 'https://github.com/launchdarkly/java-client' - - licenses { - license { - name 'The Apache License, Version 2.0' - url 'http://www.apache.org/licenses/LICENSE-2.0.txt' - } - } - - developers { - developer { - id 'jkodumal' - name 'John Kodumal' - email 'john@catamorphic.com' - } - } - - scm { - connection 'scm:git:git://github.com/launchdarkly/java-client.git' - developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-client.git' - url 'https://github.com/launchdarkly/java-client' - } +publishing { + publications { + shadow(MavenPublication) { publication -> + project.shadow.component(publication) + + artifactId = 'launchdarkly-client' + artifact sourcesJar + artifact javadocJar + + pom.withXml { + def root = asNode() + root.appendNode('description', 'Official LaunchDarkly SDK for Java') + asNode().children().last() + pomConfig + } + } + } + repositories { + mavenLocal() + maven { + def releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + def snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots/" + url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + credentials { + username ossrhUsername + password ossrhPassword } } } } - diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..80a446a15 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-client' From 5bba52a703a766eb10dd11aef072bedeb326ed0c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 16:52:06 -0700 Subject: [PATCH 046/156] misc build fixes --- build.gradle | 3 ++- scripts/release.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ba7eaec9c..c03dcd125 100644 --- a/build.gradle +++ b/build.gradle @@ -178,7 +178,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ } artifacts { - archives sourcesJar, javadocJar, shadowJar, shadowJarAll + archives jar, sourcesJar, javadocJar, shadowJar, shadowJarAll } test { @@ -240,6 +240,7 @@ publishing { artifactId = 'launchdarkly-client' artifact sourcesJar artifact javadocJar + artifact shadowJarAll pom.withXml { def root = asNode() diff --git a/scripts/release.sh b/scripts/release.sh index a637262ca..154e8a0ab 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -21,6 +21,6 @@ rm -f gradle.properties.bak sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md rm -f README.md.bak -./gradlew clean install sourcesJar javadocJar uploadArchives closeAndReleaseRepository +./gradlew clean install sourcesJar javadocJar publish closeAndReleaseRepository ./gradlew publishGhPages echo "Finished java-client release." From 8af7519df898eb26e96c1d95e93e9fa39fbee28b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 17:02:36 -0700 Subject: [PATCH 047/156] misc fixes --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index c03dcd125..ba9dd82fb 100644 --- a/build.gradle +++ b/build.gradle @@ -142,6 +142,8 @@ shadowJar { } } +// This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, +// Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { baseName = 'launchdarkly-client' classifier = 'all' @@ -238,6 +240,7 @@ publishing { project.shadow.component(publication) artifactId = 'launchdarkly-client' + artifact jar artifact sourcesJar artifact javadocJar artifact shadowJarAll From 2faeeb33504806e6255c2f51ee4c20f10701c6bd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 15 Oct 2018 11:54:46 -0700 Subject: [PATCH 048/156] fix John's email --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ba9dd82fb..7d08113ce 100644 --- a/build.gradle +++ b/build.gradle @@ -223,7 +223,7 @@ def pomConfig = { developer { id 'jkodumal' name 'John Kodumal' - email 'john@catamorphic.com' + email 'john@launchdarkly.com' } } From d79ef2f0830d8bd42233667dbe72b8a865b9c0a2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Oct 2018 11:50:50 -0700 Subject: [PATCH 049/156] fix build so jar signatures will be published --- build.gradle | 10 +++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 7d08113ce..02d336d7f 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ jar { } task wrapper(type: Wrapper) { - gradleVersion = '4.2.1' + gradleVersion = '4.10.2' } buildscript { @@ -191,10 +191,6 @@ test { } } -signing { - sign configurations.archives -} - idea { module { downloadJavadoc = true @@ -265,3 +261,7 @@ publishing { } } } + +signing { + sign publishing.publications.shadow +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 74bb77845..fb7ef980f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip From 3ed73490a72d93f09e0242d3c3eddf694725d0bb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Oct 2018 16:26:42 -0700 Subject: [PATCH 050/156] add file data source to Java SDK --- build.gradle | 1 + .../client/files/DataBuilder.java | 32 ++++ .../launchdarkly/client/files/DataLoader.java | 58 ++++++ .../client/files/DataLoaderException.java | 43 +++++ .../client/files/FileComponents.java | 107 +++++++++++ .../client/files/FileDataSource.java | 180 ++++++++++++++++++ .../client/files/FileDataSourceFactory.java | 78 ++++++++ .../client/files/FlagFactory.java | 56 ++++++ .../client/files/FlagFileParser.java | 39 ++++ .../client/files/FlagFileRep.java | 23 +++ .../client/files/JsonFlagFileParser.java | 30 +++ .../client/files/YamlFlagFileParser.java | 52 +++++ .../client/files/DataLoaderTest.java | 106 +++++++++++ .../client/files/FileDataSourceTest.java | 174 +++++++++++++++++ .../client/files/FlagFileParserTestBase.java | 76 ++++++++ .../client/files/JsonFlagFileParserTest.java | 7 + .../launchdarkly/client/files/TestData.java | 48 +++++ .../client/files/YamlFlagFileParserTest.java | 7 + .../resources/filesource/all-properties.json | 17 ++ .../resources/filesource/all-properties.yml | 11 ++ src/test/resources/filesource/flag-only.json | 8 + src/test/resources/filesource/flag-only.yml | 5 + .../filesource/flag-with-duplicate-key.json | 12 ++ src/test/resources/filesource/malformed.json | 1 + src/test/resources/filesource/malformed.yml | 2 + .../resources/filesource/segment-only.json | 8 + .../resources/filesource/segment-only.yml | 5 + .../segment-with-duplicate-key.json | 12 ++ src/test/resources/filesource/value-only.json | 5 + src/test/resources/filesource/value-only.yml | 3 + .../filesource/value-with-duplicate-key.json | 6 + 31 files changed, 1212 insertions(+) create mode 100644 src/main/java/com/launchdarkly/client/files/DataBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/files/DataLoader.java create mode 100644 src/main/java/com/launchdarkly/client/files/DataLoaderException.java create mode 100644 src/main/java/com/launchdarkly/client/files/FileComponents.java create mode 100644 src/main/java/com/launchdarkly/client/files/FileDataSource.java create mode 100644 src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java create mode 100644 src/main/java/com/launchdarkly/client/files/FlagFactory.java create mode 100644 src/main/java/com/launchdarkly/client/files/FlagFileParser.java create mode 100644 src/main/java/com/launchdarkly/client/files/FlagFileRep.java create mode 100644 src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java create mode 100644 src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java create mode 100644 src/test/java/com/launchdarkly/client/files/DataLoaderTest.java create mode 100644 src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java create mode 100644 src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java create mode 100644 src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java create mode 100644 src/test/java/com/launchdarkly/client/files/TestData.java create mode 100644 src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java create mode 100644 src/test/resources/filesource/all-properties.json create mode 100644 src/test/resources/filesource/all-properties.yml create mode 100644 src/test/resources/filesource/flag-only.json create mode 100644 src/test/resources/filesource/flag-only.yml create mode 100644 src/test/resources/filesource/flag-with-duplicate-key.json create mode 100644 src/test/resources/filesource/malformed.json create mode 100644 src/test/resources/filesource/malformed.yml create mode 100644 src/test/resources/filesource/segment-only.json create mode 100644 src/test/resources/filesource/segment-only.yml create mode 100644 src/test/resources/filesource/segment-with-duplicate-key.json create mode 100644 src/test/resources/filesource/value-only.json create mode 100644 src/test/resources/filesource/value-only.yml create mode 100644 src/test/resources/filesource/value-with-duplicate-key.json diff --git a/build.gradle b/build.gradle index 02d336d7f..3f8b96823 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ libraries.internal = [ "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", "com.launchdarkly:okhttp-eventsource:1.7.1", + "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] diff --git a/src/main/java/com/launchdarkly/client/files/DataBuilder.java b/src/main/java/com/launchdarkly/client/files/DataBuilder.java new file mode 100644 index 000000000..e9bc580a9 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/DataBuilder.java @@ -0,0 +1,32 @@ +package com.launchdarkly.client.files; + +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import java.util.HashMap; +import java.util.Map; + +/** + * Internal data structure that organizes flag/segment data into the format that the feature store + * expects. Will throw an exception if we try to add the same flag or segment key more than once. + */ +class DataBuilder { + private final Map, Map> allData = new HashMap<>(); + + public Map, Map> build() { + return allData; + } + + public void add(VersionedDataKind kind, VersionedData item) throws DataLoaderException { + @SuppressWarnings("unchecked") + Map items = (Map)allData.get(kind); + if (items == null) { + items = new HashMap(); + allData.put(kind, items); + } + if (items.containsKey(item.getKey())) { + throw new DataLoaderException("in " + kind.getNamespace() + ", key \"" + item.getKey() + "\" was already defined", null, null); + } + items.put(item.getKey(), item); + } +} diff --git a/src/main/java/com/launchdarkly/client/files/DataLoader.java b/src/main/java/com/launchdarkly/client/files/DataLoader.java new file mode 100644 index 000000000..0b4ad431c --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/DataLoader.java @@ -0,0 +1,58 @@ +package com.launchdarkly.client.files; + +import com.google.gson.JsonElement; +import com.launchdarkly.client.VersionedDataKind; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Implements the loading of flag data from one or more files. Will throw an exception if any file can't + * be read or parsed, or if any flag or segment keys are duplicates. + */ +final class DataLoader { + private final List files; + + public DataLoader(List files) { + this.files = new ArrayList(files); + } + + public Iterable getFiles() { + return files; + } + + public void load(DataBuilder builder) throws DataLoaderException + { + for (Path p: files) { + try { + byte[] data = Files.readAllBytes(p); + FlagFileParser parser = FlagFileParser.selectForContent(data); + FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); + if (fileContents.flags != null) { + for (Map.Entry e: fileContents.flags.entrySet()) { + builder.add(VersionedDataKind.FEATURES, FlagFactory.flagFromJson(e.getValue())); + } + } + if (fileContents.flagValues != null) { + for (Map.Entry e: fileContents.flagValues.entrySet()) { + builder.add(VersionedDataKind.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); + } + } + if (fileContents.segments != null) { + for (Map.Entry e: fileContents.segments.entrySet()) { + builder.add(VersionedDataKind.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); + } + } + } catch (DataLoaderException e) { + throw new DataLoaderException(e.getMessage(), e.getCause(), p); + } catch (IOException e) { + throw new DataLoaderException(null, e, p); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/files/DataLoaderException.java b/src/main/java/com/launchdarkly/client/files/DataLoaderException.java new file mode 100644 index 000000000..184a3211a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/DataLoaderException.java @@ -0,0 +1,43 @@ +package com.launchdarkly.client.files; + +import java.nio.file.Path; + +/** + * Indicates that the file processor encountered an error in one of the input files. This exception is + * not surfaced to the host application, it is only logged, and we don't do anything different programmatically + * with different kinds of exceptions, therefore it has no subclasses. + */ +@SuppressWarnings("serial") +class DataLoaderException extends Exception { + private final Path filePath; + + public DataLoaderException(String message, Throwable cause, Path filePath) { + super(message, cause); + this.filePath = filePath; + } + + public DataLoaderException(String message, Throwable cause) { + this(message, cause, null); + } + + public Path getFilePath() { + return filePath; + } + + public String getDescription() { + StringBuilder s = new StringBuilder(); + if (getMessage() != null) { + s.append(getMessage()); + if (getCause() != null) { + s.append(" "); + } + } + if (getCause() != null) { + s.append(" [").append(getCause().toString()).append("]"); + } + if (filePath != null) { + s.append(": ").append(filePath); + } + return s.toString(); + } +} diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java new file mode 100644 index 000000000..5baf2e586 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -0,0 +1,107 @@ +package com.launchdarkly.client.files; + +/** + * The entry point for the file data source, which allows you to use local files as a source of + * feature flag state. This would typically be used in a test environment, to operate using a + * predetermined feature flag state without an actual LaunchDarkly connection. + *

+ * To use this component, call {@link #fileDataSource()} to obtain a factory object, call one or + * methods to configure it, and then add it to your LaunchDarkly client configuration. At a + * minimum, you will want to call {@link FileDataSourceFactory#filePaths(String...)} to specify + * your data file(s); you can also use {@link FileDataSourceFactory#autoUpdate(boolean)} to + * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceFactory} + * for all configuration options. + *

+ *     FileDataSourceFactory f = FileComponents.fileDataSource()
+ *         .filePaths("./testData/flags.json")
+ *         .autoUpdate(true);
+ *     LDConfig config = new LDConfig.Builder()
+ *         .updateProcessorFactory(f)
+ *         .build();
+ * 
+ *

+ * This will cause the client not to connect to LaunchDarkly to get feature flags. The + * client may still make network connections to send analytics events, unless you have disabled + * this with {@link com.launchdarkly.client.LDConfig.Builder#sendEvents(boolean)} or + * {@link com.launchdarkly.client.LDConfig.Builder#offline(boolean)}. + *

+ * Flag data files can be either JSON or YAML. They contain an object with three possible + * properties: + *

    + *
  • {@code flags}: Feature flag definitions. + *
  • {@code flagVersions}: Simplified feature flags that contain only a value. + *
  • {@code segments}: User segment definitions. + *
+ *

+ * The format of the data in `flags` and `segments` is defined by the LaunchDarkly application + * and is subject to change. Rather than trying to construct these objects yourself, it is simpler + * to request existing flags directly from the LaunchDarkly server in JSON format, and use this + * output as the starting point for your file. In Linux you would do this: + *

+ *     curl -H "Authorization: " https://app.launchdarkly.com/sdk/latest-all
+ * 
+ *

+ * The output will look something like this (but with many more properties): + *

+ * {
+ *     "flags": {
+ *         "flag-key-1": {
+ *             "key": "flag-key-1",
+ *             "on": true,
+ *             "variations": [ "a", "b" ]
+ *         },
+ *         "flag-key-2": {
+ *             "key": "flag-key-2",
+ *             "on": true,
+ *             "variations": [ "c", "d" ]
+ *         }
+ *     },
+ *     "segments": {
+ *         "segment-key-1": {
+ *             "key": "segment-key-1",
+ *             "includes": [ "user-key-1" ]
+ *         }
+ *     }
+ * }
+ * 
+ *

+ * Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported + * by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to + * set specific flag keys to specific values. For that, you can use a much simpler format: + *

+ * {
+ *     "flagValues": {
+ *         "my-string-flag-key": "value-1",
+ *         "my-boolean-flag-key": true,
+ *         "my-integer-flag-key": 3
+ *     }
+ * }
+ * 
+ *

+ * Or, in YAML: + *

+ * flagValues:
+ *   my-string-flag-key: "value-1"
+ *   my-boolean-flag-key: true
+ *   my-integer-flag-key: 3
+ * 
+ *

+ * It is also possible to specify both {@code flags} and {@code flagValues}, if you want some flags + * to have simple values and others to have complex behavior. However, it is an error to use the + * same flag key or segment key more than once, either in a single file or across multiple files. + *

+ * If the data source encounters any error in any file-- malformed content, a missing file, or a + * duplicate key-- it will not load flags from any of the files. + * + * @since 4.5.0 + */ +public abstract class FileComponents { + /** + * Creates a {@link FileDataSourceFactory} which you can use to configure the file data + * source. + * @return a {@link FileDataSourceFactory} + */ + public static FileDataSourceFactory fileDataSource() { + return new FileDataSourceFactory(); + } +} diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSource.java b/src/main/java/com/launchdarkly/client/files/FileDataSource.java new file mode 100644 index 000000000..3fdc3b305 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FileDataSource.java @@ -0,0 +1,180 @@ +package com.launchdarkly.client.files; + +import com.google.common.util.concurrent.Futures; +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.UpdateProcessor; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.Watchable; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; + +/** + * Implements taking flag data from files and putting it into the feature store, at startup time and + * optionally whenever files change. + */ +class FileDataSource implements UpdateProcessor { + private static final Logger logger = LoggerFactory.getLogger(FileDataSource.class); + + private final FeatureStore store; + private final DataLoader dataLoader; + private final AtomicBoolean inited = new AtomicBoolean(false); + private final FileWatcher fileWatcher; + + FileDataSource(FeatureStore store, DataLoader dataLoader, boolean autoUpdate) { + this.store = store; + this.dataLoader = dataLoader; + + FileWatcher fw = null; + if (autoUpdate) { + try { + fw = FileWatcher.create(dataLoader.getFiles()); + } catch (IOException e) { + logger.error("Unable to watch files for auto-updating: " + e); + fw = null; + } + } + fileWatcher = fw; + } + + @Override + public Future start() { + final Future initFuture = Futures.immediateFuture(null); + + reload(); + + // Note that if reload() finds any errors, it will not set our status to "initialized". But we + // will still do all the other startup steps, because we still might end up getting valid data + // from the secondary processor, or from a change detected by the file watcher. + + if (fileWatcher != null) { + fileWatcher.start(new Runnable() { + public void run() { + FileDataSource.this.reload(); + } + }); + } + + return initFuture; + } + + private boolean reload() { + DataBuilder builder = new DataBuilder(); + try { + dataLoader.load(builder); + } catch (DataLoaderException e) { + logger.error(e.getDescription()); + return false; + } + store.init(builder.build()); + inited.set(true); + return true; + } + + @Override + public boolean initialized() { + return inited.get(); + } + + @Override + public void close() throws IOException { + if (fileWatcher != null) { + fileWatcher.stop(); + } + } + + /** + * If auto-updating is enabled, this component watches for file changes on a worker thread. + */ + private static class FileWatcher implements Runnable { + private final WatchService watchService; + private final Set watchedFilePaths; + private Runnable fileModifiedAction; + private Thread thread; + private volatile boolean stopped; + + private static FileWatcher create(Iterable files) throws IOException { + Set directoryPaths = new HashSet<>(); + Set absoluteFilePaths = new HashSet<>(); + FileSystem fs = FileSystems.getDefault(); + WatchService ws = fs.newWatchService(); + + // In Java, you watch for filesystem changes at the directory level, not for individual files. + for (Path p: files) { + absoluteFilePaths.add(p); + directoryPaths.add(p.getParent()); + } + for (Path d: directoryPaths) { + d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + } + + return new FileWatcher(ws, absoluteFilePaths); + } + + private FileWatcher(WatchService watchService, Set watchedFilePaths) { + this.watchService = watchService; + this.watchedFilePaths = watchedFilePaths; + } + + public void run() { + while (!stopped) { + try { + WatchKey key = watchService.take(); // blocks until a change is available or we are interrupted + boolean watchedFileWasChanged = false; + for (WatchEvent event: key.pollEvents()) { + Watchable w = key.watchable(); + Object context = event.context(); + if (w instanceof Path && context instanceof Path) { + Path dirPath = (Path)w; + Path fileNamePath = (Path)context; + Path absolutePath = dirPath.resolve(fileNamePath); + if (watchedFilePaths.contains(absolutePath)) { + watchedFileWasChanged = true; + break; + } + } + } + if (watchedFileWasChanged) { + try { + fileModifiedAction.run(); + } catch (Exception e) { + logger.warn("Unexpected exception when reloading file data: " + e); + } + } + key.reset(); // if we don't do this, the watch on this key stops working + } catch (InterruptedException e) { + // if we've been stopped we will drop out at the top of the while loop + } + } + } + + public void start(Runnable fileModifiedAction) { + this.fileModifiedAction = fileModifiedAction; + thread = new Thread(this, FileDataSource.class.getName()); + thread.setDaemon(true); + thread.start(); + } + + public void stop() { + stopped = true; + if (thread != null) { + thread.interrupt(); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java new file mode 100644 index 000000000..4257256ee --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -0,0 +1,78 @@ +package com.launchdarkly.client.files; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.UpdateProcessorFactory; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * To use the file data source, obtain a new instance of this class with {@link FileComponents#fileDataSource()}, + * call the builder method {@link #filePaths(String...)} to specify file path(s), + * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#updateProcessorFactory(UpdateProcessorFactory)}. + * + * @since 4.5.0 + */ +public class FileDataSourceFactory implements UpdateProcessorFactory { + private final List sources = new ArrayList<>(); + private boolean autoUpdate = false; + + /** + * Adds any number of source files for loading flag data, specifying each file path as a string. The files will + * not actually be loaded until the LaunchDarkly client starts up. + *

+ * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same factory object + * + * @throws InvalidPathException if one of the parameters is not a valid file path + */ + public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathException { + for (String p: filePaths) { + sources.add(Paths.get(p)); + } + return this; + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a Path. The files will + * not actually be loaded until the LaunchDarkly client starts up. + *

+ * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same factory object + */ + public FileDataSourceFactory filePaths(Path... filePaths) { + for (Path p: filePaths) { + sources.add(p); + } + return this; + } + + /** + * Specifies whether the data source should watch for changes to the source file(s) and reload flags + * whenever there is a change. By default, it will not, so the flags will only be loaded once. + * + * @param autoUpdate true if flags should be reloaded whenever a source file changes + * @return the same factory object + */ + public FileDataSourceFactory autoUpdate(boolean autoUpdate) { + this.autoUpdate = autoUpdate; + return this; + } + + /** + * Used internally by the LaunchDarkly client. + */ + @Override + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + return new FileDataSource(featureStore, new DataLoader(sources), autoUpdate); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/FlagFactory.java b/src/main/java/com/launchdarkly/client/files/FlagFactory.java new file mode 100644 index 000000000..19af56282 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FlagFactory.java @@ -0,0 +1,56 @@ +package com.launchdarkly.client.files; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +/** + * Creates flag or segment objects from raw JSON. + * + * Note that the {@code FeatureFlag} and {@code Segment} classes are not public in the Java + * client, so we refer to those class objects indirectly via {@code VersionedDataKind}; and + * if we want to construct a flag from scratch, we can't use the constructor but instead must + * build some JSON and then parse that. + */ +class FlagFactory { + private static final Gson gson = new Gson(); + + public static VersionedData flagFromJson(String jsonString) { + return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + public static VersionedData flagFromJson(JsonElement jsonTree) { + return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); + } + + /** + * Constructs a flag that always returns the same value. This is done by giving it a single + * variation and setting the fallthrough variation to that. + */ + public static VersionedData flagWithValue(String key, JsonElement value) { + JsonElement jsonValue = gson.toJsonTree(value); + JsonObject o = new JsonObject(); + o.addProperty("key", key); + o.addProperty("on", true); + JsonArray vs = new JsonArray(); + vs.add(jsonValue); + o.add("variations", vs); + // Note that LaunchDarkly normally prevents you from creating a flag with just one variation, + // but it's the application that validates that; the SDK doesn't care. + JsonObject ft = new JsonObject(); + ft.addProperty("variation", 0); + o.add("fallthrough", ft); + return flagFromJson(o); + } + + public static VersionedData segmentFromJson(String jsonString) { + return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + public static VersionedData segmentFromJson(JsonElement jsonTree) { + return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + } +} diff --git a/src/main/java/com/launchdarkly/client/files/FlagFileParser.java b/src/main/java/com/launchdarkly/client/files/FlagFileParser.java new file mode 100644 index 000000000..ed0de72a0 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FlagFileParser.java @@ -0,0 +1,39 @@ +package com.launchdarkly.client.files; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; + +abstract class FlagFileParser { + private static final FlagFileParser jsonParser = new JsonFlagFileParser(); + private static final FlagFileParser yamlParser = new YamlFlagFileParser(); + + public abstract FlagFileRep parse(InputStream input) throws DataLoaderException, IOException; + + public static FlagFileParser selectForContent(byte[] data) { + Reader r = new InputStreamReader(new ByteArrayInputStream(data)); + return detectJson(r) ? jsonParser : yamlParser; + } + + private static boolean detectJson(Reader r) { + // A valid JSON file for our purposes must be an object, i.e. it must start with '{' + while (true) { + try { + int ch = r.read(); + if (ch < 0) { + return false; + } + if (ch == '{') { + return true; + } + if (!Character.isWhitespace(ch)) { + return false; + } + } catch (IOException e) { + return false; + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java b/src/main/java/com/launchdarkly/client/files/FlagFileRep.java new file mode 100644 index 000000000..db04fb51b --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/FlagFileRep.java @@ -0,0 +1,23 @@ +package com.launchdarkly.client.files; + +import com.google.gson.JsonElement; + +import java.util.Map; + +/** + * The basic data structure that we expect all source files to contain. Note that we don't try to + * parse the flags or segments at this level; that will be done by {@link FlagFactory}. + */ +final class FlagFileRep { + Map flags; + Map flagValues; + Map segments; + + FlagFileRep() {} + + FlagFileRep(Map flags, Map flagValues, Map segments) { + this.flags = flags; + this.flagValues = flagValues; + this.segments = segments; + } +} diff --git a/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java b/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java new file mode 100644 index 000000000..c895fd6ab --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java @@ -0,0 +1,30 @@ +package com.launchdarkly.client.files; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonSyntaxException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +final class JsonFlagFileParser extends FlagFileParser { + private static final Gson gson = new Gson(); + + @Override + public FlagFileRep parse(InputStream input) throws DataLoaderException, IOException { + try { + return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); + } catch (JsonSyntaxException e) { + throw new DataLoaderException("cannot parse JSON", e); + } + } + + public FlagFileRep parseJson(JsonElement tree) throws DataLoaderException, IOException { + try { + return gson.fromJson(tree, FlagFileRep.class); + } catch (JsonSyntaxException e) { + throw new DataLoaderException("cannot parse JSON", e); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java b/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java new file mode 100644 index 000000000..f4e352dfc --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java @@ -0,0 +1,52 @@ +package com.launchdarkly.client.files; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.error.YAMLException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Parses a FlagFileRep from a YAML file. Two notes about this implementation: + *

+ * 1. We already have logic for parsing (and building) flags using Gson, and would rather not repeat + * that logic. So, rather than telling SnakeYAML to parse the file directly into a FlagFileRep object - + * and providing SnakeYAML-specific methods for building flags - we are just parsing the YAML into + * simple Java objects and then feeding that data into the Gson parser. This is admittedly inefficient, + * but it also means that we don't have to worry about any differences between how Gson unmarshals an + * object and how the YAML parser does it. We already know Gson does the right thing for the flag and + * segment classes, because that's what we use in the SDK. + *

+ * 2. Ideally, it should be possible to have just one parser, since any valid JSON document is supposed + * to also be parseable as YAML. However, at present, that doesn't work: + *

    + *
  • SnakeYAML (1.19) rejects many valid JSON documents due to simple things like whitespace. + * Apparently this is due to supporting only YAML 1.1, not YAML 1.2 which has full JSON support. + *
  • snakeyaml-engine (https://bitbucket.org/asomov/snakeyaml-engine) says it can handle any JSON, + * but it's only for Java 8 and above. + *
  • YamlBeans (https://github.com/EsotericSoftware/yamlbeans) only works right if you're parsing + * directly into a Java bean instance (which FeatureFlag is not). If you try the "parse to simple + * Java types (and then feed them into Gson)" approach, it does not correctly parse non-string types + * (i.e. it treats true as "true"). (https://github.com/EsotericSoftware/yamlbeans/issues/7) + *
+ */ +final class YamlFlagFileParser extends FlagFileParser { + private static final Yaml yaml = new Yaml(); + private static final Gson gson = new Gson(); + private static final JsonFlagFileParser jsonFileParser = new JsonFlagFileParser(); + + @Override + public FlagFileRep parse(InputStream input) throws DataLoaderException, IOException { + Object root; + try { + root = yaml.load(input); + } catch (YAMLException e) { + throw new DataLoaderException("unable to parse YAML", e); + } + JsonElement jsonRoot = gson.toJsonTree(root); + return jsonFileParser.parseJson(jsonRoot); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java new file mode 100644 index 000000000..9145c7d32 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java @@ -0,0 +1,106 @@ +package com.launchdarkly.client.files; + +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class DataLoaderTest { + private static final Gson gson = new Gson(); + private DataBuilder builder = new DataBuilder(); + + @Test + public void yamlFileIsAutoDetected() throws Exception { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.yml"))); + ds.load(builder); + assertDataHasItemsOfKind(FEATURES); + } + + @Test + public void jsonFileIsAutoDetected() throws Exception { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"))); + ds.load(builder); + assertDataHasItemsOfKind(SEGMENTS); + } + + @Test + public void canLoadMultipleFiles() throws Exception { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), + resourceFilePath("segment-only.yml"))); + ds.load(builder); + assertDataHasItemsOfKind(FEATURES); + assertDataHasItemsOfKind(SEGMENTS); + } + + @Test + public void flagValueIsConvertedToFlag() throws Exception { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("value-only.json"))); + JsonObject expected = gson.fromJson( + "{\"key\":\"flag2\",\"on\":true,\"fallthrough\":{\"variation\":0},\"variations\":[\"value2\"]," + + "\"trackEvents\":false,\"deleted\":false,\"version\":0}", + JsonObject.class); + ds.load(builder); + VersionedData flag = builder.build().get(FEATURES).get(FLAG_VALUE_1_KEY); + JsonObject actual = gson.toJsonTree(flag).getAsJsonObject(); + // Note, we're comparing one property at a time here because the version of the Java SDK we're + // building against may have more properties than it did when the test was written. + for (Map.Entry e: expected.entrySet()) { + assertThat(actual.get(e.getKey()), equalTo(e.getValue())); + } + } + + @Test + public void duplicateFlagKeyInFlagsThrowsException() throws Exception { + try { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json"))); + ds.load(builder); + } catch (DataLoaderException e) { + assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); + } + } + + @Test + public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Exception { + try { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), + resourceFilePath("value-with-duplicate-key.json"))); + ds.load(builder); + } catch (DataLoaderException e) { + assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); + } + } + + @Test + public void duplicateSegmentKeyThrowsException() throws Exception { + try { + DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"), + resourceFilePath("segment-with-duplicate-key.json"))); + ds.load(builder); + } catch (DataLoaderException e) { + assertThat(e.getMessage(), containsString("key \"seg1\" was already defined")); + } + } + + private void assertDataHasItemsOfKind(VersionedDataKind kind) { + Map items = builder.build().get(kind); + if (items == null || items.size() == 0) { + Assert.fail("expected at least one item in \"" + kind.getNamespace() + "\", received: " + builder.build()); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java new file mode 100644 index 000000000..62924d9d5 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java @@ -0,0 +1,174 @@ +package com.launchdarkly.client.files; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.InMemoryFeatureStore; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.VersionedDataKind; + +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.Future; + +import static com.launchdarkly.client.files.FileComponents.fileDataSource; +import static com.launchdarkly.client.files.TestData.ALL_FLAG_KEYS; +import static com.launchdarkly.client.files.TestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.client.files.TestData.getResourceContents; +import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.fail; + +public class FileDataSourceTest { + private static final Path badFilePath = Paths.get("no-such-file.json"); + + private final FeatureStore store = new InMemoryFeatureStore(); + private final LDConfig config = new LDConfig.Builder().build(); + private final FileDataSourceFactory factory; + + public FileDataSourceTest() throws Exception { + factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); + } + + private static FileDataSourceFactory makeFactoryWithFile(Path path) { + return fileDataSource().filePaths(path); + } + + @Test + public void flagsAreNotLoadedUntilStart() throws Exception { + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + assertThat(store.initialized(), equalTo(false)); + assertThat(store.all(VersionedDataKind.FEATURES).size(), equalTo(0)); + assertThat(store.all(VersionedDataKind.SEGMENTS).size(), equalTo(0)); + } + } + + @Test + public void flagsAreLoadedOnStart() throws Exception { + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + fp.start(); + assertThat(store.initialized(), equalTo(true)); + assertThat(store.all(VersionedDataKind.FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(store.all(VersionedDataKind.SEGMENTS).keySet(), equalTo(ALL_SEGMENT_KEYS)); + } + } + + @Test + public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + Future future = fp.start(); + assertThat(future.isDone(), equalTo(true)); + } + } + + @Test + public void initializedIsTrueAfterSuccessfulLoad() throws Exception { + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + fp.start(); + assertThat(fp.initialized(), equalTo(true)); + } + } + + @Test + public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { + factory.filePaths(badFilePath); + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + Future future = fp.start(); + assertThat(future.isDone(), equalTo(true)); + } + } + + @Test + public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { + factory.filePaths(badFilePath); + try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + fp.start(); + assertThat(fp.initialized(), equalTo(false)); + } + } + + @Test + public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { + File file = makeTempFlagFile(); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()); + try { + setFileContents(file, getResourceContents("flag-only.json")); + try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + fp.start(); + setFileContents(file, getResourceContents("segment-only.json")); + Thread.sleep(400); + assertThat(store.all(VersionedDataKind.FEATURES).size(), equalTo(1)); + assertThat(store.all(VersionedDataKind.SEGMENTS).size(), equalTo(0)); + } + } finally { + file.delete(); + } + } + + // Note that the auto-update tests may fail when run on a Mac, but succeed on Ubuntu. This is because on + // MacOS there is no native implementation of WatchService, and the default implementation is known + // to be extremely slow. See: https://stackoverflow.com/questions/9588737/is-java-7-watchservice-slow-for-anyone-else + @Test + public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { + File file = makeTempFlagFile(); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + long maxMsToWait = 10000; + try { + setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag + try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + fp.start(); + Thread.sleep(1000); + setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags + long deadline = System.currentTimeMillis() + maxMsToWait; + while (System.currentTimeMillis() < deadline) { + if (store.all(VersionedDataKind.FEATURES).size() == ALL_FLAG_KEYS.size()) { + // success + return; + } + Thread.sleep(500); + } + fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + } + } finally { + file.delete(); + } + } + + @Test + public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { + File file = makeTempFlagFile(); + setFileContents(file, "not valid"); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + long maxMsToWait = 10000; + try { + try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + fp.start(); + Thread.sleep(1000); + setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag + long deadline = System.currentTimeMillis() + maxMsToWait; + while (System.currentTimeMillis() < deadline) { + if (store.all(VersionedDataKind.FEATURES).size() > 0) { + // success + return; + } + Thread.sleep(500); + } + fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + } + } finally { + file.delete(); + } + } + + private File makeTempFlagFile() throws Exception { + return File.createTempFile("flags", ".json"); + } + + private void setFileContents(File file, String content) throws Exception { + Files.write(file.toPath(), content.getBytes("UTF-8")); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java new file mode 100644 index 000000000..d6165e279 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java @@ -0,0 +1,76 @@ +package com.launchdarkly.client.files; + +import org.junit.Test; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.net.URISyntaxException; + +import static com.launchdarkly.client.files.TestData.FLAG_VALUES; +import static com.launchdarkly.client.files.TestData.FULL_FLAGS; +import static com.launchdarkly.client.files.TestData.FULL_SEGMENTS; +import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +public abstract class FlagFileParserTestBase { + private final FlagFileParser parser; + private final String fileExtension; + + protected FlagFileParserTestBase(FlagFileParser parser, String fileExtension) { + this.parser = parser; + this.fileExtension = fileExtension; + } + + @Test + public void canParseFileWithAllProperties() throws Exception { + try (FileInputStream input = openFile("all-properties")) { + FlagFileRep data = parser.parse(input); + assertThat(data.flags, equalTo(FULL_FLAGS)); + assertThat(data.flagValues, equalTo(FLAG_VALUES)); + assertThat(data.segments, equalTo(FULL_SEGMENTS)); + } + } + + @Test + public void canParseFileWithOnlyFullFlag() throws Exception { + try (FileInputStream input = openFile("flag-only")) { + FlagFileRep data = parser.parse(input); + assertThat(data.flags, equalTo(FULL_FLAGS)); + assertThat(data.flagValues, nullValue()); + assertThat(data.segments, nullValue()); + } + } + + @Test + public void canParseFileWithOnlyFlagValue() throws Exception { + try (FileInputStream input = openFile("value-only")) { + FlagFileRep data = parser.parse(input); + assertThat(data.flags, nullValue()); + assertThat(data.flagValues, equalTo(FLAG_VALUES)); + assertThat(data.segments, nullValue()); + } + } + + @Test + public void canParseFileWithOnlySegment() throws Exception { + try (FileInputStream input = openFile("segment-only")) { + FlagFileRep data = parser.parse(input); + assertThat(data.flags, nullValue()); + assertThat(data.flagValues, nullValue()); + assertThat(data.segments, equalTo(FULL_SEGMENTS)); + } + } + + @Test(expected = DataLoaderException.class) + public void throwsExpectedErrorForBadFile() throws Exception { + try (FileInputStream input = openFile("malformed")) { + parser.parse(input); + } + } + + private FileInputStream openFile(String name) throws URISyntaxException, FileNotFoundException { + return new FileInputStream(resourceFilePath(name + fileExtension).toFile()); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java new file mode 100644 index 000000000..0110105f6 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java @@ -0,0 +1,7 @@ +package com.launchdarkly.client.files; + +public class JsonFlagFileParserTest extends FlagFileParserTestBase { + public JsonFlagFileParserTest() { + super(new JsonFlagFileParser(), ".json"); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/TestData.java b/src/test/java/com/launchdarkly/client/files/TestData.java new file mode 100644 index 000000000..d0b5cdce9 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/TestData.java @@ -0,0 +1,48 @@ +package com.launchdarkly.client.files; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.Set; + +public class TestData { + private static final Gson gson = new Gson(); + + // These should match the data in our test files + public static final String FULL_FLAG_1_KEY = "flag1"; + public static final JsonElement FULL_FLAG_1 = gson.fromJson("{\"key\":\"flag1\",\"on\":true}", JsonElement.class); + public static final Map FULL_FLAGS = + ImmutableMap.of(FULL_FLAG_1_KEY, FULL_FLAG_1); + + public static final String FLAG_VALUE_1_KEY = "flag2"; + public static final JsonElement FLAG_VALUE_1 = new JsonPrimitive("value2"); + public static final Map FLAG_VALUES = + ImmutableMap.of(FLAG_VALUE_1_KEY, FLAG_VALUE_1); + + public static final String FULL_SEGMENT_1_KEY = "seg1"; + public static final JsonElement FULL_SEGMENT_1 = gson.fromJson("{\"key\":\"seg1\",\"include\":[\"user1\"]}", JsonElement.class); + public static final Map FULL_SEGMENTS = + ImmutableMap.of(FULL_SEGMENT_1_KEY, FULL_SEGMENT_1); + + public static final Set ALL_FLAG_KEYS = ImmutableSet.of(FULL_FLAG_1_KEY, FLAG_VALUE_1_KEY); + public static final Set ALL_SEGMENT_KEYS = ImmutableSet.of(FULL_SEGMENT_1_KEY); + + public static Path resourceFilePath(String filename) throws URISyntaxException { + URL resource = TestData.class.getClassLoader().getResource("filesource/" + filename); + return Paths.get(resource.toURI()); + } + + public static String getResourceContents(String filename) throws Exception { + return new String(Files.readAllBytes(resourceFilePath(filename))); + } + +} diff --git a/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java new file mode 100644 index 000000000..9b94e3801 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java @@ -0,0 +1,7 @@ +package com.launchdarkly.client.files; + +public class YamlFlagFileParserTest extends FlagFileParserTestBase { + public YamlFlagFileParserTest() { + super(new YamlFlagFileParser(), ".yml"); + } +} diff --git a/src/test/resources/filesource/all-properties.json b/src/test/resources/filesource/all-properties.json new file mode 100644 index 000000000..d4b1ead3a --- /dev/null +++ b/src/test/resources/filesource/all-properties.json @@ -0,0 +1,17 @@ +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true + } + }, + "flagValues": { + "flag2": "value2" + }, + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] + } + } +} diff --git a/src/test/resources/filesource/all-properties.yml b/src/test/resources/filesource/all-properties.yml new file mode 100644 index 000000000..8392dc857 --- /dev/null +++ b/src/test/resources/filesource/all-properties.yml @@ -0,0 +1,11 @@ +--- +flags: + flag1: + key: flag1 + "on": true +flagValues: + flag2: value2 +segments: + seg1: + key: seg1 + include: ["user1"] diff --git a/src/test/resources/filesource/flag-only.json b/src/test/resources/filesource/flag-only.json new file mode 100644 index 000000000..43fa07c89 --- /dev/null +++ b/src/test/resources/filesource/flag-only.json @@ -0,0 +1,8 @@ +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true + } + } +} \ No newline at end of file diff --git a/src/test/resources/filesource/flag-only.yml b/src/test/resources/filesource/flag-only.yml new file mode 100644 index 000000000..c583babc6 --- /dev/null +++ b/src/test/resources/filesource/flag-only.yml @@ -0,0 +1,5 @@ +--- +flags: + flag1: + key: flag1 + "on": true diff --git a/src/test/resources/filesource/flag-with-duplicate-key.json b/src/test/resources/filesource/flag-with-duplicate-key.json new file mode 100644 index 000000000..2a3735ae1 --- /dev/null +++ b/src/test/resources/filesource/flag-with-duplicate-key.json @@ -0,0 +1,12 @@ +{ + "flags": { + "another": { + "key": "another", + "on": true + }, + "flag1": { + "key": "flag1", + "on": true + } + } +} \ No newline at end of file diff --git a/src/test/resources/filesource/malformed.json b/src/test/resources/filesource/malformed.json new file mode 100644 index 000000000..98232c64f --- /dev/null +++ b/src/test/resources/filesource/malformed.json @@ -0,0 +1 @@ +{ diff --git a/src/test/resources/filesource/malformed.yml b/src/test/resources/filesource/malformed.yml new file mode 100644 index 000000000..c04a34ead --- /dev/null +++ b/src/test/resources/filesource/malformed.yml @@ -0,0 +1,2 @@ +- a +b: ~ diff --git a/src/test/resources/filesource/segment-only.json b/src/test/resources/filesource/segment-only.json new file mode 100644 index 000000000..6f9e31dd2 --- /dev/null +++ b/src/test/resources/filesource/segment-only.json @@ -0,0 +1,8 @@ +{ + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] + } + } +} diff --git a/src/test/resources/filesource/segment-only.yml b/src/test/resources/filesource/segment-only.yml new file mode 100644 index 000000000..b7db027ad --- /dev/null +++ b/src/test/resources/filesource/segment-only.yml @@ -0,0 +1,5 @@ +--- +segments: + seg1: + key: seg1 + include: ["user1"] diff --git a/src/test/resources/filesource/segment-with-duplicate-key.json b/src/test/resources/filesource/segment-with-duplicate-key.json new file mode 100644 index 000000000..44ed90d16 --- /dev/null +++ b/src/test/resources/filesource/segment-with-duplicate-key.json @@ -0,0 +1,12 @@ +{ + "segments": { + "another": { + "key": "another", + "include": [] + }, + "seg1": { + "key": "seg1", + "include": ["user1"] + } + } +} diff --git a/src/test/resources/filesource/value-only.json b/src/test/resources/filesource/value-only.json new file mode 100644 index 000000000..ddf99a41d --- /dev/null +++ b/src/test/resources/filesource/value-only.json @@ -0,0 +1,5 @@ +{ + "flagValues": { + "flag2": "value2" + } +} \ No newline at end of file diff --git a/src/test/resources/filesource/value-only.yml b/src/test/resources/filesource/value-only.yml new file mode 100644 index 000000000..821e25629 --- /dev/null +++ b/src/test/resources/filesource/value-only.yml @@ -0,0 +1,3 @@ +--- +flagValues: + flag2: value2 diff --git a/src/test/resources/filesource/value-with-duplicate-key.json b/src/test/resources/filesource/value-with-duplicate-key.json new file mode 100644 index 000000000..d366f8fa9 --- /dev/null +++ b/src/test/resources/filesource/value-with-duplicate-key.json @@ -0,0 +1,6 @@ +{ + "flagValues": { + "flag1": "value1", + "flag2": "value2" + } +} \ No newline at end of file From e8ac5cc7fcd72325f32c32a45f0feb8d2efac666 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Oct 2018 16:30:00 -0700 Subject: [PATCH 051/156] formatting --- src/main/java/com/launchdarkly/client/files/FileComponents.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java index 5baf2e586..18a2387cd 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -33,7 +33,7 @@ *
  • {@code segments}: User segment definitions. * *

    - * The format of the data in `flags` and `segments` is defined by the LaunchDarkly application + * The format of the data in {@code flags} and {@code segments} is defined by the LaunchDarkly application * and is subject to change. Rather than trying to construct these objects yourself, it is simpler * to request existing flags directly from the LaunchDarkly server in JSON format, and use this * output as the starting point for your file. In Linux you would do this: From 071e0fcc145d1288c419711a5f33f6ca676b8ae7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Oct 2018 16:30:32 -0700 Subject: [PATCH 052/156] formatting --- src/main/java/com/launchdarkly/client/files/FileComponents.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java index 18a2387cd..390fb75a3 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -38,7 +38,7 @@ * to request existing flags directly from the LaunchDarkly server in JSON format, and use this * output as the starting point for your file. In Linux you would do this: *

    - *     curl -H "Authorization: " https://app.launchdarkly.com/sdk/latest-all
    + *     curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
      * 
    *

    * The output will look something like this (but with many more properties): From 5ad29f366e05bafda9955861ad45366f7d65c6f2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 23 Oct 2018 15:48:31 -0700 Subject: [PATCH 053/156] clarify comment --- .../com/launchdarkly/client/files/FileDataSourceFactory.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 4257256ee..9a4dcb313 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -59,6 +59,9 @@ public FileDataSourceFactory filePaths(Path... filePaths) { /** * Specifies whether the data source should watch for changes to the source file(s) and reload flags * whenever there is a change. By default, it will not, so the flags will only be loaded once. + *

    + * Note that auto-updating will only work if all of the files you specified have valid directory paths at + * startup time; if a directory does not exist, creating it later will not result in files being loaded from it. * * @param autoUpdate true if flags should be reloaded whenever a source file changes * @return the same factory object From 90e1bcee503a3883e013840c97276c9721014bfd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 24 Oct 2018 13:52:08 -0700 Subject: [PATCH 054/156] add link in readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 93644e82b..b0f4fe409 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Be aware of two considerations when enabling the DEBUG log level: 1. Debug-level logs can be very verbose. It is not recommended that you turn on debug logging in high-volume environments. 1. Potentially sensitive information is logged including LaunchDarkly users created by you in your usage of this SDK. +Using flag data from a file +--------------------------- +For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. + Learn more ---------- From be413264ae0565af97cb459a2f48bd645766fa10 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 24 Oct 2018 13:58:27 -0700 Subject: [PATCH 055/156] comment correction --- src/main/java/com/launchdarkly/client/files/FileDataSource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSource.java b/src/main/java/com/launchdarkly/client/files/FileDataSource.java index 3fdc3b305..e040e7902 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSource.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSource.java @@ -60,7 +60,7 @@ public Future start() { // Note that if reload() finds any errors, it will not set our status to "initialized". But we // will still do all the other startup steps, because we still might end up getting valid data - // from the secondary processor, or from a change detected by the file watcher. + // if we are told to reload by the file watcher. if (fileWatcher != null) { fileWatcher.start(new Runnable() { From 2a3ead4feb0155cc3fd31626ce813f665d3f7815 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 24 Oct 2018 17:59:41 -0700 Subject: [PATCH 056/156] test that flags loaded from a file actually work --- .../files/ClientWithFileDataSourceTest.java | 46 +++++++++++++++++++ .../launchdarkly/client/files/TestData.java | 5 +- .../resources/filesource/all-properties.json | 6 ++- .../resources/filesource/all-properties.yml | 6 +++ src/test/resources/filesource/flag-only.json | 6 ++- src/test/resources/filesource/flag-only.yml | 6 +++ 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java new file mode 100644 index 000000000..e8ec26040 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java @@ -0,0 +1,46 @@ +package com.launchdarkly.client.files; + +import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.LDClient; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.LDUser; + +import org.junit.Test; + +import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1; +import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.files.TestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.client.files.TestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class ClientWithFileDataSourceTest { + private static final LDUser user = new LDUser.Builder("userkey").build(); + + private LDClient makeClient() throws Exception { + FileDataSourceFactory fdsf = FileComponents.fileDataSource() + .filePaths(resourceFilePath("all-properties.json")); + LDConfig config = new LDConfig.Builder() + .updateProcessorFactory(fdsf) + .sendEvents(false) + .build(); + return new LDClient("sdkKey", config); + } + + @Test + public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonVariation(FULL_FLAG_1_KEY, user, new JsonPrimitive("default")), + equalTo(FULL_FLAG_1_VALUE)); + } + } + + @Test + public void simplifiedFlagEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonVariation(FLAG_VALUE_1_KEY, user, new JsonPrimitive("default")), + equalTo(FLAG_VALUE_1)); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/files/TestData.java b/src/test/java/com/launchdarkly/client/files/TestData.java index d0b5cdce9..d1f098d7c 100644 --- a/src/test/java/com/launchdarkly/client/files/TestData.java +++ b/src/test/java/com/launchdarkly/client/files/TestData.java @@ -19,7 +19,10 @@ public class TestData { // These should match the data in our test files public static final String FULL_FLAG_1_KEY = "flag1"; - public static final JsonElement FULL_FLAG_1 = gson.fromJson("{\"key\":\"flag1\",\"on\":true}", JsonElement.class); + public static final JsonElement FULL_FLAG_1 = + gson.fromJson("{\"key\":\"flag1\",\"on\":true,\"fallthrough\":{\"variation\":2},\"variations\":[\"fall\",\"off\",\"on\"]}", + JsonElement.class); + public static final JsonElement FULL_FLAG_1_VALUE = new JsonPrimitive("on"); public static final Map FULL_FLAGS = ImmutableMap.of(FULL_FLAG_1_KEY, FULL_FLAG_1); diff --git a/src/test/resources/filesource/all-properties.json b/src/test/resources/filesource/all-properties.json index d4b1ead3a..13e8a74bd 100644 --- a/src/test/resources/filesource/all-properties.json +++ b/src/test/resources/filesource/all-properties.json @@ -2,7 +2,11 @@ "flags": { "flag1": { "key": "flag1", - "on": true + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] } }, "flagValues": { diff --git a/src/test/resources/filesource/all-properties.yml b/src/test/resources/filesource/all-properties.yml index 8392dc857..de8b71f90 100644 --- a/src/test/resources/filesource/all-properties.yml +++ b/src/test/resources/filesource/all-properties.yml @@ -3,6 +3,12 @@ flags: flag1: key: flag1 "on": true + fallthrough: + variation: 2 + variations: + - fall + - "off" + - "on" flagValues: flag2: value2 segments: diff --git a/src/test/resources/filesource/flag-only.json b/src/test/resources/filesource/flag-only.json index 43fa07c89..f0c076b40 100644 --- a/src/test/resources/filesource/flag-only.json +++ b/src/test/resources/filesource/flag-only.json @@ -2,7 +2,11 @@ "flags": { "flag1": { "key": "flag1", - "on": true + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] } } } \ No newline at end of file diff --git a/src/test/resources/filesource/flag-only.yml b/src/test/resources/filesource/flag-only.yml index c583babc6..b71a39907 100644 --- a/src/test/resources/filesource/flag-only.yml +++ b/src/test/resources/filesource/flag-only.yml @@ -3,3 +3,9 @@ flags: flag1: key: flag1 "on": true + fallthrough: + variation: 2 + variations: + - fall + - "off" + - "on" From 0e4ce02e9e14bf947f8d88470755556c209684da Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 24 Oct 2018 19:49:34 -0700 Subject: [PATCH 057/156] add comment link --- .../com/launchdarkly/client/files/FileDataSourceFactory.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 9a4dcb313..216c56213 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -15,6 +15,8 @@ * To use the file data source, obtain a new instance of this class with {@link FileComponents#fileDataSource()}, * call the builder method {@link #filePaths(String...)} to specify file path(s), * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#updateProcessorFactory(UpdateProcessorFactory)}. + *

    + * For more details, see {@link FileComponents}. * * @since 4.5.0 */ From bb5ef20326efb13a072263270f583100f0fa5eef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 26 Oct 2018 14:40:50 -0700 Subject: [PATCH 058/156] fix link URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0f4fe409..f2ed083cc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Be aware of two considerations when enabling the DEBUG log level: Using flag data from a file --------------------------- -For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. +For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. Learn more ---------- From 70d6a4ea5100a9bf22b7203cd9c4016ee388a53a Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Thu, 15 Nov 2018 16:05:11 -0800 Subject: [PATCH 059/156] added fossa build step --- .circleci/config.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index d938b76e3..2a22d1061 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,3 +22,20 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit + fossa: + branches: + ignore: + - gh-pages + docker: + - image: circleci/java + - image: fossa/fossa-cli + steps: + - checkout + - run: fossa -p $CIRCLE_PROJECT_REPONAME -r $CIRCLE_SHA1 -m gradle + +workflows: + version: 2 + test: + jobs: + - build + - fossa \ No newline at end of file From e52c72ff5116c28a82736ec226398fac3c6be25f Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Thu, 15 Nov 2018 16:16:48 -0800 Subject: [PATCH 060/156] empty commit From e98f67364cdd68429bc64a85ab66194d32709700 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:10:58 -0800 Subject: [PATCH 061/156] moved workflow filter definition --- .circleci/config.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a22d1061..6c6eb9d22 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,9 +1,6 @@ version: 2 jobs: build: - branches: - ignore: - - gh-pages docker: - image: circleci/java - image: redis @@ -23,9 +20,6 @@ jobs: - store_artifacts: path: ~/junit fossa: - branches: - ignore: - - gh-pages docker: - image: circleci/java - image: fossa/fossa-cli @@ -37,5 +31,13 @@ workflows: version: 2 test: jobs: - - build - - fossa \ No newline at end of file + - build: + filters: + branches: + ignore: + - gh-pages + - fossa: + filters: + branches: + ignore: + - gh-pages \ No newline at end of file From e8c95aab1f92e665a89736410e6ad32fe1a5566f Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:19:17 -0800 Subject: [PATCH 062/156] fixed docker image reference for fossa --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6c6eb9d22..cf19acbcd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,8 +21,7 @@ jobs: path: ~/junit fossa: docker: - - image: circleci/java - - image: fossa/fossa-cli + - image: fossa/fossa-cli:base steps: - checkout - run: fossa -p $CIRCLE_PROJECT_REPONAME -r $CIRCLE_SHA1 -m gradle From c481f30a6b1fa0f4b55bc1a8f28c695caddffb9a Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:36:12 -0800 Subject: [PATCH 063/156] give up on fossa-cli docker image --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cf19acbcd..40d0fbb20 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,9 +21,10 @@ jobs: path: ~/junit fossa: docker: - - image: fossa/fossa-cli:base + - image: circleci/java steps: - checkout + - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - run: fossa -p $CIRCLE_PROJECT_REPONAME -r $CIRCLE_SHA1 -m gradle workflows: From cffdea7195e10eb4c6420fdcfebad83f46d68794 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:39:52 -0800 Subject: [PATCH 064/156] added fossa.yml config --- .circleci/config.yml | 2 +- .fossa.yaml | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .fossa.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index 40d0fbb20..f434e341c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,7 @@ jobs: steps: - checkout - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: fossa -p $CIRCLE_PROJECT_REPONAME -r $CIRCLE_SHA1 -m gradle + - run: fossa -p $CIRCLE_REPOSITORY_URL -r $CIRCLE_SHA1 workflows: version: 2 diff --git a/.fossa.yaml b/.fossa.yaml new file mode 100644 index 000000000..e69de29bb From 6df4a6c3e08a71a669ea12a44ce50552b78908e6 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:45:21 -0800 Subject: [PATCH 065/156] use v1 of fossa config? --- .fossa.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.fossa.yaml b/.fossa.yaml index e69de29bb..a0093cba6 100644 --- a/.fossa.yaml +++ b/.fossa.yaml @@ -0,0 +1,5 @@ +version: 1 + +analyze: + modules: + - name: gradle \ No newline at end of file From a2d8e0b25655fc8aa04e79374e8c8ae844a8042e Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:48:23 -0800 Subject: [PATCH 066/156] iterate on fossa config --- .fossa.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.fossa.yaml b/.fossa.yaml index a0093cba6..16b10e467 100644 --- a/.fossa.yaml +++ b/.fossa.yaml @@ -2,4 +2,6 @@ version: 1 analyze: modules: - - name: gradle \ No newline at end of file + - name: fossa-cli + path: ./cmd/fossa + type: gradle \ No newline at end of file From 03ffa5dbaea320ab2492677296599ead076f1f22 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 13:54:28 -0800 Subject: [PATCH 067/156] run fossa init --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f434e341c..507174d56 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,9 @@ jobs: steps: - checkout - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: fossa -p $CIRCLE_REPOSITORY_URL -r $CIRCLE_SHA1 + - run: fossa init -p $CIRCLE_REPOSITORY_URL -r $CIRCLE_SHA1 + - run: cat .fossa.yaml + - run: fossa workflows: version: 2 From 4c17f0444bae8e36b7da095df497ef5e84ba66fb Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 16:47:12 -0800 Subject: [PATCH 068/156] adapted fossa setup after seeing pr from fossa engineer --- .circleci/config.yml | 6 ++++-- .fossa.yaml | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 507174d56..7c3ca8f79 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,10 +24,12 @@ jobs: - image: circleci/java steps: - checkout + - run: cp gradle.properties.example gradle.properties + - run: ./gradlew dependencies - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: fossa init -p $CIRCLE_REPOSITORY_URL -r $CIRCLE_SHA1 + - run: sed -i.bak "s/REPO_NAME/$CIRCLE_PROJECT_REPONAME" .fossa.yaml - run: cat .fossa.yaml - - run: fossa + - run: fossa -r $CIRCLE_SHA1 workflows: version: 2 diff --git a/.fossa.yaml b/.fossa.yaml index 16b10e467..f400553f2 100644 --- a/.fossa.yaml +++ b/.fossa.yaml @@ -1,7 +1,13 @@ version: 1 +cli: + server: https://app.fossa.io + fetcher: git + project: github.com/launchdarkly/REPO_NAME analyze: modules: - - name: fossa-cli - path: ./cmd/fossa - type: gradle \ No newline at end of file + - name: REPO_NAME + path: . + type: gradle + options: + task: dependencies \ No newline at end of file From fcbf66b20a6993f36219e1f7efd4e32eb663d7e1 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 16:49:14 -0800 Subject: [PATCH 069/156] fixed sed command for fossa config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7c3ca8f79..345e66388 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,7 +27,7 @@ jobs: - run: cp gradle.properties.example gradle.properties - run: ./gradlew dependencies - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: sed -i.bak "s/REPO_NAME/$CIRCLE_PROJECT_REPONAME" .fossa.yaml + - run: sed -i.bak "s/REPO_NAME/$CIRCLE_PROJECT_REPONAME/g" .fossa.yaml - run: cat .fossa.yaml - run: fossa -r $CIRCLE_SHA1 From 5059856c941403ef68a816e0e0442d3575521cdd Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 16:51:38 -0800 Subject: [PATCH 070/156] removed sed nonsense since it isn't necessary --- .circleci/config.yml | 2 -- .fossa.yaml | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 345e66388..36c9536e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,8 +27,6 @@ jobs: - run: cp gradle.properties.example gradle.properties - run: ./gradlew dependencies - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: sed -i.bak "s/REPO_NAME/$CIRCLE_PROJECT_REPONAME/g" .fossa.yaml - - run: cat .fossa.yaml - run: fossa -r $CIRCLE_SHA1 workflows: diff --git a/.fossa.yaml b/.fossa.yaml index f400553f2..c2ebbaf7a 100644 --- a/.fossa.yaml +++ b/.fossa.yaml @@ -2,11 +2,9 @@ version: 1 cli: server: https://app.fossa.io - fetcher: git - project: github.com/launchdarkly/REPO_NAME analyze: modules: - - name: REPO_NAME + - name: java-client path: . type: gradle options: From fc24db50386695c819b20ce1ede4ee32b555bdf8 Mon Sep 17 00:00:00 2001 From: Patrick Kaeding Date: Fri, 16 Nov 2018 17:06:39 -0800 Subject: [PATCH 071/156] let fossa figure out the sha, rather than passing it in from a circle environment variable --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 36c9536e3..20020fefa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,7 +27,7 @@ jobs: - run: cp gradle.properties.example gradle.properties - run: ./gradlew dependencies - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: fossa -r $CIRCLE_SHA1 + - run: fossa analyze workflows: version: 2 From 279a6b7815011fcfaaa0ab0dca7fc2119ff2871e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Nov 2018 12:31:31 -0800 Subject: [PATCH 072/156] log stream errors as WARN, not ERROR --- src/main/java/com/launchdarkly/client/StreamProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 6c98f4ee0..f5fcb621a 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -169,7 +169,7 @@ public void onComment(String comment) { @Override public void onError(Throwable throwable) { - logger.error("Encountered EventSource error: {}" + throwable.toString()); + logger.warn("Encountered EventSource error: {}", throwable.toString()); logger.debug(throwable.toString(), throwable); } }; From 1c161293a6817cf09375c98311a35b44f5eccf8f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Nov 2018 12:57:26 -0800 Subject: [PATCH 073/156] publish test jar for shared tests --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 3f8b96823..ada9beba4 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,11 @@ buildscript { } } +task testJar(type: Jar, dependsOn: testClasses) { + classifier = 'test' + from sourceSets.test.output +} + // custom tasks for creating source/javadoc jars task sourcesJar(type: Jar, dependsOn: classes) { classifier = 'sources' @@ -241,6 +246,7 @@ publishing { artifact sourcesJar artifact javadocJar artifact shadowJarAll + artifact testJar pom.withXml { def root = asNode() From f92927c01730722ee67409b9d10c86bb2b052992 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Nov 2018 14:36:04 -0800 Subject: [PATCH 074/156] add feature store support classes and use them in Redis --- .../client/RedisFeatureStore.java | 427 +++++++----------- .../client/RedisFeatureStoreBuilder.java | 8 +- .../client/utils/CachingStoreWrapper.java | 293 ++++++++++++ .../client/utils/FeatureStoreCore.java | 77 ++++ .../client/utils/FeatureStoreHelpers.java | 35 ++ .../client/FeatureStoreDatabaseTestBase.java | 235 ++++++++++ .../client/FeatureStoreTestBase.java | 120 ++++- .../client/InMemoryFeatureStoreTest.java | 12 +- .../client/RedisFeatureStoreBuilderTest.java | 14 +- .../client/RedisFeatureStoreTest.java | 88 ++-- .../com/launchdarkly/client/TestUtil.java | 22 + .../client/utils/CachingStoreWrapperTest.java | 411 +++++++++++++++++ 12 files changed, 1389 insertions(+), 353 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java create mode 100644 src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java create mode 100644 src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java create mode 100644 src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java create mode 100644 src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 1a9c1935a..cdf60566f 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -1,15 +1,9 @@ package com.launchdarkly.client; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Optional; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheStats; -import com.google.common.cache.LoadingCache; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.google.gson.Gson; +import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.FeatureStoreCore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,12 +12,9 @@ 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 static com.launchdarkly.client.utils.FeatureStoreHelpers.marshalJson; +import static com.launchdarkly.client.utils.FeatureStoreHelpers.unmarshalJson; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; @@ -36,42 +27,57 @@ */ public class RedisFeatureStore implements FeatureStore { private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStore.class); - 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(); + + // Note that we could avoid the indirection of delegating everything to CachingStoreWrapper if we + // simply returned the wrapper itself as the FeatureStore; however, for historical reasons we can't, + // because we have already exposed the RedisFeatureStore type. + private final CachingStoreWrapper wrapper; + private final Core core; - private final JedisPool pool; - private LoadingCache> cache; - private final LoadingCache initCache = createInitCache(); - private String prefix; - private ListeningExecutorService executorService; - private UpdateListener updateListener; + @Override + public void init(Map, Map> allData) { + wrapper.init(allData); + } - private static class CacheKey { - final VersionedDataKind kind; - final String key; - - public CacheKey(VersionedDataKind kind, String key) { - this.kind = kind; - this.key = key; - } - - @Override - public boolean equals(Object other) { - if (other instanceof CacheKey) { - CacheKey o = (CacheKey) other; - return o.kind.getNamespace().equals(this.kind.getNamespace()) && - o.key.equals(this.key); - } - return false; - } - - @Override - public int hashCode() { - return kind.getNamespace().hashCode() * 31 + key.hashCode(); - } + @Override + public T get(VersionedDataKind kind, String key) { + return wrapper.get(kind, key); + } + + @Override + public Map all(VersionedDataKind kind) { + return wrapper.all(kind); + } + + @Override + public void upsert(VersionedDataKind kind, T item) { + wrapper.upsert(kind, item); + } + + @Override + public void delete(VersionedDataKind kind, String key, int version) { + wrapper.delete(kind, key, version); + } + + @Override + public boolean initialized() { + return wrapper.initialized(); + } + + @Override + public void close() throws IOException { + wrapper.close(); } + /** + * Return the underlying Guava cache stats object. + * + * @return the cache statistics object. + */ + public CacheStats getCacheStats() { + return wrapper.getCacheStats(); + } + /** * Creates a new store instance that connects to Redis based on the provided {@link RedisFeatureStoreBuilder}. *

    @@ -80,15 +86,18 @@ public int hashCode() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - if (builder.poolConfig == null) { - this.pool = new JedisPool(getPoolConfig(), builder.uri, builder.connectTimeout, builder.socketTimeout); - } else { - this.pool = new JedisPool(builder.poolConfig, builder.uri, builder.connectTimeout, builder.socketTimeout); - } - this.prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? + JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); + JedisPool pool = new JedisPool(poolConfig, builder.uri, builder.connectTimeout, builder.socketTimeout); + String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? RedisFeatureStoreBuilder.DEFAULT_PREFIX : builder.prefix; - createCache(builder.cacheTimeSecs, builder.refreshStaleValues, builder.asyncRefresh); + + this.core = new Core(pool, prefix); + this.wrapper = new CachingStoreWrapper.Builder(this.core) + .cacheTime(builder.cacheTime, builder.cacheTimeUnit) + .refreshStaleValues(builder.refreshStaleValues) + .asyncRefresh(builder.asyncRefresh) + .build(); } /** @@ -96,238 +105,144 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { * @deprecated Please use {@link Components#redisFeatureStore()} instead. */ public RedisFeatureStore() { - pool = new JedisPool(getPoolConfig(), "localhost"); - this.prefix = RedisFeatureStoreBuilder.DEFAULT_PREFIX; + JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost"); + this.core = new Core(pool, RedisFeatureStoreBuilder.DEFAULT_PREFIX); + this.wrapper = new CachingStoreWrapper.Builder(this.core).build(); } - private void createCache(long cacheTimeSecs, boolean refreshStaleValues, boolean asyncRefresh) { - if (cacheTimeSecs > 0) { - if (refreshStaleValues) { - createRefreshCache(cacheTimeSecs, asyncRefresh); - } else { - createExpiringCache(cacheTimeSecs); - } + static class Core implements FeatureStoreCore { + private final JedisPool pool; + private final String prefix; + private UpdateListener updateListener; + + Core(JedisPool pool, String prefix) { + this.pool = pool; + this.prefix = prefix; } - } - - private CacheLoader> createDefaultCacheLoader() { - return new CacheLoader>() { - @Override - public Optional load(CacheKey key) throws Exception { - try (Jedis jedis = pool.getResource()) { - return Optional.fromNullable(getRedisEvenIfDeleted(key.kind, key.key, jedis)); + + @SuppressWarnings("unchecked") + @Override + public T getInternal(VersionedDataKind kind, String key) { + try (Jedis jedis = pool.getResource()) { + VersionedData item = getRedis(kind, key, jedis); + if (item != null) { + logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); } + return (T)item; } - }; - } - - /** - * Configures the instance to use a "refresh after write" cache. This will not automatically evict stale values, allowing them to be returned if failures - * occur when updating them. Optionally set the cache to refresh values asynchronously, which always returns the previously cached value immediately. - * - * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureFlag} value is created that it should be refreshed. - * @param asyncRefresh makes the refresh asynchronous or not. - */ - private void createRefreshCache(long cacheTimeSecs, boolean asyncRefresh) { - ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build(); - ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); - executorService = MoreExecutors.listeningDecorator(parentExecutor); - CacheLoader> cacheLoader = createDefaultCacheLoader(); - if (asyncRefresh) { - cacheLoader = CacheLoader.asyncReloading(cacheLoader, executorService); } - cache = CacheBuilder.newBuilder().refreshAfterWrite(cacheTimeSecs, TimeUnit.SECONDS).build(cacheLoader); - } - - /** - * Configures the instance to use an "expire after write" cache. This will evict stale values and block while loading the latest from Redis. - * - * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureFlag} value is created that it should be automatically removed. - */ - private void createExpiringCache(long cacheTimeSecs) { - cache = CacheBuilder.newBuilder().expireAfterWrite(cacheTimeSecs, TimeUnit.SECONDS).build(createDefaultCacheLoader()); - } - - private LoadingCache createInitCache() { - // Note that this cache does not expire - it's being used only for memoization. - return CacheBuilder.newBuilder().build(new CacheLoader() { - @Override - public Boolean load(String key) throws Exception { - return getInit(); - } - }); - } - @SuppressWarnings("unchecked") - @Override - public T get(VersionedDataKind kind, String key) { - T item; - if (cache != null) { - item = (T) cache.getUnchecked(new CacheKey(kind, key)).orNull(); - } else { + @Override + public Map getAllInternal(VersionedDataKind kind) { 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()); - } - return item; - } + Map allJson = jedis.hgetAll(itemsKey(kind)); + Map result = new HashMap<>(); - @Override - public Map all(VersionedDataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); - - for (Map.Entry entry : allJson.entrySet()) { - T item = gson.fromJson(entry.getValue(), kind.getItemClass()); - if (!item.isDeleted()) { + for (Map.Entry entry : allJson.entrySet()) { + T item = unmarshalJson(kind, entry.getValue()); result.put(entry.getKey(), item); } + return result; } - return result; } - } - - @Override - public void init(Map, Map> allData) { - try (Jedis jedis = pool.getResource()) { - Transaction t = jedis.multi(); + + @Override + public void initInternal(Map, Map> allData) { + try (Jedis jedis = pool.getResource()) { + Transaction t = jedis.multi(); - for (Map.Entry, Map> entry: allData.entrySet()) { - String baseKey = itemsKey(entry.getKey()); - t.del(baseKey); - for (VersionedData item: entry.getValue().values()) { - t.hset(baseKey, item.getKey(), gson.toJson(item)); + for (Map.Entry, Map> entry: allData.entrySet()) { + String baseKey = itemsKey(entry.getKey()); + t.del(baseKey); + for (VersionedData item: entry.getValue().values()) { + t.hset(baseKey, item.getKey(), marshalJson(item)); + } } - } - t.exec(); + t.set(initedKey(), ""); + t.exec(); + } } - cache.invalidateAll(); - initCache.put(INIT_KEY, true); - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - T deletedItem = kind.makeDeletedItem(key, version); - updateItemWithVersioning(kind, deletedItem); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - updateItemWithVersioning(kind, item); - } - - 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.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()); - 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())); - } - return; - } finally { - if (jedis != null) { - jedis.unwatch(); - jedis.close(); + + @SuppressWarnings("unchecked") + @Override + public T upsertInternal(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 = getRedis(kind, newItem.getKey(), jedis); + + if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) { + 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()); + return (T)oldItem; + } + + Transaction tx = jedis.multi(); + tx.hset(baseKey, newItem.getKey(), marshalJson(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; + } + + return newItem; + } finally { + if (jedis != null) { + jedis.unwatch(); + jedis.close(); + } } } } - } - - @Override - public boolean initialized() { - // The LoadingCache takes care of both coalescing multiple simultaneous requests and memoizing - // the result, so we'll only ever query Redis once for this (if at all - the Redis query will - // be skipped if the cache was explicitly set by init()). - return initCache.getUnchecked(INIT_KEY); - } - - /** - * Releases all resources associated with the store. The store must no longer be used once closed. - * - * @throws IOException if an underlying service threw an exception - */ - public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); - try { - if (executorService != null) { - executorService.shutdownNow(); + + @Override + public boolean initializedInternal() { + try (Jedis jedis = pool.getResource()) { + return jedis.exists(initedKey()); } - } finally { + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly RedisFeatureStore"); pool.destroy(); } - } - /** - * Return the underlying Guava cache stats object. - * - * @return the cache statistics object. - */ - public CacheStats getCacheStats() { - if (cache != null) { - return cache.stats(); + @VisibleForTesting + void setUpdateListener(UpdateListener updateListener) { + this.updateListener = updateListener; } - return null; - } - - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); - } - - private Boolean getInit() { - try (Jedis jedis = pool.getResource()) { - return jedis.exists(itemsKey(FEATURES)); + + private String itemsKey(VersionedDataKind kind) { + return prefix + ":" + kind.getNamespace(); } - } - - private T getRedisEvenIfDeleted(VersionedDataKind kind, String key, Jedis jedis) { - String json = jedis.hget(itemsKey(kind), key); - - if (json == null) { - logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); - return null; + + private String initedKey() { + return prefix + ":$inited"; } + + private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { + String json = jedis.hget(itemsKey(kind), key); - return gson.fromJson(json, kind.getItemClass()); - } + if (json == null) { + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); + return null; + } - private static JedisPoolConfig getPoolConfig() { - return new JedisPoolConfig(); + return unmarshalJson(kind, json); + } } static interface UpdateListener { @@ -336,6 +251,6 @@ static interface UpdateListener { @VisibleForTesting void setUpdateListener(UpdateListener updateListener) { - this.updateListener = updateListener; + core.setUpdateListener(updateListener); } } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 7cb9ed5f6..4fac992d1 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -50,7 +50,8 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; - long cacheTimeSecs = DEFAULT_CACHE_TIME_SECONDS; + long cacheTime = DEFAULT_CACHE_TIME_SECONDS; + TimeUnit cacheTimeUnit = TimeUnit.SECONDS; JedisPoolConfig poolConfig = null; // These constructors are called only from Implementations @@ -157,11 +158,12 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * If this value is set to 0 then it effectively disables local caching altogether. * * @param cacheTime the time value to cache for - * @param timeUnit the time unit for the time value. This is used to convert your time value to seconds. + * @param timeUnit the time unit for the time value * @return the builder */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - this.cacheTimeSecs = timeUnit.toSeconds(cacheTime); + this.cacheTime = cacheTime; + this.cacheTimeUnit = timeUnit; return this; } diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java new file mode 100644 index 000000000..dbff1610c --- /dev/null +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -0,0 +1,293 @@ +package com.launchdarkly.client.utils; + +import com.google.common.base.Optional; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.CacheStats; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import java.io.IOException; +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 java.util.concurrent.atomic.AtomicBoolean; + +/** + * CachingStoreWrapper is a partial implementation of {@link FeatureStore} that delegates the basic + * functionality to an instance of {@link FeatureStoreCore}. It provides optional caching behavior and + * other logic that would otherwise be repeated in every feature store implementation. + * + * Construct instances of this class with {@link CachingStoreWrapper.Builder}. + * + * @since 4.6.0 + */ +public class CachingStoreWrapper implements FeatureStore { + private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; + + private final FeatureStoreCore core; + private final LoadingCache> itemCache; + private final LoadingCache, Map> allCache; + private final LoadingCache initCache; + private final AtomicBoolean inited = new AtomicBoolean(false); + private final ListeningExecutorService executorService; + + protected CachingStoreWrapper(final FeatureStoreCore core, long cacheTime, TimeUnit cacheTimeUnit, boolean refreshStaleValues, boolean asyncRefresh) { + this.core = core; + + if (cacheTime <= 0) { + itemCache = null; + allCache = null; + initCache = null; + executorService = null; + } else { + CacheLoader> itemLoader = new CacheLoader>() { + @Override + public Optional load(CacheKey key) throws Exception { + return Optional.fromNullable(core.getInternal(key.kind, key.key)); + } + }; + CacheLoader, Map> allLoader = new CacheLoader, Map>() { + @Override + public Map load(VersionedDataKind kind) throws Exception { + return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); + } + }; + CacheLoader initLoader = new CacheLoader() { + @Override + public Boolean load(String key) throws Exception { + return core.initializedInternal(); + } + }; + + if (refreshStaleValues) { + // We are using a "refresh after write" cache. This will not automatically evict stale values, allowing them + // to be returned if failures occur when updating them. Optionally set the cache to refresh values asynchronously, + // which always returns the previously cached value immediately (this is only done for itemCache, not allCache, + // since retrieving all flags is less frequently needed and we don't want to incur the extra overhead). + + ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build(); + ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); + executorService = MoreExecutors.listeningDecorator(parentExecutor); + + if (asyncRefresh) { + itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); + } + itemCache = CacheBuilder.newBuilder().refreshAfterWrite(cacheTime, cacheTimeUnit).build(itemLoader); + allCache = CacheBuilder.newBuilder().refreshAfterWrite(cacheTime, cacheTimeUnit).build(allLoader); + } else { + // We are using an "expire after write" cache. This will evict stale values and block while loading the latest + // from Redis. + + itemCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(itemLoader); + allCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(allLoader); + executorService = null; + } + + initCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(initLoader); + } + } + + @Override + public void close() throws IOException { + if (executorService != null) { + executorService.shutdownNow(); + } + core.close(); + } + + @SuppressWarnings("unchecked") + @Override + public T get(VersionedDataKind kind, String key) { + if (itemCache != null) { + Optional cachedItem = itemCache.getUnchecked(CacheKey.forItem(kind, key)); + if (cachedItem != null) { + T item = (T)cachedItem.orNull(); + return itemOnlyIfNotDeleted(item); + } + } + return itemOnlyIfNotDeleted(core.getInternal(kind, key)); + } + + @SuppressWarnings("unchecked") + @Override + public Map all(VersionedDataKind kind) { + if (allCache != null) { + Map items = (Map)allCache.getUnchecked(kind); + if (items != null) { + return items; + } + } + return core.getAllInternal(kind); + } + + @Override + public void init(Map, Map> allData) { + core.initInternal(allData); + inited.set(true); + if (allCache != null && itemCache != null) { + allCache.invalidateAll(); + itemCache.invalidateAll(); + for (Map.Entry, Map> e0: allData.entrySet()) { + VersionedDataKind kind = e0.getKey(); + allCache.put(kind, e0.getValue()); + for (Map.Entry e1: e0.getValue().entrySet()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of((VersionedData)e1.getValue())); + } + } + } + } + + @Override + public void delete(VersionedDataKind kind, String key, int version) { + upsert(kind, kind.makeDeletedItem(key, version)); + } + + @Override + public void upsert(VersionedDataKind kind, T item) { + VersionedData newState = core.upsertInternal(kind, item); + if (itemCache != null) { + itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); + } + if (allCache != null) { + allCache.invalidate(kind); + } + } + + @Override + public boolean initialized() { + if (inited.get()) { + return true; + } + boolean result; + if (initCache != null) { + result = initCache.getUnchecked("arbitrary-key"); + } else { + result = core.initializedInternal(); + } + if (result) { + inited.set(true); + } + return result; + } + + /** + * Return the underlying Guava cache stats object. + * + * @return the cache statistics object. + */ + public CacheStats getCacheStats() { + if (itemCache != null) { + return itemCache.stats(); + } + return null; + } + + private T itemOnlyIfNotDeleted(T item) { + return (item != null && item.isDeleted()) ? null : item; + } + + private Map itemsOnlyIfNotDeleted(Map items) { + Map ret = new HashMap<>(); + if (items != null) { + for (Map.Entry item: items.entrySet()) { + if (!item.getValue().isDeleted()) { + ret.put(item.getKey(), item.getValue()); + } + } + } + return ret; + } + + private static class CacheKey { + final VersionedDataKind kind; + final String key; + + public static CacheKey forItem(VersionedDataKind kind, String key) { + return new CacheKey(kind, key); + } + + private CacheKey(VersionedDataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof CacheKey) { + CacheKey o = (CacheKey) other; + return o.kind.getNamespace().equals(this.kind.getNamespace()) && + o.key.equals(this.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.getNamespace().hashCode() * 31 + key.hashCode(); + } + } + + /** + * Builder for instances of {@link CachingStoreWrapper}. + */ + public static class Builder { + private final FeatureStoreCore core; + private long cacheTime; + private TimeUnit cacheTimeUnit; + private boolean refreshStaleValues; + private boolean asyncRefresh; + + public Builder(FeatureStoreCore core) { + this.core = core; + } + + /** + * Specifies the cache TTL. If {@code cacheTime} is zero or negative, there will be no local caching. + * Caching is off by default. + * @param cacheTime the cache TTL, in whatever unit is specified by {@code cacheTimeUnit} + * @param cacheTimeUnit the time unit + * @return the same builder + */ + public Builder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { + this.cacheTime = cacheTime; + this.cacheTimeUnit = cacheTimeUnit; + return this; + } + + /** + * Specifies whether the cache (if any) should attempt to refresh stale values instead of evicting them. + * In this mode, if the refresh fails, the last good value will still be available from the cache. + * @param refreshStaleValues true if values should be lazily refreshed + * @return the same builder + */ + public Builder refreshStaleValues(boolean refreshStaleValues) { + this.refreshStaleValues = refreshStaleValues; + return this; + } + + /** + * Specifies whether cache refreshing should be asynchronous (assuming {@code refreshStaleValues} is true). + * In this mode, if a cached value has expired, retrieving it will still get the old value but will + * trigger an attempt to refresh on another thread, rather than blocking until a new value is available. + * @param asyncRefresh true if values should be asynchronously refreshed + * @return the same builder + */ + public Builder asyncRefresh(boolean asyncRefresh) { + this.asyncRefresh = asyncRefresh; + return this; + } + + public CachingStoreWrapper build() { + return new CachingStoreWrapper(core, cacheTime, cacheTimeUnit, refreshStaleValues, asyncRefresh); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java new file mode 100644 index 000000000..9a7036f72 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -0,0 +1,77 @@ +package com.launchdarkly.client.utils; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import java.io.Closeable; +import java.util.Map; + +/** + * FeatureStoreCore is an interface for a simplified subset of the functionality of + * {@link FeatureStore}, to be used in conjunction with {@link CachingStoreWrapper}. This allows + * developers of custom FeatureStore implementations to avoid repeating logic that would + * commonly be needed in any such implementation, such as caching. Instead, they can implement + * only FeatureStoreCore and then create a CachingStoreWrapper. {@link FeatureStoreHelpers} may + * also be useful. + * + * @since 4.6.0 + */ +public interface FeatureStoreCore extends Closeable { + /** + * Returns the object to which the specified key is mapped, or null if no such item exists. + * The method should not attempt to filter out any items based on their isDeleted() property, + * nor to cache any items. + * + * @param class of the object that will be returned + * @param kind the kind of object to get + * @param key the key whose associated object is to be returned + * @return the object to which the specified key is mapped, or null + */ + T getInternal(VersionedDataKind kind, String key); + + /** + * Returns a {@link java.util.Map} of all associated objects of a given kind. The method + * should not attempt to filter out any items based on their isDeleted() property, nor to + * cache any items. + * + * @param class of the objects that will be returned in the map + * @param kind the kind of objects to get + * @return a map of all associated object. + */ + Map getAllInternal(VersionedDataKind kind); + + /** + * Initializes (or re-initializes) the store with the specified set of objects. Any existing entries + * will be removed. Implementations can assume that this set of objects is up to date-- there is no + * need to perform individual version comparisons between the existing objects and the supplied + * features. + * + * @param allData all objects to be stored + */ + void initInternal(Map, Map> allData); + + /** + * Updates or inserts the object associated with the specified key. If an item with the same key + * already exists, it should update it only if the new item's getVersion() value is greater than + * the old one. It should return the final state of the item, i.e. if the update succeeded then + * it returns the item that was passed in, and if the update failed due to the version check + * then it returns the item that is currently in the data store (this ensures that + * CachingStoreWrapper will update the cache correctly). + * + * @param class of the object to be updated + * @param kind the kind of object to update + * @param item the object to update or insert + */ + T upsertInternal(VersionedDataKind kind, T item); + + /** + * Returns true if this store has been initialized. In a shared data store, it should be able to + * detect this even if initInternal was called in a different process,ni.e. the test should be + * based on looking at what is in the data store. The method does not need to worry about caching + * this value; FeatureStoreWrapper will only call it when necessary. + * + * @return true if this store has been initialized + */ + boolean initializedInternal(); +} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java new file mode 100644 index 000000000..77885155f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -0,0 +1,35 @@ +package com.launchdarkly.client.utils; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +/** + * Helper methods that may be useful for implementing a {@link FeatureStore} or {@link FeatureStoreCore}. + * + * @since 4.6.0 + */ +public abstract class FeatureStoreHelpers { + private static final Gson gson = new Gson(); + + public static T unmarshalJson(VersionedDataKind kind, String data) { + try { + return gson.fromJson(data, kind.getItemClass()); + } catch (JsonParseException e) { + throw new UnmarshalException(e); + } + } + + public static String marshalJson(VersionedData item) { + return gson.toJson(item); + } + + @SuppressWarnings("serial") + public static class UnmarshalException extends RuntimeException { + public UnmarshalException(Throwable cause) { + super(cause); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java new file mode 100644 index 000000000..e5b40f722 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -0,0 +1,235 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.TestUtil.DataBuilder; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Map; + +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; + +/** + * Extends FeatureStoreTestBase with tests for feature stores where multiple store instances can + * use the same underlying data store (i.e. database implementations in general). + */ +@RunWith(Parameterized.class) +public abstract class FeatureStoreDatabaseTestBase extends FeatureStoreTestBase { + + @Parameters(name="cached={0}") + public static Iterable data() { + return Arrays.asList(new Boolean[] { false, true }); + } + + public FeatureStoreDatabaseTestBase(boolean cached) { + super(cached); + } + + /** + * Test subclasses should override this method if the feature store class supports a key prefix option + * for keeping data sets distinct within the same database. + */ + protected T makeStoreWithPrefix(String prefix) { + return null; + } + + /** + * Test classes should override this to return false if the feature store class does not have a local + * caching option (e.g. the in-memory store). + * @return + */ + protected boolean isCachingSupported() { + return true; + } + + /** + * Test classes should override this to clear all data from the underlying database, if it is + * possible for data to exist there before the feature store is created (i.e. if + * isUnderlyingDataSharedByAllInstances() returns true). + */ + protected void clearAllData() { + } + + /** + * Test classes should override this (and return true) if it is possible to instrument the feature + * store to execute the specified Runnable during an upsert operation, for concurrent modification tests. + */ + protected boolean setUpdateHook(T storeUnderTest, Runnable hook) { + return false; + } + + @Before + public void setup() { + assumeTrue(isCachingSupported() || !cached); + super.setup(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + clearAllData(); + assertFalse(store.initialized()); + } + + @Test + public void storeInitializedAfterInit() { + store.init(new DataBuilder().build()); + assertTrue(store.initialized()); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { + assumeFalse(cached); // caching would cause the inited state to only be detected after the cache has expired + + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.initialized()); + + store2.init(new DataBuilder().add(FEATURES, feature1).build()); + + assertTrue(store.initialized()); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { + assumeFalse(cached); // caching would cause the inited state to only be detected after the cache has expired + + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.initialized()); + + store2.init(new DataBuilder().build()); + + assertTrue(store.initialized()); + } + + // The following two tests verify that the update version checking logic works correctly when + // another client instance is modifying the same data. They will run only if the test class + // supports setUpdateHook(). + + @Test + public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() throws Exception { + final T store2 = makeStore(); + + int startVersion = 1; + final int store2VersionStart = 2; + final int store2VersionEnd = 4; + int store1VersionEnd = 10; + + final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + + Runnable concurrentModifier = new Runnable() { + int versionCounter = store2VersionStart; + public void run() { + if (versionCounter <= store2VersionEnd) { + FeatureFlag f = new FeatureFlagBuilder(flag1).version(versionCounter).build(); + store2.upsert(FEATURES, f); + versionCounter++; + } + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(FEATURES, flag1).build()); + + FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); + store.upsert(FEATURES, store1End); + + FeatureFlag result = store.get(FEATURES, flag1.getKey()); + assertEquals(store1VersionEnd, result.getVersion()); + } finally { + store2.close(); + } + } + + @Test + public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { + final T store2 = makeStore(); + + int startVersion = 1; + final int store2Version = 3; + int store1VersionEnd = 2; + + final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + + Runnable concurrentModifier = new Runnable() { + public void run() { + FeatureFlag f = new FeatureFlagBuilder(flag1).version(store2Version).build(); + store2.upsert(FEATURES, f); + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(FEATURES, flag1).build()); + + FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); + store.upsert(FEATURES, store1End); + + FeatureFlag result = store.get(FEATURES, flag1.getKey()); + assertEquals(store2Version, result.getVersion()); + } finally { + store2.close(); + } + } + + @Test + public void storesWithDifferentPrefixAreIndependent() throws Exception { + assumeFalse(cached); + + T store1 = makeStoreWithPrefix("aaa"); + Assume.assumeNotNull(store1); + T store2 = makeStoreWithPrefix("bbb"); + clearAllData(); + + try { + assertFalse(store1.initialized()); + assertFalse(store2.initialized()); + + FeatureFlag flag1a = new FeatureFlagBuilder("flag-a").version(1).build(); + FeatureFlag flag1b = new FeatureFlagBuilder("flag-b").version(1).build(); + FeatureFlag flag2a = new FeatureFlagBuilder("flag-a").version(2).build(); + FeatureFlag flag2c = new FeatureFlagBuilder("flag-c").version(2).build(); + + store1.init(new DataBuilder().add(FEATURES, flag1a, flag1b).build()); + assertTrue(store1.initialized()); + assertFalse(store2.initialized()); + + store2.init(new DataBuilder().add(FEATURES, flag2a, flag2c).build()); + assertTrue(store1.initialized()); + assertTrue(store2.initialized()); + + Map items1 = store1.all(FEATURES); + Map items2 = store2.all(FEATURES); + assertEquals(2, items1.size()); + assertEquals(2, items2.size()); + assertEquals(flag1a.getVersion(), items1.get(flag1a.getKey()).getVersion()); + assertEquals(flag1b.getVersion(), items1.get(flag1b.getKey()).getVersion()); + assertEquals(flag2a.getVersion(), items2.get(flag2a.getKey()).getVersion()); + assertEquals(flag2c.getVersion(), items2.get(flag2c.getKey()).getVersion()); + } finally { + store1.close(); + store2.close(); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index e82ef1012..222af5eb7 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -1,19 +1,29 @@ package com.launchdarkly.client; +import com.launchdarkly.client.TestUtil.DataBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + 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.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import java.util.HashMap; -import java.util.Map; - -import org.junit.Test; - +/** + * Basic tests for FeatureStore implementations. For database implementations, use the more + * comprehensive FeatureStoreDatabaseTestBase. + */ public abstract class FeatureStoreTestBase { protected T store; + protected boolean cached; protected FeatureFlag feature1 = new FeatureFlagBuilder("foo") .version(10) @@ -25,37 +35,103 @@ public abstract class FeatureStoreTestBase { .salt("abc") .build(); - protected void initStore() { - HashMap flags = new HashMap<>(); - flags.put(feature1.getKey(), feature1); - flags.put(feature2.getKey(), feature2); - Map, Map> allData = new HashMap<>(); - allData.put(FEATURES, flags); - store.init(allData); + protected Segment segment1 = new Segment.Builder("foo") + .version(11) + .build(); + + public FeatureStoreTestBase() { + this(false); + } + + public FeatureStoreTestBase(boolean cached) { + this.cached = cached; + } + + /** + * Test subclasses must override this method to create an instance of the feature store class, with + * caching either enabled or disabled depending on the "cached" property. + * @return + */ + protected abstract T makeStore(); + + /** + * Test classes should override this to clear all data from the underlying database, if it is + * possible for data to exist there before the feature store is created (i.e. if + * isUnderlyingDataSharedByAllInstances() returns true). + */ + protected void clearAllData() { + } + + @Before + public void setup() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + clearAllData(); + assertFalse(store.initialized()); } @Test public void storeInitializedAfterInit() { - initStore(); + store.init(new DataBuilder().build()); assertTrue(store.initialized()); } + @Test + public void initCompletelyReplacesPreviousData() { + clearAllData(); + + Map, Map> allData = + new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build(); + store.init(allData); + + FeatureFlag feature2v2 = new FeatureFlagBuilder(feature2).version(feature2.getVersion() + 1).build(); + allData = new DataBuilder().add(FEATURES, feature2v2).add(SEGMENTS).build(); + store.init(allData); + + assertNull(store.get(FEATURES, feature1.getKey())); + FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); + assertNotNull(item2); + assertEquals(feature2v2.getVersion(), item2.getVersion()); + assertNull(store.get(SEGMENTS, segment1.getKey())); + } + @Test public void getExistingFeature() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); FeatureFlag result = store.get(FEATURES, feature1.getKey()); assertEquals(feature1.getKey(), result.getKey()); } @Test public void getNonexistingFeature() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); assertNull(store.get(FEATURES, "biz")); } + @Test + public void getAll() { + store.init(new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build()); + Map items = store.all(FEATURES); + assertEquals(2, items.size()); + FeatureFlag item1 = items.get(feature1.getKey()); + assertNotNull(item1); + assertEquals(feature1.getVersion(), item1.getVersion()); + FeatureFlag item2 = items.get(feature2.getKey()); + assertNotNull(item2); + assertEquals(feature2.getVersion(), item2.getVersion()); + } + @Test public void upsertWithNewerVersion() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); FeatureFlag newVer = new FeatureFlagBuilder(feature1) .version(feature1.getVersion() + 1) .build(); @@ -66,7 +142,7 @@ public void upsertWithNewerVersion() { @Test public void upsertWithOlderVersion() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); FeatureFlag oldVer = new FeatureFlagBuilder(feature1) .version(feature1.getVersion() - 1) .build(); @@ -77,7 +153,7 @@ public void upsertWithOlderVersion() { @Test public void upsertNewFeature() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); FeatureFlag newFeature = new FeatureFlagBuilder("biz") .version(99) .build(); @@ -88,28 +164,28 @@ public void upsertNewFeature() { @Test public void deleteWithNewerVersion() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); assertNull(store.get(FEATURES, feature1.getKey())); } @Test public void deleteWithOlderVersion() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); store.delete(FEATURES, feature1.getKey(), feature1.getVersion() - 1); assertNotNull(store.get(FEATURES, feature1.getKey())); } @Test public void deleteUnknownFeature() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); store.delete(FEATURES, "biz", 11); assertNull(store.get(FEATURES, "biz")); } @Test public void upsertOlderVersionAfterDelete() { - initStore(); + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); store.upsert(FEATURES, feature1); assertNull(store.get(FEATURES, feature1.getKey())); diff --git a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java index c493122b8..3e10be929 100644 --- a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java @@ -1,11 +1,13 @@ package com.launchdarkly.client; -import org.junit.Before; - public class InMemoryFeatureStoreTest extends FeatureStoreTestBase { - @Before - public void setup() { - store = new InMemoryFeatureStore(); + public InMemoryFeatureStoreTest(boolean cached) { + super(cached); + } + + @Override + protected InMemoryFeatureStore makeStore() { + return new InMemoryFeatureStore(); } } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java index e83f0a2ac..ed029109b 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java @@ -17,7 +17,8 @@ public class RedisFeatureStoreBuilderTest { public void testDefaultValues() { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTimeSecs); + assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTime); + assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(false, conf.refreshStaleValues); @@ -31,7 +32,8 @@ public void testConstructorSpecifyingUri() { URI uri = URI.create("redis://host:1234"); RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); assertEquals(uri, conf.uri); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTimeSecs); + assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTime); + assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(false, conf.refreshStaleValues); @@ -45,7 +47,8 @@ public void testConstructorSpecifyingUri() { public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); assertEquals(URI.create("badscheme://example:1234"), conf.uri); - assertEquals(100, conf.cacheTimeSecs); + assertEquals(100, conf.cacheTime); + assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(false, conf.refreshStaleValues); @@ -85,9 +88,10 @@ public void testSocketTimeoutConfigured() throws URISyntaxException { } @Test - public void testCacheTimeConfiguredInSeconds() throws URISyntaxException { + public void testCacheTimeWithUnit() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); - assertEquals(2, conf.cacheTimeSecs); + assertEquals(2000, conf.cacheTime); + assertEquals(TimeUnit.MILLISECONDS, conf.cacheTimeUnit); } @Test diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index 7b91d649e..90c9a1dca 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -1,83 +1,47 @@ package com.launchdarkly.client; -import com.google.gson.Gson; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; +import com.launchdarkly.client.RedisFeatureStore.UpdateListener; 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 java.util.concurrent.TimeUnit; import redis.clients.jedis.Jedis; -public class RedisFeatureStoreTest extends FeatureStoreTestBase { +public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { - @Before - public void setup() { - store = new RedisFeatureStoreBuilder(URI.create("redis://localhost:6379")).build(); + private static final URI REDIS_URI = URI.create("redis://localhost:6379"); + + public RedisFeatureStoreTest(boolean cached) { + super(cached); } - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() { - final Jedis otherClient = new Jedis("localhost"); - try { - final FeatureFlag flag = new FeatureFlagBuilder("foo").version(1).build(); - initStoreWithSingleFeature(store, flag); - - store.setUpdateListener(makeConcurrentModifier(otherClient, flag, 2, 4)); - - 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(); - } + @Override + protected RedisFeatureStore makeStore() { + RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); + builder.cacheTime(cached ? 30 : 0, TimeUnit.SECONDS); + return builder.build(); } - @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)); - - FeatureFlag myVer = new FeatureFlagBuilder(flag).version(2).build(); - store.upsert(FEATURES, myVer); - FeatureFlag result = store.get(FEATURES, feature1.getKey()); - Assert.assertEquals(3, result.getVersion()); - } finally { - otherClient.close(); - } + @Override + protected RedisFeatureStore makeStoreWithPrefix(String prefix) { + return new RedisFeatureStoreBuilder(REDIS_URI).cacheTime(0, TimeUnit.SECONDS).prefix(prefix).build(); } - 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); + @Override + protected void clearAllData() { + try (Jedis client = new Jedis("localhost")) { + client.flushDB(); + } } - 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 + protected boolean setUpdateHook(RedisFeatureStore storeUnderTest, final Runnable hook) { + storeUnderTest.setUpdateListener(new UpdateListener() { @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)); - } + hook.run(); } - }; + }); + return true; } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index a55f81fb9..c37ff6549 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Future; @@ -154,6 +155,27 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } + public static class DataBuilder { + private Map, Map> data = new HashMap<>(); + + @SuppressWarnings("unchecked") + public DataBuilder add(VersionedDataKind kind, VersionedData... items) { + Map itemsMap = (Map) data.get(kind); + if (itemsMap == null) { + itemsMap = new HashMap<>(); + data.put(kind, itemsMap); + } + for (VersionedData item: items) { + itemsMap.put(item.getKey(), item); + } + return this; + } + + public Map, Map> build() { + return data; + } + } + public static EvaluationDetail simpleEvaluation(int variation, JsonElement value) { return new EvaluationDetail<>(EvaluationReason.fallthrough(), variation, value); } diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java new file mode 100644 index 000000000..b3aae4706 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -0,0 +1,411 @@ +package com.launchdarkly.client.utils; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assume.assumeThat; + +@RunWith(Parameterized.class) +public class CachingStoreWrapperTest { + + private final boolean cached; + private final MockCore core; + private final CachingStoreWrapper wrapper; + + @Parameters(name="cached={0}") + public static Iterable data() { + return Arrays.asList(new Boolean[] { false, true }); + } + + public CachingStoreWrapperTest(boolean cached) { + this.cached = cached; + this.core = new MockCore(); + this.wrapper = new CachingStoreWrapper(core, cached ? 30 : 0, TimeUnit.SECONDS, false, false); + } + + @Test + public void get() { + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + core.forceSet(THINGS, itemv1); + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); + + core.forceSet(THINGS, itemv2); + MockItem result = wrapper.get(THINGS, itemv1.key); + assertThat(result, equalTo(cached ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet + } + + @Test + public void getDeletedItem() { + MockItem itemv1 = new MockItem("flag", 1, true); + MockItem itemv2 = new MockItem("flag", 2, false); + + core.forceSet(THINGS, itemv1); + assertThat(wrapper.get(THINGS, itemv1.key), nullValue()); // item is filtered out because deleted is true + + core.forceSet(THINGS, itemv2); + MockItem result = wrapper.get(THINGS, itemv1.key); + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet + } + + @Test + public void getMissingItem() { + MockItem item = new MockItem("flag", 1, false); + + assertThat(wrapper.get(THINGS, item.getKey()), nullValue()); + + core.forceSet(THINGS, item); + MockItem result = wrapper.get(THINGS, item.key); + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result + } + + @Test + public void cachedGetUsesValuesFromInit() { + if (!cached) { + return; + } + + MockItem item1 = new MockItem("flag1", 1, false); + MockItem item2 = new MockItem("flag2", 1, false); + Map, Map> allData = makeData(item1, item2); + wrapper.init(allData); + + assertThat(core.data, equalTo(allData)); + } + + @Test + public void getAll() { + MockItem item1 = new MockItem("flag1", 1, false); + MockItem item2 = new MockItem("flag2", 1, false); + + core.forceSet(THINGS, item1); + core.forceSet(THINGS, item2); + Map items = wrapper.all(THINGS); + Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); + assertThat(items, equalTo(expected)); + + core.forceRemove(THINGS, item2.key); + items = wrapper.all(THINGS); + if (cached) { + assertThat(items, equalTo(expected)); + } else { + Map expected1 = ImmutableMap.of(item1.key, item1); + assertThat(items, equalTo(expected1)); + } + } + + @Test + public void cachedAllUsesValuesFromInit() { + if (!cached) { + return; + } + + MockItem item1 = new MockItem("flag1", 1, false); + MockItem item2 = new MockItem("flag2", 1, false); + Map, Map> allData = makeData(item1, item2); + wrapper.init(allData); + + core.forceRemove(THINGS, item2.key); + + Map items = wrapper.all(THINGS); + Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedAllUsesFreshValuesIfThereHasBeenAnUpdate() { + if (!cached) { + return; + } + + MockItem item1 = new MockItem("flag1", 1, false); + MockItem item1v2 = new MockItem("flag1", 2, false); + MockItem item2 = new MockItem("flag2", 1, false); + MockItem item2v2 = new MockItem("flag2", 2, false); + + Map, Map> allData = makeData(item1, item2); + wrapper.init(allData); + + // make a change to item1 via the wrapper - this should flush the cache + wrapper.upsert(THINGS, item1v2); + + // make a change to item2 that bypasses the cache + core.forceSet(THINGS, item2v2); + + // we should now see both changes since the cache was flushed + Map items = wrapper.all(THINGS); + Map expected = ImmutableMap.of(item1.key, item1v2, item2.key, item2v2); + assertThat(items, equalTo(expected)); + } + + @Test + public void upsertSuccessful() { + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.upsert(THINGS, itemv1); + assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv1)); + + wrapper.upsert(THINGS, itemv2); + assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); + + // if we have a cache, verify that the new item is now cached by writing a different value + // to the underlying data - Get should still return the cached item + if (cached) { + MockItem item1v3 = new MockItem("flag", 3, false); + core.forceSet(THINGS, item1v3); + } + + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); + } + + @Test + public void cachedUpsertUnsuccessful() { + if (!cached) { + return; + } + + // This is for an upsert where the data in the store has a higher version. In an uncached + // store, this is just a no-op as far as the wrapper is concerned so there's nothing to + // test here. In a cached store, we need to verify that the cache has been refreshed + // using the data that was found in the store. + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.upsert(THINGS, itemv2); + assertThat((MockItem)core.data.get(THINGS).get(itemv2.key), equalTo(itemv2)); + + wrapper.upsert(THINGS, itemv1); + assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); // value in store remains the same + + MockItem itemv3 = new MockItem("flag", 3, false); + core.forceSet(THINGS, itemv3); // bypasses cache so we can verify that itemv2 is in the cache + + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); + } + + @Test + public void delete() { + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, true); + MockItem itemv3 = new MockItem("flag", 3, false); + + core.forceSet(THINGS, itemv1); + MockItem item = wrapper.get(THINGS, itemv1.key); + assertThat(item, equalTo(itemv1)); + + wrapper.delete(THINGS, itemv1.key, 2); + assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); + + // make a change that bypasses the cache + core.forceSet(THINGS, itemv3); + + MockItem result = wrapper.get(THINGS, itemv1.key); + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv3)); + } + + @Test + public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { + assumeThat(cached, is(false)); + + assertThat(wrapper.initialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited = true; + assertThat(wrapper.initialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + core.inited = false; + assertThat(wrapper.initialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + + @Test + public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { + assumeThat(cached, is(false)); + + assertThat(wrapper.initialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + wrapper.init(makeData()); + + assertThat(wrapper.initialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(1)); + } + + @Test + public void initializedCanCacheFalseResult() throws Exception { + assumeThat(cached, is(true)); + + // We need to create a different object for this test so we can set a short cache TTL + try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, 500, TimeUnit.MILLISECONDS, false, false)) { + assertThat(wrapper1.initialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited = true; + assertThat(core.initedQueryCount, equalTo(1)); + + Thread.sleep(600); + + assertThat(wrapper1.initialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + // From this point on it should remain true and the method should not be called + assertThat(wrapper1.initialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + } + + private Map, Map> makeData(MockItem... items) { + Map innerMap = new HashMap<>(); + for (MockItem item: items) { + innerMap.put(item.getKey(), item); + } + Map, Map> outerMap = new HashMap<>(); + outerMap.put(THINGS, innerMap); + return outerMap; + } + + static class MockCore implements FeatureStoreCore { + Map, Map> data = new HashMap<>(); + boolean inited; + int initedQueryCount; + + @Override + public void close() throws IOException { + } + + @SuppressWarnings("unchecked") + @Override + public T getInternal(VersionedDataKind kind, String key) { + if (data.containsKey(kind)) { + return (T)data.get(kind).get(key); + } + return null; + } + + @SuppressWarnings("unchecked") + @Override + public Map getAllInternal(VersionedDataKind kind) { + return (Map)data.get(kind); + } + + @Override + public void initInternal(Map, Map> allData) { + data = new HashMap<>(); + for (Map.Entry, Map> e: allData.entrySet()) { + data.put(e.getKey(), new HashMap<>(e.getValue())); + } + inited = true; + } + + @SuppressWarnings("unchecked") + @Override + public T upsertInternal(VersionedDataKind kind, T item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap()); + } + HashMap items = (HashMap)data.get(kind); + T oldItem = (T)items.get(item.getKey()); + if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { + return oldItem; + } + items.put(item.getKey(), item); + return item; + } + + @Override + public boolean initializedInternal() { + initedQueryCount++; + return inited; + } + + public void forceSet(VersionedDataKind kind, T item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap()); + } + @SuppressWarnings("unchecked") + HashMap items = (HashMap)data.get(kind); + items.put(item.getKey(), item); + } + + public void forceRemove(VersionedDataKind kind, String key) { + if (data.containsKey(kind)) { + data.get(kind).remove(key); + } + } + } + + static class MockItem implements VersionedData { + private final String key; + private final int version; + private final boolean deleted; + + public MockItem(String key, int version, boolean deleted) { + this.key = key; + this.version = version; + this.deleted = deleted; + } + + public String getKey() { + return key; + } + + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + + @Override + public String toString() { + return "[" + key + ", " + version + ", " + deleted + "]"; + } + + @Override + public boolean equals(Object other) { + if (other instanceof MockItem) { + MockItem o = (MockItem)other; + return key.equals(o.key) && version == o.version && deleted == o.deleted; + } + return false; + } + } + + static VersionedDataKind THINGS = new VersionedDataKind() { + public String getNamespace() { + return "things"; + } + + public Class getItemClass() { + return MockItem.class; + } + + public String getStreamApiPath() { + return "/things/"; + } + + public MockItem makeDeletedItem(String key, int version) { + return new MockItem(key, version, true); + } + }; +} From fa56386c91f3232f655e2ac4973a1885563359d4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Nov 2018 14:42:10 -0800 Subject: [PATCH 075/156] comments --- .../client/utils/FeatureStoreHelpers.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index 77885155f..caa79d9f9 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -14,6 +14,16 @@ public abstract class FeatureStoreHelpers { private static final Gson gson = new Gson(); + /** + * Unmarshals a feature store item from a JSON string. This is a very simple wrapper around a Gson + * method, just to allow external feature store implementations to make use of the Gson instance + * that's inside the SDK rather than having to import Gson themselves. + * + * @param kind specifies the type of item being decoded + * @param data the JSON string + * @return the unmarshaled item + * @throws UnmarshalException if the JSON string was invalid + */ public static T unmarshalJson(VersionedDataKind kind, String data) { try { return gson.fromJson(data, kind.getItemClass()); @@ -22,6 +32,13 @@ public static T unmarshalJson(VersionedDataKind kin } } + /** + * Marshals a feature store item into a JSON string. This is a very simple wrapper around a Gson + * method, just to allow external feature store implementations to make use of the Gson instance + * that's inside the SDK rather than having to import Gson themselves. + * @param item the item to be marshaled + * @return the JSON string + */ public static String marshalJson(VersionedData item) { return gson.toJson(item); } From 416c7dd81ee16230ac74a7acd326ec8b400246f6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Nov 2018 14:45:05 -0800 Subject: [PATCH 076/156] fix test --- .../com/launchdarkly/client/InMemoryFeatureStoreTest.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java index 3e10be929..fec2aab7c 100644 --- a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java @@ -2,10 +2,6 @@ public class InMemoryFeatureStoreTest extends FeatureStoreTestBase { - public InMemoryFeatureStoreTest(boolean cached) { - super(cached); - } - @Override protected InMemoryFeatureStore makeStore() { return new InMemoryFeatureStore(); From cc5efed970eb520198e8a8e711169f26205beb30 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Nov 2018 10:40:08 -0800 Subject: [PATCH 077/156] generate OSGi manifests semi-manually --- build.gradle | 64 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/build.gradle b/build.gradle index 3f8b96823..1994e9551 100644 --- a/build.gradle +++ b/build.gradle @@ -27,18 +27,29 @@ allprojects { ext.libraries = [:] +ext.imports = [ + commons_codec: [ version: "1.10" ], + guava: [ version: "19.0" ], + joda_time: [ version: "2.9.3" ], + okhttp_eventsource: [ version: "1.7.1" ], + snakeyaml: [ version: "1.19" ], + jedis: [ version: "2.9.0" ], + gson: [ version: "2.7", limit: "3" ], + slf4j_api: [ version: "1.7.21", limit: "2" ] +] + libraries.internal = [ - "commons-codec:commons-codec:1.10", - "com.google.guava:guava:19.0", - "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.7.1", - "org.yaml:snakeyaml:1.19", - "redis.clients:jedis:2.9.0" + "commons-codec:commons-codec:" + imports.commons_codec.version, + "com.google.guava:guava:" + imports.guava.version, + "joda-time:joda-time:" + imports.joda_time.version, + "com.launchdarkly:okhttp-eventsource:" + imports.okhttp_eventsource.version, + "org.yaml:snakeyaml:" + imports.snakeyaml.version, + "redis.clients:jedis:" + imports.jedis.version ] libraries.external = [ - "com.google.code.gson:gson:2.7", - "org.slf4j:slf4j-api:1.7.21" + "com.google.code.gson:gson:" + imports.gson.version, + "org.slf4j:slf4j-api:" + imports.slf4j_api.version ] libraries.test = [ @@ -120,6 +131,7 @@ shadowJar { // Shade all jars except for launchdarkly relocate('com', 'com.launchdarkly.shaded.com') { exclude("com.launchdarkly.client.*") + exclude("com.launchdarkly.client.files.*") exclude("com.google.gson.*") exclude("com.google.gson.annotations.*") exclude("com.google.gson.internal.*") @@ -140,9 +152,45 @@ shadowJar { manifest { attributes("Implementation-Version": version) + + attributes(osgiBaseAttributes()) + attributes(osgiImports()) + attributes("Export-Package": [ + bundleExport("com.launchdarkly.client"), + bundleExport("com.launchdarkly.client.files") + ].join(",")) } } +def osgiBaseAttributes() { + [ + "Bundle-SymbolicName": "com.launchdarkly.client", + "Bundle-Version": version, + "Bundle-Name": "launchdarkly-client", + "Bundle-ManifestVersion": "2" + ] +} + +def osgiImports() { + [ + "Import-Package": [ + bundleImport("com.google.gson", imports.gson), + bundleImport("com.google.gson.annotations", imports.gson), + bundleImport("com.google.gson.reflect", imports.gson), + bundleImport("com.google.gson.stream", imports.gson), + bundleImport("org.slf4j", imports.slf4j_api) + ].join(",") + ] +} + +def bundleExport(packageName) { + packageName + ";version=\"" + version + "\"" +} + +def bundleImport(packageName, importDef) { + packageName + ";version=\"[" + importDef.version + "," + importDef.limit + ")\"" +} + // This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, // Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { From 94db999138982033c3f0679e6e2a45567512832b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Nov 2018 11:43:15 -0800 Subject: [PATCH 078/156] fix build script so file data source package isn't shaded --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 3f8b96823..6d8f920b5 100644 --- a/build.gradle +++ b/build.gradle @@ -120,6 +120,7 @@ shadowJar { // Shade all jars except for launchdarkly relocate('com', 'com.launchdarkly.shaded.com') { exclude("com.launchdarkly.client.*") + exclude("com.launchdarkly.client.files.*") exclude("com.google.gson.*") exclude("com.google.gson.annotations.*") exclude("com.google.gson.internal.*") @@ -157,6 +158,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ // Shade all jars except for launchdarkly relocate('com', 'com.launchdarkly.shaded.com') { exclude("com.launchdarkly.client.*") + exclude("com.launchdarkly.client.files.*") exclude("com.google.gson.*") exclude("com.google.gson.annotations.*") exclude("com.google.gson.internal.*") From 7854de7e3b9f1550f61917436235cdd2cce55538 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Nov 2018 13:18:34 -0800 Subject: [PATCH 079/156] remove hard-coded package names, detect them from dependencies --- build.gradle | 112 +++++++++++++++++++++++++++++---------------------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/build.gradle b/build.gradle index 6d8f920b5..216f91dbe 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,11 @@ allprojects { targetCompatibility = 1.7 } +ext { + sdkBasePackage = "com.launchdarkly.client" + sdkBaseName = "launchdarkly-client" +} + ext.libraries = [:] libraries.internal = [ @@ -54,11 +59,14 @@ dependencies { compileClasspath libraries.external runtime libraries.internal, libraries.external testImplementation libraries.test, libraries.internal, libraries.external + + // Unlike what the name might suggest, the "shadow" configuration specifies dependencies that + // should *not* be shaded by the Shadow plugin when we build our shaded jars. shadow libraries.external } jar { - baseName = 'launchdarkly-client' + baseName = sdkBaseName // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' @@ -106,38 +114,66 @@ githubPages { } } +// Returns the names of all Java packages defined in this library - not including +// enclosing packages like "com" that don't have any classes in them. +def getAllSdkPackages() { + def names = [] + project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> + if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { + baseDir.eachFileRecurse { f -> + if (f.name.endsWith(".class")) { + def subPath = f.getPath().substring(baseDir.getPath().length() + File.separator.length()) + def pkgName = subPath.substring(0, subPath.lastIndexOf(File.separator)).replace(File.separator, ".") + names += pkgName + } + } + } + } + names.unique() +} + +// Returns the names of all Java packages contained in the specified jar - not including +// enclosing packages like "com" that don't have any classes in them. +def getPackagesInDependencyJar(jarFile) { + new java.util.zip.ZipFile(jarFile).withCloseable { zf -> + zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { + it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") + }.unique() + } +} + +// Used by shadowJar and shadowJarAll to specify which packages should be shaded. We should +// *not* shade any of the dependencies that are specified in the "shadow" configuration, +// nor any of the classes from the SDK itself. +def shadeDependencies(jarTask) { + def excludePackages = getAllSdkPackages() + + configurations.shadow.collectMany { getPackagesInDependencyJar(it)} + def topLevelPackages = + configurations.runtime.collectMany { + getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } + }. + unique().findAll { it != "javax" } // also, don't shade javax + topLevelPackages.forEach { top -> + jarTask.relocate(top, "com.launchdarkly.shaded." + top) { + excludePackages.forEach { exclude(it + ".*") } + } + } +} + shadowJar { - baseName = 'launchdarkly-client' - //no classifier means that the shaded jar becomes the default artifact + baseName = sdkBaseName + + // No classifier means that the shaded jar becomes the default artifact classifier = '' - // Don't shade or include slf4j + // Don't include slf4j or gson. This is the only difference between this artifact + // and shadowJarAll, which does include (but doesn't shade) slf4j and gson. dependencies{ exclude(dependency('org.slf4j:.*:.*')) exclude(dependency('com.google.code.gson:.*:.*')) } - // Shade all jars except for launchdarkly - relocate('com', 'com.launchdarkly.shaded.com') { - exclude("com.launchdarkly.client.*") - exclude("com.launchdarkly.client.files.*") - exclude("com.google.gson.*") - exclude("com.google.gson.annotations.*") - exclude("com.google.gson.internal.*") - exclude("com.google.gson.internal.bind.*") - exclude("com.google.gson.internal.bind.util.*") - exclude("com.google.gson.reflect.*") - exclude("com.google.gson.stream.*") - } - relocate('okhttp3', 'com.launchdarkly.shaded.okhttp3') - relocate('okio', 'com.launchdarkly.shaded.okio') - relocate('org', 'com.launchdarkly.shaded.org') { - exclude("org.slf4j.*") - exclude("org.slf4j.event.*") - exclude("org.slf4j.helpers.*") - exclude("org.slf4j.spi.*") - } - relocate('redis', 'com.launchdarkly.shaded.redis') + shadeDependencies(owner) manifest { attributes("Implementation-Version": version) @@ -147,7 +183,7 @@ shadowJar { // This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, // Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { - baseName = 'launchdarkly-client' + baseName = sdkBaseName classifier = 'all' group = "shadow" description = "Builds a Shaded fat jar including SLF4J" @@ -155,27 +191,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - // Shade all jars except for launchdarkly - relocate('com', 'com.launchdarkly.shaded.com') { - exclude("com.launchdarkly.client.*") - exclude("com.launchdarkly.client.files.*") - exclude("com.google.gson.*") - exclude("com.google.gson.annotations.*") - exclude("com.google.gson.internal.*") - exclude("com.google.gson.internal.bind.*") - exclude("com.google.gson.internal.bind.util.*") - exclude("com.google.gson.reflect.*") - exclude("com.google.gson.stream.*") - } - relocate('okhttp3', 'com.launchdarkly.shaded.okhttp3') - relocate('okio', 'com.launchdarkly.shaded.okio') - relocate('org', 'com.launchdarkly.shaded.org') { - exclude("org.slf4j.*") - exclude("org.slf4j.event.*") - exclude("org.slf4j.helpers.*") - exclude("org.slf4j.spi.*") - } - relocate('redis', 'com.launchdarkly.shaded.redis') + shadeDependencies(owner) manifest { attributes("Implementation-Version": version) @@ -238,7 +254,7 @@ publishing { shadow(MavenPublication) { publication -> project.shadow.component(publication) - artifactId = 'launchdarkly-client' + artifactId = sdkBaseName artifact jar artifact sourcesJar artifact javadocJar From 1eb708418e767383662e497dcb27254bd2b06722 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Nov 2018 13:52:30 -0800 Subject: [PATCH 080/156] don't try to access build products during configuration phase --- build.gradle | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 216f91dbe..5a9d392f1 100644 --- a/build.gradle +++ b/build.gradle @@ -145,6 +145,9 @@ def getPackagesInDependencyJar(jarFile) { // Used by shadowJar and shadowJarAll to specify which packages should be shaded. We should // *not* shade any of the dependencies that are specified in the "shadow" configuration, // nor any of the classes from the SDK itself. +// +// This depends on our build products, so it can't be executed during Gradle's configuration +// phase; instead we have to run it after configuration, with the "afterEvaluate" block below. def shadeDependencies(jarTask) { def excludePackages = getAllSdkPackages() + configurations.shadow.collectMany { getPackagesInDependencyJar(it)} @@ -160,6 +163,15 @@ def shadeDependencies(jarTask) { } } +// We can't actually call shadeDependencies from within the configuration section of shadowJar +// or shadowJarAll, because Groovy executes all the configuration sections before executing +// any tasks, meaning we wouldn't have any build products yet to inspect. So we'll do that +// configuration step at the last minute after the compile task has executed. +compileJava.doLast { + shadeDependencies(project.tasks.shadowJar) + shadeDependencies(project.tasks.shadowJarAll) +} + shadowJar { baseName = sdkBaseName @@ -173,8 +185,6 @@ shadowJar { exclude(dependency('com.google.code.gson:.*:.*')) } - shadeDependencies(owner) - manifest { attributes("Implementation-Version": version) } @@ -191,8 +201,6 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - shadeDependencies(owner) - manifest { attributes("Implementation-Version": version) } From facf198e35320855f1755a85ca4374efb19ea1c5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Nov 2018 14:01:45 -0800 Subject: [PATCH 081/156] comments --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 5a9d392f1..91f627879 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,9 @@ ext { ext.libraries = [:] +// Add dependencies to "libraries.internal" that are not exposed in our public API. These +// will be completely omitted from the "thin" jar, and will be embedded with shaded names +// in the other two SDK jars. libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", @@ -41,11 +44,14 @@ libraries.internal = [ "redis.clients:jedis:2.9.0" ] +// Add dependencies to "libraries.external" that are exposed in our public API, or that have +// global state that must be shared between the SDK and the caller. libraries.external = [ "com.google.code.gson:gson:2.7", "org.slf4j:slf4j-api:1.7.21" ] +// Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ "com.squareup.okhttp3:mockwebserver:3.10.0", "org.hamcrest:hamcrest-all:1.3", From beb8a1d20a819a3de168502b1475b785e94c9eef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 21 Nov 2018 20:09:35 -0800 Subject: [PATCH 082/156] minor javadoc fixes --- .../java/com/launchdarkly/client/utils/FeatureStoreCore.java | 1 + .../java/com/launchdarkly/client/utils/FeatureStoreHelpers.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java index 9a7036f72..561fa468f 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -62,6 +62,7 @@ public interface FeatureStoreCore extends Closeable { * @param class of the object to be updated * @param kind the kind of object to update * @param item the object to update or insert + * @return the state of the object after the update */ T upsertInternal(VersionedDataKind kind, T item); diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index caa79d9f9..871838899 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -19,6 +19,7 @@ public abstract class FeatureStoreHelpers { * method, just to allow external feature store implementations to make use of the Gson instance * that's inside the SDK rather than having to import Gson themselves. * + * @param class of the object that will be returned * @param kind specifies the type of item being decoded * @param data the JSON string * @return the unmarshaled item From 37d7b0bfd9164e25f5ad5e6630d285d9da4c286b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 21 Nov 2018 22:51:38 -0800 Subject: [PATCH 083/156] misc changes for test support --- .../client/utils/CachingStoreWrapper.java | 11 ++++++++++- .../client/FeatureStoreDatabaseTestBase.java | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index dbff1610c..33d4e920c 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -182,7 +182,7 @@ public boolean initialized() { /** * Return the underlying Guava cache stats object. * - * @return the cache statistics object. + * @return the cache statistics object */ public CacheStats getCacheStats() { if (itemCache != null) { @@ -191,6 +191,15 @@ public CacheStats getCacheStats() { return null; } + /** + * Return the underlying implementation object. + * + * @return the underlying implementation object + */ + public FeatureStoreCore getCore() { + return core; + } + private T itemOnlyIfNotDeleted(T item) { return (item != null && item.isDeleted()) ? null : item; } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index e5b40f722..34bfc1d74 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -227,6 +227,11 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertEquals(flag1b.getVersion(), items1.get(flag1b.getKey()).getVersion()); assertEquals(flag2a.getVersion(), items2.get(flag2a.getKey()).getVersion()); assertEquals(flag2c.getVersion(), items2.get(flag2c.getKey()).getVersion()); + + assertEquals(flag1a.getVersion(), store1.get(FEATURES, flag1a.getKey()).getVersion()); + assertEquals(flag1b.getVersion(), store1.get(FEATURES, flag1b.getKey()).getVersion()); + assertEquals(flag2a.getVersion(), store1.get(FEATURES, flag2a.getKey()).getVersion()); + assertEquals(flag2c.getVersion(), store1.get(FEATURES, flag2c.getKey()).getVersion()); } finally { store1.close(); store2.close(); From 64765c27b1737f8bb12e1caa7eb1add8dedfdca0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 21 Nov 2018 23:08:49 -0800 Subject: [PATCH 084/156] test fix --- .../com/launchdarkly/client/FeatureStoreDatabaseTestBase.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index 34bfc1d74..e96278e11 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -230,8 +230,8 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertEquals(flag1a.getVersion(), store1.get(FEATURES, flag1a.getKey()).getVersion()); assertEquals(flag1b.getVersion(), store1.get(FEATURES, flag1b.getKey()).getVersion()); - assertEquals(flag2a.getVersion(), store1.get(FEATURES, flag2a.getKey()).getVersion()); - assertEquals(flag2c.getVersion(), store1.get(FEATURES, flag2c.getKey()).getVersion()); + assertEquals(flag2a.getVersion(), store2.get(FEATURES, flag2a.getKey()).getVersion()); + assertEquals(flag2c.getVersion(), store2.get(FEATURES, flag2c.getKey()).getVersion()); } finally { store1.close(); store2.close(); From e82efc214ae3bae99b19d6933e8f8e2ffe17d861 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Nov 2018 11:24:36 -0800 Subject: [PATCH 085/156] auto-discovery of imports and exports for shaded jars --- build.gradle | 131 +++++++++++++++++++++++++-------------------------- 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/build.gradle b/build.gradle index 197972ee4..2a42e2b85 100644 --- a/build.gradle +++ b/build.gradle @@ -32,34 +32,23 @@ ext { ext.libraries = [:] -ext.imports = [ - commons_codec: [ version: "1.10" ], - guava: [ version: "19.0" ], - joda_time: [ version: "2.9.3" ], - okhttp_eventsource: [ version: "1.7.1" ], - snakeyaml: [ version: "1.19" ], - jedis: [ version: "2.9.0" ], - gson: [ version: "2.7", limit: "3" ], - slf4j_api: [ version: "1.7.21", limit: "2" ] -] - // Add dependencies to "libraries.internal" that are not exposed in our public API. These // will be completely omitted from the "thin" jar, and will be embedded with shaded names // in the other two SDK jars. libraries.internal = [ - "commons-codec:commons-codec:" + imports.commons_codec.version, - "com.google.guava:guava:" + imports.guava.version, - "joda-time:joda-time:" + imports.joda_time.version, - "com.launchdarkly:okhttp-eventsource:" + imports.okhttp_eventsource.version, - "org.yaml:snakeyaml:" + imports.snakeyaml.version, - "redis.clients:jedis:" + imports.jedis.version + "commons-codec:commons-codec:1.10", + "com.google.guava:guava:19.0", + "joda-time:joda-time:2.9.3", + "com.launchdarkly:okhttp-eventsource:1.7.1", + "org.yaml:snakeyaml:1.19", + "redis.clients:jedis:2.9.0" ] // Add dependencies to "libraries.external" that are exposed in our public API, or that have // global state that must be shared between the SDK and the caller. libraries.external = [ - "com.google.code.gson:gson:" + imports.gson.version, - "org.slf4j:slf4j-api:" + imports.slf4j_api.version + "com.google.code.gson:gson:2.7", + "org.slf4j:slf4j-api:1.7.21" ] // Add dependencies to "libraries.test" that are used only in unit tests. @@ -190,66 +179,78 @@ def shadeDependencies(jarTask) { // any tasks, meaning we wouldn't have any build products yet to inspect. So we'll do that // configuration step at the last minute after the compile task has executed. compileJava.doLast { + def externalDependencies = configurations.shadow + shadeDependencies(project.tasks.shadowJar) + // Note that "configurations.shadow" is the same as "libraries.external", except it contains + // objects with detailed information about the resolved dependencies. + addOsgiManifest(project.tasks.shadowJar, [ externalDependencies ], []) + shadeDependencies(project.tasks.shadowJarAll) + // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the + // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a + // higher version if one is provided by another bundle. + addOsgiManifest(project.tasks.shadowJarAll, [ externalDependencies ], [ externalDependencies ]) } -shadowJar { - baseName = sdkBaseName - - // No classifier means that the shaded jar becomes the default artifact - classifier = '' - - // Don't include slf4j or gson. This is the only difference between this artifact - // and shadowJarAll, which does include (but doesn't shade) slf4j and gson. - dependencies{ - exclude(dependency('org.slf4j:.*:.*')) - exclude(dependency('com.google.code.gson:.*:.*')) - } - - manifest { - attributes("Implementation-Version": version) +def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { + jarTask.manifest { + attributes( + "Implementation-Version": version, + "Bundle-SymbolicName": "com.launchdarkly.client", + "Bundle-Version": version, + "Bundle-Name": "launchdarkly-client", + "Bundle-ManifestVersion": "2" + ) + + def imports = importConfigs.collectMany { it.resolvedConfiguration.resolvedArtifacts } + .collectMany { a -> + getPackagesInDependencyJar(a.file).collect { p -> + bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) + } + } + attributes("Import-Package": imports.join(",")) - attributes(osgiBaseAttributes()) - attributes(osgiImports()) - attributes("Export-Package": [ - bundleExport("com.launchdarkly.client"), - bundleExport("com.launchdarkly.client.files") - ].join(",")) + def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } + def exportedDependencies = exportConfigs.collectMany { it.resolvedConfiguration.resolvedArtifacts } + .collectMany { a -> + getPackagesInDependencyJar(a.file).collect { p -> + bundleExport(p, a.moduleVersion.id.version) + } + } + attributes("Export-Package": (sdkExports + exportedDependencies).join(",")) } } -def osgiBaseAttributes() { - [ - "Bundle-SymbolicName": "com.launchdarkly.client", - "Bundle-Version": version, - "Bundle-Name": "launchdarkly-client", - "Bundle-ManifestVersion": "2" - ] +def bundleImport(packageName, importVersion, versionLimit) { + packageName + ";version=\"[" + importVersion + "," + versionLimit + ")\"" } -def osgiImports() { - [ - "Import-Package": [ - bundleImport("com.google.gson", imports.gson), - bundleImport("com.google.gson.annotations", imports.gson), - bundleImport("com.google.gson.reflect", imports.gson), - bundleImport("com.google.gson.stream", imports.gson), - bundleImport("org.slf4j", imports.slf4j_api) - ].join(",") - ] +def bundleExport(packageName, exportVersion) { + packageName + ";version=\"" + exportVersion + "\"" } -def bundleExport(packageName) { - packageName + ";version=\"" + version + "\"" +def nextMajorVersion(v) { + def majorComponent = v.contains('.') ? v.substring(0, v.indexOf('.')) : v; + String.valueOf(Integer.parseInt(majorComponent) + 1) } -def bundleImport(packageName, importDef) { - packageName + ";version=\"[" + importDef.version + "," + importDef.limit + ")\"" +// This builds the default uberjar that contains all of our dependencies except Gson and +// SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. +shadowJar { + baseName = sdkBaseName + + // No classifier means that the shaded jar becomes the default artifact + classifier = '' + + dependencies{ + exclude(dependency('org.slf4j:.*:.*')) + exclude(dependency('com.google.code.gson:.*:.*')) + } } -// This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, -// Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. +// This builds the "-all"/"fat" jar, which is the same as the default uberjar except that +// Gson and SLF4j are bundled and exposed (unshaded). task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { baseName = sdkBaseName classifier = 'all' @@ -258,10 +259,6 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - - manifest { - attributes("Implementation-Version": version) - } } artifacts { From 4a26f930ae280bf348e431ecdb4508ae209e3a95 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Nov 2018 11:48:25 -0800 Subject: [PATCH 086/156] add OSGi manifest to thin jar + misc refactoring --- build.gradle | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/build.gradle b/build.gradle index 2a42e2b85..5544dd14e 100644 --- a/build.gradle +++ b/build.gradle @@ -76,9 +76,6 @@ jar { // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' - manifest { - attributes("Implementation-Version": version) - } } task wrapper(type: Wrapper) { @@ -181,6 +178,9 @@ def shadeDependencies(jarTask) { compileJava.doLast { def externalDependencies = configurations.shadow + // In OSGi, the "thin" jar has to import all of its dependencies. + addOsgiManifest(project.tasks.jar, [ externalDependencies, configurations.runtime ], []) + shadeDependencies(project.tasks.shadowJar) // Note that "configurations.shadow" is the same as "libraries.external", except it contains // objects with detailed information about the resolved dependencies. @@ -203,25 +203,26 @@ def addOsgiManifest(jarTask, List importConfigs, List - getPackagesInDependencyJar(a.file).collect { p -> - bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) - } - } + def imports = forEachArtifactAndPackage(importConfigs, { a, p -> + bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) + }) attributes("Import-Package": imports.join(",")) def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } - def exportedDependencies = exportConfigs.collectMany { it.resolvedConfiguration.resolvedArtifacts } - .collectMany { a -> - getPackagesInDependencyJar(a.file).collect { p -> - bundleExport(p, a.moduleVersion.id.version) - } - } + def exportedDependencies = forEachArtifactAndPackage(exportConfigs, { a, p -> + bundleExport(p, a.moduleVersion.id.version) + }) attributes("Export-Package": (sdkExports + exportedDependencies).join(",")) } } +def forEachArtifactAndPackage(configs, closure) { + configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } + .collectMany { a -> + getPackagesInDependencyJar(a.file).collect { p -> closure(a, p) } + } +} + def bundleImport(packageName, importVersion, versionLimit) { packageName + ";version=\"[" + importVersion + "," + versionLimit + ")\"" } From a997086797b436132e12560bfd236fcd73a62ba9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Nov 2018 11:48:50 -0800 Subject: [PATCH 087/156] describe jar distributions --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a7c515ac0..62f199697 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,30 @@ Supported Java versions This version of the LaunchDarkly SDK works with Java 7 and above. +Distributions +------------- + +Three variants of the SDK jar are published to Maven: + +* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. +* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading (and are exported in OSGi). +* The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. + Quick setup ----------- 0. Add the Java SDK to your project + com.launchdarkly launchdarkly-client 4.5.1 + // or in Gradle: + "com.launchdarkly:launchdarkly-client:4.5.1" + 1. Import the LaunchDarkly package: import com.launchdarkly.client.*; @@ -44,9 +57,9 @@ Your first feature flag // the code to run if the feature is off } - Logging ------- + The LaunchDarkly SDK uses [SLF4J](https://www.slf4j.org/). All loggers are namespaced under `com.launchdarkly`. For an example configuration check out the [hello-java](https://github.com/launchdarkly/hello-java) project. Be aware of two considerations when enabling the DEBUG log level: @@ -55,6 +68,7 @@ Be aware of two considerations when enabling the DEBUG log level: Using flag data from a file --------------------------- + For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. Learn more From 67cf59ba2cbf036871a9147a3f8319fdbadbc8af Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Nov 2018 11:49:40 -0800 Subject: [PATCH 088/156] update other reference to version in readme --- scripts/release.sh | 10 +--------- scripts/update-version.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) create mode 100755 scripts/update-version.sh diff --git a/scripts/release.sh b/scripts/release.sh index e9afb20e7..a8a435ed4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -11,15 +11,7 @@ set -uxe echo "Starting java-client release." -VERSION=$1 - -# Update version in gradle.properties file: -sed -i.bak "s/^version.*$/version=${VERSION}/" gradle.properties -rm -f gradle.properties.bak - -# Update version in README.md: -sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md -rm -f README.md.bak +$(dirname $0)/update-version.sh $1 ./gradlew clean publish closeAndReleaseRepository ./gradlew publishGhPages diff --git a/scripts/update-version.sh b/scripts/update-version.sh new file mode 100755 index 000000000..028bfd3be --- /dev/null +++ b/scripts/update-version.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +VERSION=$1 + +# Update version in gradle.properties file: +sed -i.bak "s/^version.*$/version=${VERSION}/" gradle.properties +rm -f gradle.properties.bak + +# Update version in README.md: +sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md +sed -i.bak "s/\"com.launchdarkly:launchdarkly-client:.*\"/\"com.launchdarkly:launchdarkly-client:${VERSION}\"/" README.md +rm -f README.md.bak From f7f0d9a0cbbf96c0e15ca37f39fc606d415c659a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Nov 2018 11:55:30 -0800 Subject: [PATCH 089/156] rm Twisted --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 62f199697..59e0068d5 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,6 @@ About LaunchDarkly * [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK") * [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK") * [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK") - * [Python Twisted](http://docs.launchdarkly.com/docs/python-twisted-sdk-reference "LaunchDarkly Python Twisted SDK") * [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK") * [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK") * [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK") From e66c59018d07a95dc5fd26e30e23373dc8e73bff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 14:55:03 -0800 Subject: [PATCH 090/156] misc fixes to jar configuration --- build.gradle | 58 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/build.gradle b/build.gradle index 5544dd14e..7a76a6d08 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,8 @@ allprojects { ext { sdkBasePackage = "com.launchdarkly.client" sdkBaseName = "launchdarkly-client" + + systemPackageImports = [ "javax.net", "javax.net.ssl" ] } ext.libraries = [:] @@ -76,6 +78,13 @@ jar { // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' + + // doFirst causes the following step to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because it accesses the build products + doFirst { + // In OSGi, the "thin" jar has to import all of its dependencies. + addOsgiManifest(project.tasks.jar, [ configurations.runtime ], []) + } } task wrapper(type: Wrapper) { @@ -171,28 +180,6 @@ def shadeDependencies(jarTask) { } } -// We can't actually call shadeDependencies from within the configuration section of shadowJar -// or shadowJarAll, because Groovy executes all the configuration sections before executing -// any tasks, meaning we wouldn't have any build products yet to inspect. So we'll do that -// configuration step at the last minute after the compile task has executed. -compileJava.doLast { - def externalDependencies = configurations.shadow - - // In OSGi, the "thin" jar has to import all of its dependencies. - addOsgiManifest(project.tasks.jar, [ externalDependencies, configurations.runtime ], []) - - shadeDependencies(project.tasks.shadowJar) - // Note that "configurations.shadow" is the same as "libraries.external", except it contains - // objects with detailed information about the resolved dependencies. - addOsgiManifest(project.tasks.shadowJar, [ externalDependencies ], []) - - shadeDependencies(project.tasks.shadowJarAll) - // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the - // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a - // higher version if one is provided by another bundle. - addOsgiManifest(project.tasks.shadowJarAll, [ externalDependencies ], [ externalDependencies ]) -} - def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { jarTask.manifest { attributes( @@ -205,7 +192,7 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) - }) + }) + systemPackageImports attributes("Import-Package": imports.join(",")) def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } @@ -219,7 +206,9 @@ def addOsgiManifest(jarTask, List importConfigs, List - getPackagesInDependencyJar(a.file).collect { p -> closure(a, p) } + getPackagesInDependencyJar(a.file) + .findAll { p -> !p.contains(".internal") } + .collect { p -> closure(a, p) } } } @@ -244,10 +233,19 @@ shadowJar { // No classifier means that the shaded jar becomes the default artifact classifier = '' - dependencies{ + dependencies { exclude(dependency('org.slf4j:.*:.*')) exclude(dependency('com.google.code.gson:.*:.*')) } + + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJar) + // Note that "configurations.shadow" is the same as "libraries.external", except it contains + // objects with detailed information about the resolved dependencies. + addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) + } } // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that @@ -260,6 +258,16 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJarAll) + // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the + // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a + // higher version if one is provided by another bundle. + addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) + } } artifacts { From 9e33f90bf9d6240ace2445c81e765a35db967ae2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 14:55:20 -0800 Subject: [PATCH 091/156] add CI job to verify that we can build the jars --- .circleci/config.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 20020fefa..df9473843 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,6 +19,16 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit + - persist_to_workspace: + root: build + packaging: + docker: + - image: circleci/java + steps: + - checkout + - attach_workspace: + at: build + - run: ./gradlew publishToMavenLocal fossa: docker: - image: circleci/java @@ -38,6 +48,9 @@ workflows: branches: ignore: - gh-pages + - packaging: + requires: + - build - fossa: filters: branches: From 45151991dbf3cfec9620298ddd527f740ceb2f8d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 14:57:05 -0800 Subject: [PATCH 092/156] CI fix --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index df9473843..10077c685 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,8 @@ jobs: path: ~/junit - persist_to_workspace: root: build + paths: + - libs packaging: docker: - image: circleci/java From da41de73de35594b578c2658b33a12d277321b0d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:01:02 -0800 Subject: [PATCH 093/156] CI fix --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 10077c685..578e7d165 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ jobs: - persist_to_workspace: root: build paths: - - libs + - classes packaging: docker: - image: circleci/java From d00d59ebc845fcb969b11d4cc880a17f67a5aa0e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:05:30 -0800 Subject: [PATCH 094/156] CI fix --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 578e7d165..1e7cb5cbf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,10 @@ jobs: - checkout - attach_workspace: at: build - - run: ./gradlew publishToMavenLocal + - run: cat gradle.properties.example >>gradle.properties + - run: + name: build all SDK jars + comand: ./gradlew publishToMavenLocal fossa: docker: - image: circleci/java From ead2f9be50d3a9d74055068c1867f20c0d5c432a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:07:13 -0800 Subject: [PATCH 095/156] CI fix --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1e7cb5cbf..720c9207c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ jobs: - run: cat gradle.properties.example >>gradle.properties - run: name: build all SDK jars - comand: ./gradlew publishToMavenLocal + command: ./gradlew publishToMavenLocal fossa: docker: - image: circleci/java From 38255ca9d908ffb9c07f4094c248bb36c0980883 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:14:46 -0800 Subject: [PATCH 096/156] skip signing jars in CI --- .circleci/config.yml | 6 +++++- build.gradle | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 720c9207c..d1f418394 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ jobs: - run: cat gradle.properties.example >>gradle.properties - run: name: build all SDK jars - command: ./gradlew publishToMavenLocal + command: ./gradlew publishToMavenLocal -P LD_SKIP_SIGNING=1 fossa: docker: - image: circleci/java @@ -56,6 +56,10 @@ workflows: - packaging: requires: - build + filters: + branches: + ignore: + - gh-pages - fossa: filters: branches: diff --git a/build.gradle b/build.gradle index 7a76a6d08..43f25ad67 100644 --- a/build.gradle +++ b/build.gradle @@ -355,5 +355,7 @@ publishing { } signing { + required { !project.hasProperty("LD_SKIP_SIGNING") } + sign publishing.publications.shadow } From 6a0871d49579544d86f298fecc3f033fbb2663ff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:42:44 -0800 Subject: [PATCH 097/156] CI fix --- build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 43f25ad67..ac244ff4d 100644 --- a/build.gradle +++ b/build.gradle @@ -355,7 +355,9 @@ publishing { } signing { - required { !project.hasProperty("LD_SKIP_SIGNING") } - sign publishing.publications.shadow } + +tasks.withType(Sign) { + onlyIf(!project.hasProperty("LD_SKIP_SIGNING")) // so we can build jars for testing in CI +} From c0be7b269a667c5c46a525cb57a8428012fd91d5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 15:43:46 -0800 Subject: [PATCH 098/156] CI fix --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ac244ff4d..dbf83009b 100644 --- a/build.gradle +++ b/build.gradle @@ -359,5 +359,5 @@ signing { } tasks.withType(Sign) { - onlyIf(!project.hasProperty("LD_SKIP_SIGNING")) // so we can build jars for testing in CI + onlyIf { !project.hasProperty("LD_SKIP_SIGNING") } // so we can build jars for testing in CI } From 6aa4fde851ad299618ba7dd26073ab235d045102 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 16:39:18 -0800 Subject: [PATCH 099/156] add basic packaging tests --- .circleci/config.yml | 6 +++++ .gitignore | 4 +++- packaging-test/Makefile | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packaging-test/Makefile diff --git a/.circleci/config.yml b/.circleci/config.yml index d1f418394..7317fcb65 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,6 @@ version: 2 jobs: + build: docker: - image: circleci/java @@ -23,6 +24,7 @@ jobs: root: build paths: - classes + packaging: docker: - image: circleci/java @@ -34,6 +36,10 @@ jobs: - run: name: build all SDK jars command: ./gradlew publishToMavenLocal -P LD_SKIP_SIGNING=1 + - run: + name: run packaging tests + command: cd packaging-test && make all + fossa: docker: - image: circleci/java diff --git a/.gitignore b/.gitignore index e368b8cda..de40ed7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ build/ bin/ out/ -classes/ \ No newline at end of file +classes/ + +packaging-test/temp/ diff --git a/packaging-test/Makefile b/packaging-test/Makefile new file mode 100644 index 000000000..d25e48e02 --- /dev/null +++ b/packaging-test/Makefile @@ -0,0 +1,53 @@ +.PHONY: all test-all-jar test-default-jar test-thin-jar + +BASE_DIR:=$(shell pwd) +PROJECT_DIR=$(shell cd .. && pwd) +SDK_VERSION=$(shell grep "version=" $(PROJECT_DIR)/gradle.properties | cut -d '=' -f 2) + +TEMP_DIR=$(BASE_DIR)/temp +SDK_JARS_DIR=$(PROJECT_DIR)/build/libs +SDK_DEFAULT_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION).jar +SDK_ALL_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION)-all.jar +SDK_THIN_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION)-thin.jar + +TEMP_CLASSES=$(TEMP_DIR)/temp-classes +classes_prepare=echo "checking $(1)..." && jar tf $(1) | grep '\.class$$' >$(TEMP_CLASSES) +classes_should_contain=echo " should contain $(2)" && grep $(1) $(TEMP_CLASSES) >/dev/null +classes_should_not_contain=echo " should not contain $(2)" && ! grep $(1) $(TEMP_CLASSES) >/dev/null + +all: test-all-jar-classes test-default-jar-classes test-thin-jar-classes + +test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) + @$(call classes_prepare,$<) + @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call classes_should_contain,'^com/google/gson/',Gson (unshaded)) + @$(call classes_should_contain,'^org/slf4j/',SLF4j (unshaded)) + @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) + @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/client',shaded SDK classes) + @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) + @$(call classes_should_not_contain,'^com/launchdarkly/shaded/org/slf4j',shaded SLF4j) + +test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) + @$(call classes_prepare,$<) + @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) + @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/client',shaded SDK classes) + @$(call classes_should_not_contain,'com/google/gson/',Gson (shaded or unshaded)) + @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) + +test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) + @$(call classes_prepare,$<) + @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call classes_should_not_contain,-v '^com/launchdarkly/client/',anything other than SDK classes) + +$(SDK_DEFAULT_JAR): + cd .. && ./gradlew shadowJar + +$(SDK_ALL_JAR): + cd .. && ./gradlew shadowJarAll + +$(SDK_THIN_JAR): + cd .. && ./gradlew jar + +$(TEMP_DIR): + [ -d $@ ] || mkdir -p $@ From 25a8f8be979d244f1f53b3aa69784385dcbd59bf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 16:44:07 -0800 Subject: [PATCH 100/156] install make --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7317fcb65..3e37ca009 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,6 +29,7 @@ jobs: docker: - image: circleci/java steps: + - run: sudo apt-get install make -y -q - checkout - attach_workspace: at: build From f18c50b118dcf01978804b37bbe890a27683e879 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 17:24:21 -0800 Subject: [PATCH 101/156] verify that every subpackage in the SDK source code exists in the jars --- packaging-test/Makefile | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index d25e48e02..5c701572f 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -15,11 +15,18 @@ classes_prepare=echo "checking $(1)..." && jar tf $(1) | grep '\.class$$' >$(TEM classes_should_contain=echo " should contain $(2)" && grep $(1) $(TEMP_CLASSES) >/dev/null classes_should_not_contain=echo " should not contain $(2)" && ! grep $(1) $(TEMP_CLASSES) >/dev/null +verify_sdk_classes= \ + $(call classes_should_contain,'^com/launchdarkly/client/[^/]*$$',com.launchdarkly.client) && \ + $(foreach subpkg,$(sdk_subpackage_names), \ + $(call classes_should_contain,'^com/launchdarkly/client/$(subpkg)/',com.launchdarkly.client.$(subpkg)) && ) true +sdk_subpackage_names= \ + $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/client/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') + all: test-all-jar-classes test-default-jar-classes test-thin-jar-classes test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call classes_prepare,$<) - @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/google/gson/',Gson (unshaded)) @$(call classes_should_contain,'^org/slf4j/',SLF4j (unshaded)) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) @@ -29,7 +36,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_prepare,$<) - @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/client',shaded SDK classes) @$(call classes_should_not_contain,'com/google/gson/',Gson (shaded or unshaded)) @@ -37,7 +44,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @$(call classes_prepare,$<) - @$(call classes_should_contain,'^com/launchdarkly/client/',SDK classes) + @$(call verify_sdk_classes) @$(call classes_should_not_contain,-v '^com/launchdarkly/client/',anything other than SDK classes) $(SDK_DEFAULT_JAR): From 5f6d9bee454f42e97140e50b5de59df2ce991229 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 17:31:29 -0800 Subject: [PATCH 102/156] remove OSGi support for now --- README.md | 4 +-- build.gradle | 89 +++++++++++----------------------------------------- 2 files changed, 20 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 59e0068d5..a8b69ecc1 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Distributions Three variants of the SDK jar are published to Maven: -* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. -* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading (and are exported in OSGi). +* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names, so they will not interfere with any other versions of the same packages. +* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading. * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. Quick setup diff --git a/build.gradle b/build.gradle index dbf83009b..3dc8984e6 100644 --- a/build.gradle +++ b/build.gradle @@ -28,8 +28,6 @@ allprojects { ext { sdkBasePackage = "com.launchdarkly.client" sdkBaseName = "launchdarkly-client" - - systemPackageImports = [ "javax.net", "javax.net.ssl" ] } ext.libraries = [:] @@ -78,12 +76,8 @@ jar { // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' - - // doFirst causes the following step to be run during Gradle's execution phase rather than the - // configuration phase; this is necessary because it accesses the build products - doFirst { - // In OSGi, the "thin" jar has to import all of its dependencies. - addOsgiManifest(project.tasks.jar, [ configurations.runtime ], []) + manifest { + attributes("Implementation-Version": version) } } @@ -180,76 +174,35 @@ def shadeDependencies(jarTask) { } } -def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { - jarTask.manifest { - attributes( - "Implementation-Version": version, - "Bundle-SymbolicName": "com.launchdarkly.client", - "Bundle-Version": version, - "Bundle-Name": "launchdarkly-client", - "Bundle-ManifestVersion": "2" - ) - - def imports = forEachArtifactAndPackage(importConfigs, { a, p -> - bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) - }) + systemPackageImports - attributes("Import-Package": imports.join(",")) - - def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } - def exportedDependencies = forEachArtifactAndPackage(exportConfigs, { a, p -> - bundleExport(p, a.moduleVersion.id.version) - }) - attributes("Export-Package": (sdkExports + exportedDependencies).join(",")) - } +// We can't actually call shadeDependencies from within the configuration section of shadowJar +// or shadowJarAll, because Groovy executes all the configuration sections before executing +// any tasks, meaning we wouldn't have any build products yet to inspect. So we'll do that +// configuration step at the last minute after the compile task has executed. +compileJava.doLast { + shadeDependencies(project.tasks.shadowJar) + shadeDependencies(project.tasks.shadowJarAll) } -def forEachArtifactAndPackage(configs, closure) { - configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } - .collectMany { a -> - getPackagesInDependencyJar(a.file) - .findAll { p -> !p.contains(".internal") } - .collect { p -> closure(a, p) } - } -} - -def bundleImport(packageName, importVersion, versionLimit) { - packageName + ";version=\"[" + importVersion + "," + versionLimit + ")\"" -} - -def bundleExport(packageName, exportVersion) { - packageName + ";version=\"" + exportVersion + "\"" -} - -def nextMajorVersion(v) { - def majorComponent = v.contains('.') ? v.substring(0, v.indexOf('.')) : v; - String.valueOf(Integer.parseInt(majorComponent) + 1) -} - -// This builds the default uberjar that contains all of our dependencies except Gson and -// SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. shadowJar { baseName = sdkBaseName // No classifier means that the shaded jar becomes the default artifact classifier = '' - dependencies { + // Don't include slf4j or gson. This is the only difference between this artifact + // and shadowJarAll, which does include (but doesn't shade) slf4j and gson. + dependencies{ exclude(dependency('org.slf4j:.*:.*')) exclude(dependency('com.google.code.gson:.*:.*')) } - // doFirst causes the following steps to be run during Gradle's execution phase rather than the - // configuration phase; this is necessary because they access the build products - doFirst { - shadeDependencies(project.tasks.shadowJar) - // Note that "configurations.shadow" is the same as "libraries.external", except it contains - // objects with detailed information about the resolved dependencies. - addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) + manifest { + attributes("Implementation-Version": version) } } -// This builds the "-all"/"fat" jar, which is the same as the default uberjar except that -// Gson and SLF4j are bundled and exposed (unshaded). +// This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, +// Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { baseName = sdkBaseName classifier = 'all' @@ -259,14 +212,8 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - // doFirst causes the following steps to be run during Gradle's execution phase rather than the - // configuration phase; this is necessary because they access the build products - doFirst { - shadeDependencies(project.tasks.shadowJarAll) - // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the - // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a - // higher version if one is provided by another bundle. - addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) + manifest { + attributes("Implementation-Version": version) } } From cc3b7b18ff226ceb1abdfc1c947cbc182f7dd70c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 17:35:09 -0800 Subject: [PATCH 103/156] Revert "remove OSGi support for now" This reverts commit 5f6d9bee454f42e97140e50b5de59df2ce991229. --- README.md | 4 +-- build.gradle | 89 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a8b69ecc1..59e0068d5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Distributions Three variants of the SDK jar are published to Maven: -* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names, so they will not interfere with any other versions of the same packages. -* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading. +* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. +* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading (and are exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. Quick setup diff --git a/build.gradle b/build.gradle index 3dc8984e6..dbf83009b 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,8 @@ allprojects { ext { sdkBasePackage = "com.launchdarkly.client" sdkBaseName = "launchdarkly-client" + + systemPackageImports = [ "javax.net", "javax.net.ssl" ] } ext.libraries = [:] @@ -76,8 +78,12 @@ jar { // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' - manifest { - attributes("Implementation-Version": version) + + // doFirst causes the following step to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because it accesses the build products + doFirst { + // In OSGi, the "thin" jar has to import all of its dependencies. + addOsgiManifest(project.tasks.jar, [ configurations.runtime ], []) } } @@ -174,35 +180,76 @@ def shadeDependencies(jarTask) { } } -// We can't actually call shadeDependencies from within the configuration section of shadowJar -// or shadowJarAll, because Groovy executes all the configuration sections before executing -// any tasks, meaning we wouldn't have any build products yet to inspect. So we'll do that -// configuration step at the last minute after the compile task has executed. -compileJava.doLast { - shadeDependencies(project.tasks.shadowJar) - shadeDependencies(project.tasks.shadowJarAll) +def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { + jarTask.manifest { + attributes( + "Implementation-Version": version, + "Bundle-SymbolicName": "com.launchdarkly.client", + "Bundle-Version": version, + "Bundle-Name": "launchdarkly-client", + "Bundle-ManifestVersion": "2" + ) + + def imports = forEachArtifactAndPackage(importConfigs, { a, p -> + bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) + }) + systemPackageImports + attributes("Import-Package": imports.join(",")) + + def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } + def exportedDependencies = forEachArtifactAndPackage(exportConfigs, { a, p -> + bundleExport(p, a.moduleVersion.id.version) + }) + attributes("Export-Package": (sdkExports + exportedDependencies).join(",")) + } } +def forEachArtifactAndPackage(configs, closure) { + configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } + .collectMany { a -> + getPackagesInDependencyJar(a.file) + .findAll { p -> !p.contains(".internal") } + .collect { p -> closure(a, p) } + } +} + +def bundleImport(packageName, importVersion, versionLimit) { + packageName + ";version=\"[" + importVersion + "," + versionLimit + ")\"" +} + +def bundleExport(packageName, exportVersion) { + packageName + ";version=\"" + exportVersion + "\"" +} + +def nextMajorVersion(v) { + def majorComponent = v.contains('.') ? v.substring(0, v.indexOf('.')) : v; + String.valueOf(Integer.parseInt(majorComponent) + 1) +} + +// This builds the default uberjar that contains all of our dependencies except Gson and +// SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. shadowJar { baseName = sdkBaseName // No classifier means that the shaded jar becomes the default artifact classifier = '' - // Don't include slf4j or gson. This is the only difference between this artifact - // and shadowJarAll, which does include (but doesn't shade) slf4j and gson. - dependencies{ + dependencies { exclude(dependency('org.slf4j:.*:.*')) exclude(dependency('com.google.code.gson:.*:.*')) } - manifest { - attributes("Implementation-Version": version) + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJar) + // Note that "configurations.shadow" is the same as "libraries.external", except it contains + // objects with detailed information about the resolved dependencies. + addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) } } -// This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, -// Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. +// This builds the "-all"/"fat" jar, which is the same as the default uberjar except that +// Gson and SLF4j are bundled and exposed (unshaded). task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { baseName = sdkBaseName classifier = 'all' @@ -212,8 +259,14 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ configurations = [project.configurations.runtime] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - manifest { - attributes("Implementation-Version": version) + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJarAll) + // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the + // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a + // higher version if one is provided by another bundle. + addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) } } From ca324bf06528836a15401500d5db7f91b0e16910 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:35:47 -0800 Subject: [PATCH 104/156] misc improvements for OSGi build and testing --- build.gradle | 9 +- packaging-test/Makefile | 103 ++++++++++++++++-- packaging-test/test-app/build.gradle | 58 ++++++++++ .../src/main/java/testapp/TestApp.java | 13 +++ .../java/testapp/TestAppOsgiEntryPoint.java | 16 +++ 5 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 packaging-test/test-app/build.gradle create mode 100644 packaging-test/test-app/src/main/java/testapp/TestApp.java create mode 100644 packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java diff --git a/build.gradle b/build.gradle index dbf83009b..ac1ea8e3a 100644 --- a/build.gradle +++ b/build.gradle @@ -186,7 +186,7 @@ def addOsgiManifest(jarTask, List importConfigs, List$(TEMP_CLASSES) -classes_should_contain=echo " should contain $(2)" && grep $(1) $(TEMP_CLASSES) >/dev/null -classes_should_not_contain=echo " should not contain $(2)" && ! grep $(1) $(TEMP_CLASSES) >/dev/null +TEMP_DIR=$(BASE_DIR)/temp +TEMP_OUTPUT=$(TEMP_DIR)/test.out + +# Build product of the project in ./test-app; can be run as either a regular app or an OSGi bundle +TEST_APP_JAR=$(TEMP_DIR)/test-app.jar + +# SLF4j implementation - we need to download this separately because it's not in the SDK dependencies +SLF4J_SIMPLE_JAR=$(TEMP_DIR)/test-slf4j-simple.jar + +# Felix OSGi container +FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.1.tar.gz +FELIX_DIR=$(TEMP_DIR)/felix +FELIX_JAR=$(FELIX_DIR)/bin/felix.jar +TEMP_BUNDLE_DIR=$(TEMP_DIR)/bundles + +# Lists of jars to use as a classpath (for the non-OSGi runtime test) or to install as bundles (for +# the OSGi test). Note that we're assuming that all of the SDK's dependencies have built-in support +# for OSGi, which is currently true; if that weren't true, we would have to do something different +# to put them on the system classpath in the OSGi test. +RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) +RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) \ + $(SLF4J_SIMPLE_JAR) +RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ + $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar) \ + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) \ + $(SLF4J_SIMPLE_JAR) + +# The test-app displays this message on success +SUCCESS_MESSAGE="@@@ successfully created LD client @@@" + +classes_prepare=echo " checking $(1)..." && jar tf $(1) | grep '\.class$$' >$(TEMP_OUTPUT) +classes_should_contain=echo " should contain $(2)" && grep $(1) $(TEMP_OUTPUT) >/dev/null +classes_should_not_contain=echo " should not contain $(2)" && ! grep $(1) $(TEMP_OUTPUT) >/dev/null verify_sdk_classes= \ $(call classes_should_contain,'^com/launchdarkly/client/[^/]*$$',com.launchdarkly.client) && \ @@ -22,9 +60,31 @@ verify_sdk_classes= \ sdk_subpackage_names= \ $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/client/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') -all: test-all-jar-classes test-default-jar-classes test-thin-jar-classes +caption=echo "" && echo "$(1)" + +all: test-all-jar test-default-jar test-thin-jar + +clean: + rm -rf $(TEMP_DIR)/* + +# SECONDEXPANSION is necessary so we can use "$@" inside a variable in the target list of the test targets +.SECONDEXPANSION: + +test-all-jar test-default-jar test-thin-jar: $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_JAR) get-sdk-dependencies $$@-classes + @$(call caption,$@) + @echo " non-OSGi runtime test" + @java -classpath $(shell echo "$(RUN_JARS_$@)" | sed -e 's/ /:/g') testapp.TestApp | tee $(TEMP_OUTPUT) + @grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null + @echo "" + @echo " OSGi runtime test" + @rm -rf $(TEMP_BUNDLE_DIR) + @mkdir -p $(TEMP_BUNDLE_DIR) + @cp $(RUN_JARS_$@) $(FELIX_DIR)/bundle/*.jar $(TEMP_BUNDLE_DIR) + @cd $(FELIX_DIR) && echo "sleep 5;exit 0" | java -jar $(FELIX_JAR) -b $(TEMP_BUNDLE_DIR) | tee $(TEMP_OUTPUT) + @grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) + @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/google/gson/',Gson (unshaded)) @@ -35,6 +95,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,'^com/launchdarkly/shaded/org/slf4j',shaded SLF4j) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) + @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) @@ -43,6 +104,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) + @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) @$(call classes_should_not_contain,-v '^com/launchdarkly/client/',anything other than SDK classes) @@ -56,5 +118,32 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar +$(TEST_APP_JAR): + cd test-app && ../../gradlew jar + cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ + +get-sdk-dependencies: $(TEMP_DIR)/dependencies-all $(TEMP_DIR)/dependencies-external $(TEMP_DIR)/dependencies-internal + +$(TEMP_DIR)/dependencies-all: | $(TEMP_DIR) + [ -d $@ ] || mkdir -p $@ + cd .. && ./gradlew exportDependencies + +$(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all + [ -d $@ ] || mkdir -p $@ + cp $(TEMP_DIR)/dependencies-all/gson*.jar $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + +$(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all + [ -d $@ ] || mkdir -p $@ + cp $(TEMP_DIR)/dependencies-all/*.jar $@ + rm $@/gson*.jar $@/slf4j*.jar + +$(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) + curl https://oss.sonatype.org/content/groups/public/org/slf4j/slf4j-simple/1.7.21/slf4j-simple-1.7.21.jar >$@ + +$(FELIX_JAR): | $(TEMP_DIR) + curl http://ftp.naz.com/apache//felix/$(FELIX_ARCHIVE) >$(TEMP_DIR)/$(FELIX_ARCHIVE) + cd $(TEMP_DIR) && tar xfz $(FELIX_ARCHIVE) && rm $(FELIX_ARCHIVE) + cd $(TEMP_DIR) && mv `ls -d felix*` felix + $(TEMP_DIR): [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle new file mode 100644 index 000000000..e27bcc942 --- /dev/null +++ b/packaging-test/test-app/build.gradle @@ -0,0 +1,58 @@ +apply plugin: 'java' +apply plugin: 'osgi' + +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +repositories { + mavenLocal() + mavenCentral() +} + +allprojects { + group = 'com.launchdarkly' + version = "1.0.0" + sourceCompatibility = 1.7 + targetCompatibility = 1.7 +} + +ext.libraries = [:] + +libraries.internal = [ +] + +libraries.external = [ + "com.launchdarkly:launchdarkly-client:4.+", + "com.google.code.gson:gson:2.7", + "org.slf4j:slf4j-api:1.7.21", + "org.osgi:osgi_R4_core:1.0" +] + +dependencies { + compileClasspath libraries.external +} + +jar { + baseName = 'test-app-bundle' + manifest { + instruction 'Bundle-Activator', 'testapp.TestAppOsgiEntryPoint' + } +} + +task wrapper(type: Wrapper) { + gradleVersion = '4.10.2' +} + +buildscript { + repositories { + jcenter() + mavenCentral() + mavenLocal() + } + dependencies { + classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' + classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.8.0" + } +} diff --git a/packaging-test/test-app/src/main/java/testapp/TestApp.java b/packaging-test/test-app/src/main/java/testapp/TestApp.java new file mode 100644 index 000000000..4f4398956 --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -0,0 +1,13 @@ +package testapp; + +import com.launchdarkly.client.*; + +public class TestApp { + public static void main(String[] args) throws Exception { + LDConfig config = new LDConfig.Builder() + .offline(true) + .build(); + LDClient client = new LDClient("fake-sdk-key", config); + System.out.println("@@@ successfully created LD client @@@"); + } +} \ No newline at end of file diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java new file mode 100644 index 000000000..f1a9db3ad --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -0,0 +1,16 @@ +package testapp; + +import org.osgi.framework.BundleActivator; +import org.osgi.framework.BundleContext; + +public class TestAppOsgiEntryPoint implements BundleActivator { + public void start(BundleContext context) throws Exception { + System.out.println("@@@ starting test bundle @@@"); + + TestApp.main(new String[0]); + } + + public void stop(BundleContext context) throws Exception { + System.out.println("@@@ stopping test bundle @@@"); + } +} \ No newline at end of file From 7ac732006f50d5272ce3f9f640fb4c61b9e1f835 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:39:31 -0800 Subject: [PATCH 105/156] ensure that temp dir is created --- packaging-test/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 5b17d2310..7c94a4e63 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -118,7 +118,7 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar -$(TEST_APP_JAR): +$(TEST_APP_JAR): | $(TEMP_DIR) cd test-app && ../../gradlew jar cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ From 9cfda83e33e73699cdd2cd0224b9f2cafb35845c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:49:09 -0800 Subject: [PATCH 106/156] comment --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index ac1ea8e3a..86995c103 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ ext { sdkBasePackage = "com.launchdarkly.client" sdkBaseName = "launchdarkly-client" + // List any packages here that should be included in OSGi imports for the SDK, if they cannot + // be discovered by looking in our explicit dependencies. systemPackageImports = [ "javax.net", "javax.net.ssl" ] } From 50dd76a767555dc9b62d18f9de3e413db568d274 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:51:44 -0800 Subject: [PATCH 107/156] add vendor attribute --- build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 86995c103..bb8946eab 100644 --- a/build.gradle +++ b/build.gradle @@ -189,7 +189,8 @@ def addOsgiManifest(jarTask, List importConfigs, List From 8caab84ecbaa3414f6d52469b62b509b9814c0a7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:56:13 -0800 Subject: [PATCH 108/156] misc reorganization + comments --- build.gradle | 121 +++++++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/build.gradle b/build.gradle index bb8946eab..4299fe1ac 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,23 @@ dependencies { shadow libraries.external } +task wrapper(type: Wrapper) { + gradleVersion = '4.10.2' +} + +buildscript { + repositories { + jcenter() + mavenCentral() + mavenLocal() + } + dependencies { + classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' + classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' + classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.8.0" + } +} + jar { baseName = sdkBaseName // thin classifier means that the non-shaded non-fat jar is still available @@ -89,20 +106,48 @@ jar { } } -task wrapper(type: Wrapper) { - gradleVersion = '4.10.2' -} +// This builds the default uberjar that contains all of our dependencies except Gson and +// SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. +shadowJar { + baseName = sdkBaseName + + // No classifier means that the shaded jar becomes the default artifact + classifier = '' -buildscript { - repositories { - jcenter() - mavenCentral() - mavenLocal() - } dependencies { - classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' - classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' - classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.8.0" + exclude(dependency('org.slf4j:.*:.*')) + exclude(dependency('com.google.code.gson:.*:.*')) + } + + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJar) + // Note that "configurations.shadow" is the same as "libraries.external", except it contains + // objects with detailed information about the resolved dependencies. + addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) + } +} + +// This builds the "-all"/"fat" jar, which is the same as the default uberjar except that +// Gson and SLF4j are bundled and exposed (unshaded). +task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + baseName = sdkBaseName + classifier = 'all' + group = "shadow" + description = "Builds a Shaded fat jar including SLF4J" + from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) + configurations = [project.configurations.runtime] + exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + + // doFirst causes the following steps to be run during Gradle's execution phase rather than the + // configuration phase; this is necessary because they access the build products + doFirst { + shadeDependencies(project.tasks.shadowJarAll) + // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the + // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a + // higher version if one is provided by another bundle. + addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) } } @@ -193,11 +238,18 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports attributes("Import-Package": imports.join(",")) + // Similarly, we're adding package exports for every package in whatever libraries we're + // making publicly available. def sdkExports = getAllSdkPackages().collect { bundleExport(it, version) } def exportedDependencies = forEachArtifactAndPackage(exportConfigs, { a, p -> bundleExport(p, a.moduleVersion.id.version) @@ -228,51 +280,6 @@ def nextMajorVersion(v) { String.valueOf(Integer.parseInt(majorComponent) + 1) } -// This builds the default uberjar that contains all of our dependencies except Gson and -// SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. -shadowJar { - baseName = sdkBaseName - - // No classifier means that the shaded jar becomes the default artifact - classifier = '' - - dependencies { - exclude(dependency('org.slf4j:.*:.*')) - exclude(dependency('com.google.code.gson:.*:.*')) - } - - // doFirst causes the following steps to be run during Gradle's execution phase rather than the - // configuration phase; this is necessary because they access the build products - doFirst { - shadeDependencies(project.tasks.shadowJar) - // Note that "configurations.shadow" is the same as "libraries.external", except it contains - // objects with detailed information about the resolved dependencies. - addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) - } -} - -// This builds the "-all"/"fat" jar, which is the same as the default uberjar except that -// Gson and SLF4j are bundled and exposed (unshaded). -task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { - baseName = sdkBaseName - classifier = 'all' - group = "shadow" - description = "Builds a Shaded fat jar including SLF4J" - from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) - configurations = [project.configurations.runtime] - exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') - - // doFirst causes the following steps to be run during Gradle's execution phase rather than the - // configuration phase; this is necessary because they access the build products - doFirst { - shadeDependencies(project.tasks.shadowJarAll) - // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the - // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a - // higher version if one is provided by another bundle. - addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) - } -} - artifacts { archives jar, sourcesJar, javadocJar, shadowJar, shadowJarAll } From 97caed8148162afeb0196a36f31977a1621f51b4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 20:58:40 -0800 Subject: [PATCH 109/156] comment --- packaging-test/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 7c94a4e63..0aa26000c 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -67,7 +67,7 @@ all: test-all-jar test-default-jar test-thin-jar clean: rm -rf $(TEMP_DIR)/* -# SECONDEXPANSION is necessary so we can use "$@" inside a variable in the target list of the test targets +# SECONDEXPANSION is needed so we can use "$@" inside a variable in the prerequisite list of the test targets .SECONDEXPANSION: test-all-jar test-default-jar test-thin-jar: $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_JAR) get-sdk-dependencies $$@-classes From 8f537304d7f8884ec148cb68f64764b353a93362 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 21:07:33 -0800 Subject: [PATCH 110/156] simplify test app build --- packaging-test/Makefile | 2 +- packaging-test/test-app/build.gradle | 35 +++++++--------------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 0aa26000c..462d38d7f 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -118,7 +118,7 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar -$(TEST_APP_JAR): | $(TEMP_DIR) +$(TEST_APP_JAR): $(SDK_THIN_JAR) | $(TEMP_DIR) cd test-app && ../../gradlew jar cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index e27bcc942..100bbaa0f 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -1,37 +1,23 @@ -apply plugin: 'java' -apply plugin: 'osgi' - -configurations.all { - // check for updates every build for dependencies with: 'changing: true' - resolutionStrategy.cacheChangingModulesFor 0, 'seconds' -} +apply plugin: "java" +apply plugin: "osgi" repositories { - mavenLocal() mavenCentral() } allprojects { - group = 'com.launchdarkly' + group = "com.launchdarkly" version = "1.0.0" sourceCompatibility = 1.7 targetCompatibility = 1.7 } -ext.libraries = [:] - -libraries.internal = [ -] - -libraries.external = [ - "com.launchdarkly:launchdarkly-client:4.+", - "com.google.code.gson:gson:2.7", - "org.slf4j:slf4j-api:1.7.21", - "org.osgi:osgi_R4_core:1.0" -] - dependencies { - compileClasspath libraries.external + // Note, the SDK build must have already been run before this, since we're using its product as a dependency + compileClasspath fileTree(dir: "../../build/libs", include: "launchdarkly-client-*-thin.jar") + compileClasspath "com.google.code.gson:gson:2.7" + compileClasspath "org.slf4j:slf4j-api:1.7.21" + compileClasspath "org.osgi:osgi_R4_core:1.0" } jar { @@ -49,10 +35,5 @@ buildscript { repositories { jcenter() mavenCentral() - mavenLocal() - } - dependencies { - classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' - classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.8.0" } } From 10c8264967abfd3152fa9344022e077abe577c03 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 23:02:20 -0800 Subject: [PATCH 111/156] improvements to OSGi test app --- .../test-app/src/main/java/testapp/TestApp.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packaging-test/test-app/src/main/java/testapp/TestApp.java b/packaging-test/test-app/src/main/java/testapp/TestApp.java index 4f4398956..f56f1207e 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,13 +1,23 @@ package testapp; import com.launchdarkly.client.*; +import com.google.gson.*; +import org.slf4j.*; public class TestApp { + private static final Logger logger = LoggerFactory.getLogger(TestApp.class); + public static void main(String[] args) throws Exception { LDConfig config = new LDConfig.Builder() .offline(true) .build(); LDClient client = new LDClient("fake-sdk-key", config); + + // The following line is just for the sake of referencing Gson, so we can be sure + // that it's on the classpath as it should be (i.e. if we're using the "all" jar + // that provides its own copy of Gson). + JsonPrimitive x = new JsonPrimitive("x"); + System.out.println("@@@ successfully created LD client @@@"); } } \ No newline at end of file From 88843a5fdd8cb34a8db7678f8c2e474cdd033833 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 23:02:42 -0800 Subject: [PATCH 112/156] more accurate way of discovering packages from OSGi jars --- build.gradle | 83 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/build.gradle b/build.gradle index 4299fe1ac..80a537141 100644 --- a/build.gradle +++ b/build.gradle @@ -239,11 +239,10 @@ def addOsgiManifest(jarTask, List importConfigs, List + // imports by looking at the actual code; instead, we're just importing whatever packages each + // dependency is exporting (if it has an OSGi manifest) or every package in the dependency (if + // it doesn't). + def imports = forEachArtifactAndVisiblePackage(importConfigs, { a, p -> bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports attributes("Import-Package": imports.join(",")) @@ -251,22 +250,13 @@ def addOsgiManifest(jarTask, List importConfigs, List + def exportedDependencies = forEachArtifactAndVisiblePackage(exportConfigs, { a, p -> bundleExport(p, a.moduleVersion.id.version) }) attributes("Export-Package": (sdkExports + exportedDependencies).join(",")) } } -def forEachArtifactAndPackage(configs, closure) { - configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } - .collectMany { a -> - getPackagesInDependencyJar(a.file) - .findAll { p -> !p.contains(".internal") } - .collect { p -> closure(a, p) } - } -} - def bundleImport(packageName, importVersion, versionLimit) { packageName + ";version=\"[" + importVersion + "," + versionLimit + ")\"" } @@ -280,6 +270,69 @@ def nextMajorVersion(v) { String.valueOf(Integer.parseInt(majorComponent) + 1) } +def forEachArtifactAndVisiblePackage(configs, closure) { + configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } + .collectMany { a -> + def exportedPackages = getOsgiPackageExportsFromJar(a.file) + if (exportedPackages == null) { + // This dependency didn't specify OSGi exports, so we'll just have to assume that + // we might need to use any package that's in this jar (with a little special-casing + // to exclude things we probably should not be importing). + exportedPackages = getPackagesInDependencyJar(a.file) + .findAll { !it.contains(".internal") } + } + exportedPackages.collect { p -> closure(a, p) } + } +} + +def getOsgiPackageExportsFromJar(file) { + return new java.util.jar.JarFile(file).withCloseable { jar -> + def manifest = jar.manifest + if (manifest != null) { + def allExports = manifest.mainAttributes.getValue("Export-Package") + if (allExports != null) { + return parseExports(allExports) + } + } + return null + } +} + +def parseExports(input) { + // This parses the elaborate format used by OSGi manifest attributes - in a somewhat + // simplified way since we don't care about anything other than the package names. + def ret = [] + def pos = 0 + while (pos < input.length()) { + def pkg = "" + def semicolon = input.indexOf(";", pos) + def comma = input.indexOf(",", pos) + if (comma >= 0 && (semicolon < 0 || semicolon > comma)) { + pkg = input.substring(pos, comma) + pos = comma + 1 + } else { + if (semicolon < 0) { + pkg = input.substring(pos) + pos = input.length() + } else { + pkg = input.substring(pos, semicolon) + pos = semicolon + 1 + // Discard all parameters up to the next clause + while (pos < input.length()) { + def ch = input.charAt(pos++) + if (ch == ',') { + break + } else if (ch == '"') { + while (pos < input.length() && input.charAt(pos++) != '"') { } + } + } + } + } + ret += pkg.trim() + } + return ret +} + artifacts { archives jar, sourcesJar, javadocJar, shadowJar, shadowJarAll } From e3f319733afc0ce09eb0ada34bdedab22d7dbcf6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 23:02:59 -0800 Subject: [PATCH 113/156] OSGi test fixes --- packaging-test/Makefile | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 462d38d7f..df0577b6f 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -37,7 +37,8 @@ TEMP_BUNDLE_DIR=$(TEMP_DIR)/bundles # the OSGi test). Note that we're assuming that all of the SDK's dependencies have built-in support # for OSGi, which is currently true; if that weren't true, we would have to do something different # to put them on the system classpath in the OSGi test. -RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) +RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) \ + $(SLF4J_SIMPLE_JAR) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) \ $(SLF4J_SIMPLE_JAR) @@ -74,14 +75,18 @@ test-all-jar test-default-jar test-thin-jar: $$(RUN_JARS_$$@) $(TEST_APP_JAR) $( @$(call caption,$@) @echo " non-OSGi runtime test" @java -classpath $(shell echo "$(RUN_JARS_$@)" | sed -e 's/ /:/g') testapp.TestApp | tee $(TEMP_OUTPUT) - @grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null - @echo "" - @echo " OSGi runtime test" - @rm -rf $(TEMP_BUNDLE_DIR) - @mkdir -p $(TEMP_BUNDLE_DIR) - @cp $(RUN_JARS_$@) $(FELIX_DIR)/bundle/*.jar $(TEMP_BUNDLE_DIR) - @cd $(FELIX_DIR) && echo "sleep 5;exit 0" | java -jar $(FELIX_JAR) -b $(TEMP_BUNDLE_DIR) | tee $(TEMP_OUTPUT) @grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null +# Can't currently run the OSGi test for the thin jar, because some of our dependencies aren't available as OSGi bundles. + @if [ "$@" != "test-thin-jar" ]; then \ + echo ""; \ + echo " OSGi runtime test"; \ + rm -rf $(TEMP_BUNDLE_DIR); \ + mkdir -p $(TEMP_BUNDLE_DIR); \ + cp $(RUN_JARS_$@) $(FELIX_DIR)/bundle/*.jar $(TEMP_BUNDLE_DIR); \ + rm -rf $(FELIX_DIR)/felix-cache; \ + cd $(FELIX_DIR) && echo "sleep 3;exit 0" | java -jar $(FELIX_JAR) -b $(TEMP_BUNDLE_DIR) | tee $(TEMP_OUTPUT); \ + grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null; \ + fi test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) From a3612b7137a225a46cc15f43854888d4578f25e2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 23 Nov 2018 23:35:59 -0800 Subject: [PATCH 114/156] simplify things using some library code --- build.gradle | 49 ++++++++----------------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/build.gradle b/build.gradle index 80a537141..3965e74f4 100644 --- a/build.gradle +++ b/build.gradle @@ -89,6 +89,8 @@ buildscript { classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.8.0" + classpath "org.eclipse.virgo.util:org.eclipse.virgo.util.osgi.manifest:3.5.0.RELEASE" + classpath "org.osgi:osgi_R4_core:1.0" } } @@ -288,49 +290,14 @@ def forEachArtifactAndVisiblePackage(configs, closure) { def getOsgiPackageExportsFromJar(file) { return new java.util.jar.JarFile(file).withCloseable { jar -> def manifest = jar.manifest - if (manifest != null) { - def allExports = manifest.mainAttributes.getValue("Export-Package") - if (allExports != null) { - return parseExports(allExports) - } - } - return null - } -} - -def parseExports(input) { - // This parses the elaborate format used by OSGi manifest attributes - in a somewhat - // simplified way since we don't care about anything other than the package names. - def ret = [] - def pos = 0 - while (pos < input.length()) { - def pkg = "" - def semicolon = input.indexOf(";", pos) - def comma = input.indexOf(",", pos) - if (comma >= 0 && (semicolon < 0 || semicolon > comma)) { - pkg = input.substring(pos, comma) - pos = comma + 1 - } else { - if (semicolon < 0) { - pkg = input.substring(pos) - pos = input.length() - } else { - pkg = input.substring(pos, semicolon) - pos = semicolon + 1 - // Discard all parameters up to the next clause - while (pos < input.length()) { - def ch = input.charAt(pos++) - if (ch == ',') { - break - } else if (ch == '"') { - while (pos < input.length() && input.charAt(pos++) != '"') { } - } - } - } + if (manifest == null) { + return null } - ret += pkg.trim() + def dict = new java.util.Hashtable() // sadly, the manifest parser requires a Dictionary + manifest.mainAttributes.each { k, v -> dict.put(k.toString(), v.toString()) } + return org.eclipse.virgo.util.osgi.manifest.BundleManifestFactory.createBundleManifest(dict) + .exportPackage.exportedPackages.collect { it.packageName } } - return ret } artifacts { From 27792985cd1ac75d94efa7dfbcef01c6c1afd197 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 3 Dec 2018 18:30:47 -0800 Subject: [PATCH 115/156] more specific logic for property switch --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3dc8984e6..e8f39034b 100644 --- a/build.gradle +++ b/build.gradle @@ -306,5 +306,5 @@ signing { } tasks.withType(Sign) { - onlyIf { !project.hasProperty("LD_SKIP_SIGNING") } // so we can build jars for testing in CI + onlyIf { !"1".equals(project.findProperty("LD_SKIP_SIGNING")) } // so we can build jars for testing in CI } From 1c1af7929954f4075ec0ff7ab5e37800f8f78cd5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 12:58:01 -0800 Subject: [PATCH 116/156] better abstraction of caching parameters --- .../client/FeatureStoreCaching.java | 212 ++++++++++++++++++ .../client/RedisFeatureStore.java | 5 +- .../client/RedisFeatureStoreBuilder.java | 99 ++++---- .../client/utils/CachingStoreWrapper.java | 87 +++---- .../client/FeatureStoreCachingTest.java | 120 ++++++++++ .../client/RedisFeatureStoreBuilderTest.java | 35 +-- .../client/utils/CachingStoreWrapperTest.java | 7 +- 7 files changed, 445 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreCaching.java create mode 100644 src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java new file mode 100644 index 000000000..ab975b8eb --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java @@ -0,0 +1,212 @@ +package com.launchdarkly.client; + +import com.google.common.cache.CacheBuilder; +import com.launchdarkly.client.utils.CachingStoreWrapper; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Parameters that can be used for {@link FeatureStore} implementations that support local caching. + *

    + * This is an immutable class that uses a fluent interface. Obtain an instance by calling the static + * methods {@link #disabled()} or {@link #enabled(long, TimeUnit)}; then, if it is enabled, you can + * use chained methods to set other properties: + * + *

    + *     new RedisFeatureStoreBuilder()
    + *         .caching(
    + *             FeatureStoreCaching.enabled().staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.REFRESH)
    + *         )
    + * 
    + * + * @see RedisFeatureStoreBuilder#caching(FeatureStoreCaching) + * @see CachingStoreWrapper.Builder#caching(FeatureStoreCaching) + * @since 4.6.0 + */ +public final class FeatureStoreCaching { + /** + * The default TTL, in seconds, used by {@link #DEFAULT}. + */ + public static final long DEFAULT_TIME_SECONDS = 15; + + /** + * The caching parameters that feature store should use by default. Caching is enabled, with a + * TTL of {@link #DEFAULT_TIME_SECONDS} and the {@link StaleValuesPolicy#EVICT} policy. + */ + public static final FeatureStoreCaching DEFAULT = enabled(); + + private final long cacheTime; + private final TimeUnit cacheTimeUnit; + private final StaleValuesPolicy staleValuesPolicy; + + /** + * Possible values for {@link FeatureStoreCaching#staleValuesPolicy(StaleValuesPolicy)}. + */ + public enum StaleValuesPolicy { + /** + * Indicates that when the cache TTL expires for an item, it is evicted from the cache. The next + * attempt to read that item causes a synchronous read from the underlying data store; if that + * fails, no value is available. This is the default behavior. + * + * @see {@link CacheBuilder#expireAfterWrite(long, TimeUnit)} + */ + EVICT, + /** + * Indicates that the cache should refresh stale values instead of evicting them. + *

    + * In this mode, an attempt to read an expired item causes a synchronous read from the underlying + * data store, like {@link #EVICT}--but if an error occurs during this refresh, the cache will + * continue to return the previously cached values (if any). This is useful if you prefer the most + * recently cached feature rule set to be returned for evaluation over the default value when + * updates go wrong. + *

    + * See: CacheBuilder + * for more specific information on cache semantics. This mode is equivalent to {@code expireAfterWrite}. + */ + REFRESH, + /** + * Indicates that the cache should refresh stale values asynchronously instead of evicting them. + *

    + * This is the same as {@link #REFRESH}, except that the attempt to refresh the value is done + * on another thread (using a {@link java.util.concurrent.Executor}). In the meantime, the cache + * will continue to return the previously cached value (if any) in a non-blocking fashion to threads + * requesting the stale key. Any exception encountered during the asynchronous reload will cause + * the previously cached value to be retained. + *

    + * This setting is ideal to enable when you desire high performance reads and can accept returning + * stale values for the period of the async refresh. For example, configuring this feature store + * with a very low cache time and enabling this feature would see great performance benefit by + * decoupling calls from network I/O. + *

    + * See: CacheBuilder for + * more specific information on cache semantics. + */ + REFRESH_ASYNC + }; + + /** + * Returns a parameter object indicating that caching should be disabled. Specifying any additional + * properties on this object will have no effect. + * @return a {@link FeatureStoreCaching} instance + */ + public static FeatureStoreCaching disabled() { + return new FeatureStoreCaching(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); + } + + /** + * Returns a parameter object indicating that caching should be enabled, using the default TTL of + * {@link #DEFAULT_TIME_SECONDS}. You can further modify the cache properties using the other + * methods of this class. + * @return a {@link FeatureStoreCaching} instance + */ + public static FeatureStoreCaching enabled() { + return new FeatureStoreCaching(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + } + + private FeatureStoreCaching(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { + this.cacheTime = cacheTime; + this.cacheTimeUnit = cacheTimeUnit; + this.staleValuesPolicy = staleValuesPolicy; + } + + /** + * Returns true if caching will be enabled. + * @return true if the cache TTL is great then 0 + */ + public boolean isEnabled() { + return getCacheTime() > 0; + } + + /** + * Returns the cache TTL. Caching is enabled if this is greater than zero. + * @return the cache TTL in whatever units were specified + * @see {@link #getCacheTimeUnit()} + */ + public long getCacheTime() { + return cacheTime; + } + + /** + * Returns the time unit for the cache TTL. + * @return the time unit + */ + public TimeUnit getCacheTimeUnit() { + return cacheTimeUnit; + } + + /** + * Returns the cache TTL converted to milliseconds. + * @return the TTL in milliseconds + */ + public long getCacheTimeMillis() { + return cacheTimeUnit.toMillis(cacheTime); + } + + /** + * Returns the {@link StaleValuesPolicy} setting. + * @return the expiration policy + */ + public StaleValuesPolicy getStaleValuesPolicy() { + return staleValuesPolicy; + } + + /** + * Specifies the cache TTL. Items will be evicted or refreshed (depending on {@link #staleValuesPolicy(StaleValuesPolicy)}) + * after this amount of time from the time when they were originally cached. If the time is less + * than or equal to zero, caching is disabled. + * + * @param cacheTime the cache TTL in whatever units you wish + * @param timeUnit the time unit + * @return an updated parameters object + */ + public FeatureStoreCaching ttl(long cacheTime, TimeUnit timeUnit) { + return new FeatureStoreCaching(cacheTime, timeUnit, staleValuesPolicy); + } + + /** + * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. + * + * @param seconds the cache TTL in milliseconds + * @return an updated parameters object + */ + public FeatureStoreCaching ttlMillis(long millis) { + return ttl(millis, TimeUnit.MILLISECONDS); + } + + /** + * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#SECONDS}. + * + * @param seconds the cache TTL in seconds + * @return an updated parameters object + */ + public FeatureStoreCaching ttlSeconds(long seconds) { + return ttl(seconds, TimeUnit.SECONDS); + } + + /** + * Specifies how the cache (if any) should deal with old values when the cache TTL expires. The default + * is {@link StaleValuesPolicy#EVICT}. This property has no effect if caching is disabled. + * + * @param policy a {@link StaleValuesPolicy} constant + * @return an updated parameters object + */ + public FeatureStoreCaching staleValuesPolicy(StaleValuesPolicy policy) { + return new FeatureStoreCaching(cacheTime, cacheTimeUnit, policy); + } + + @Override + public boolean equals(Object other) { + if (other instanceof FeatureStoreCaching) { + FeatureStoreCaching o = (FeatureStoreCaching) other; + return o.cacheTime == this.cacheTime && o.cacheTimeUnit == this.cacheTimeUnit && + o.staleValuesPolicy == this.staleValuesPolicy; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(cacheTime, cacheTimeUnit, staleValuesPolicy); + } +} diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index cdf60566f..bac489feb 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -93,10 +93,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { builder.prefix; this.core = new Core(pool, prefix); - this.wrapper = new CachingStoreWrapper.Builder(this.core) - .cacheTime(builder.cacheTime, builder.cacheTimeUnit) - .refreshStaleValues(builder.refreshStaleValues) - .asyncRefresh(builder.asyncRefresh) + this.wrapper = new CachingStoreWrapper.Builder(this.core).caching(builder.caching) .build(); } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 4fac992d1..bd23ec05c 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -40,19 +40,19 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). + * @deprecated Use {@link FeatureStoreCaching#DEFAULT}. * @since 4.0.0 */ - public static final long DEFAULT_CACHE_TIME_SECONDS = 15; + public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCaching.DEFAULT_TIME_SECONDS; final URI uri; - boolean refreshStaleValues = false; - boolean asyncRefresh = false; String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; - long cacheTime = DEFAULT_CACHE_TIME_SECONDS; - TimeUnit cacheTimeUnit = TimeUnit.SECONDS; + FeatureStoreCaching caching = FeatureStoreCaching.DEFAULT; JedisPoolConfig poolConfig = null; + boolean refreshStaleValues = false; + boolean asyncRefresh = false; // These constructors are called only from Implementations RedisFeatureStoreBuilder() { @@ -89,55 +89,66 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache this.uri = new URI(scheme, null, host, port, null, null, null); this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); } - + /** - * Optionally set the {@link RedisFeatureStore} local cache to refresh stale values instead of evicting them (the default behaviour). - * - * When enabled; the cache refreshes stale values instead of completely evicting them. This mode returns the previously cached, stale values if - * anything goes wrong during the refresh phase (for example a connection timeout). If there was no previously cached value then the store will - * return null (resulting in the default value being returned). This is useful if you prefer the most recently cached feature rule set to be returned - * for evaluation over the default value when updates go wrong. - * - * When disabled; results in a behaviour which evicts stale values from the local cache and retrieves the latest value from Redis. If the updated value - * can not be returned for whatever reason then a null is returned (resulting in the default value being returned). - * - * This property has no effect if the cache time is set to 0. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for details. - * - * See: CacheBuilder for more specific information on cache semantics. + * Specifies whether local caching should be enabled and if so, sets the cache properties. Local + * caching is enabled by default; see {@link FeatureStoreCaching#DEFAULT}. To disable it, pass + * {@link FeatureStoreCaching#disabled()} to this method. + * + * @param caching a {@link FeatureStoreCaching} object specifying caching parameters + * @return the builder + * + * @since 4.6.0 + */ + public RedisFeatureStoreBuilder caching(FeatureStoreCaching caching) { + this.caching = caching; + return this; + } + + /** + * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH} + * or {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH_ASYNC}. * - * @param enabled turns on lazy refresh of cached values. + * @param enabled turns on lazy refresh of cached values * @return the builder + * + * @deprecated Use {@link #caching(FeatureStoreCaching)} and + * {@link FeatureStoreCaching#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy)}. */ public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { this.refreshStaleValues = enabled; + updateCachingStaleValuesPolicy(); return this; } /** - * Optionally make cache refresh mode asynchronous. This setting only works if {@link RedisFeatureStoreBuilder#refreshStaleValues(boolean)} has been enabled - * and has no effect otherwise. - * - * Upon hitting a stale value in the local cache; the refresh of the value will be asynchronous which will return the previously cached value in a - * non-blocking fashion to threads requesting the stale key. This internally will utilize a {@link java.util.concurrent.Executor} to asynchronously - * refresh the stale value upon the first read request for the stale value in the cache. - * - * If there was no previously cached value then the feature store returns null (resulting in the default value being returned). Any exception - * encountered during the asynchronous reload will simply keep the previously cached value instead. - * - * This setting is ideal to enable when you desire high performance reads and can accept returning stale values for the period of the async refresh. For - * example configuring this feature store with a very low cache time and enabling this feature would see great performance benefit by decoupling calls - * from network I/O. + * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH_ASYNC}. * - * This property has no effect if the cache time is set to 0. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for details. - * - * @param enabled turns on asychronous refreshes on. + * @param enabled turns on asynchronous refresh of cached values (only if {@link #refreshStaleValues(boolean)} + * is also true) * @return the builder + * + * @deprecated Use {@link #caching(FeatureStoreCaching)} and + * {@link FeatureStoreCaching#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy)}. */ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { this.asyncRefresh = enabled; + updateCachingStaleValuesPolicy(); return this; } + private void updateCachingStaleValuesPolicy() { + // We need this logic in order to support the existing behavior of the deprecated methods above: + // asyncRefresh is supposed to have no effect unless refreshStaleValues is true + if (this.refreshStaleValues) { + this.caching = this.caching.staleValuesPolicy(this.asyncRefresh ? + FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC : + FeatureStoreCaching.StaleValuesPolicy.REFRESH); + } else { + this.caching = this.caching.staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.EVICT); + } + } + /** * Optionally configures the namespace prefix for all keys stored in Redis. * @@ -150,20 +161,18 @@ public RedisFeatureStoreBuilder prefix(String prefix) { } /** - * A mandatory field which configures the amount of time the store should internally cache the value before being marked invalid. - * - * The eviction strategy of stale values is determined by the configuration picked. See {@link RedisFeatureStoreBuilder#refreshStaleValues(boolean)} for - * more information on stale value updating strategies. - * - * If this value is set to 0 then it effectively disables local caching altogether. + * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled + * by default; see {@link FeatureStoreCaching#DEFAULT}. * - * @param cacheTime the time value to cache for + * @param cacheTime the time value to cache for, or 0 to disable local caching * @param timeUnit the time unit for the time value * @return the builder + * + * @deprecated use {@link #caching(FeatureStoreCaching)} and {@link FeatureStoreCaching#enabled(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - this.cacheTime = cacheTime; - this.cacheTimeUnit = timeUnit; + this.caching = this.caching.ttl(cacheTime, timeUnit) + .staleValuesPolicy(this.caching.getStaleValuesPolicy()); return this; } diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 33d4e920c..53f2a66de 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -9,6 +9,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCaching; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; @@ -18,14 +19,14 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** * CachingStoreWrapper is a partial implementation of {@link FeatureStore} that delegates the basic * functionality to an instance of {@link FeatureStoreCore}. It provides optional caching behavior and - * other logic that would otherwise be repeated in every feature store implementation. - * + * other logic that would otherwise be repeated in every feature store implementation. This makes it + * easier to create new database integrations by implementing only the database-specific logic. + *

    * Construct instances of this class with {@link CachingStoreWrapper.Builder}. * * @since 4.6.0 @@ -40,10 +41,10 @@ public class CachingStoreWrapper implements FeatureStore { private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; - protected CachingStoreWrapper(final FeatureStoreCore core, long cacheTime, TimeUnit cacheTimeUnit, boolean refreshStaleValues, boolean asyncRefresh) { + protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCaching caching) { this.core = core; - if (cacheTime <= 0) { + if (!caching.isEnabled()) { itemCache = null; allCache = null; initCache = null; @@ -68,7 +69,17 @@ public Boolean load(String key) throws Exception { } }; - if (refreshStaleValues) { + switch (caching.getStaleValuesPolicy()) { + case EVICT: + // We are using an "expire after write" cache. This will evict stale values and block while loading the latest + // from the underlying data store. + + itemCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(itemLoader); + allCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); + executorService = null; + break; + + default: // We are using a "refresh after write" cache. This will not automatically evict stale values, allowing them // to be returned if failures occur when updating them. Optionally set the cache to refresh values asynchronously, // which always returns the previously cached value immediately (this is only done for itemCache, not allCache, @@ -78,21 +89,14 @@ public Boolean load(String key) throws Exception { ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); executorService = MoreExecutors.listeningDecorator(parentExecutor); - if (asyncRefresh) { + if (caching.getStaleValuesPolicy() == FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC) { itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); } - itemCache = CacheBuilder.newBuilder().refreshAfterWrite(cacheTime, cacheTimeUnit).build(itemLoader); - allCache = CacheBuilder.newBuilder().refreshAfterWrite(cacheTime, cacheTimeUnit).build(allLoader); - } else { - // We are using an "expire after write" cache. This will evict stale values and block while loading the latest - // from Redis. - - itemCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(itemLoader); - allCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(allLoader); - executorService = null; + itemCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(itemLoader); + allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); } - initCache = CacheBuilder.newBuilder().expireAfterWrite(cacheTime, cacheTimeUnit).build(initLoader); + initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); } } @@ -250,53 +254,32 @@ public int hashCode() { */ public static class Builder { private final FeatureStoreCore core; - private long cacheTime; - private TimeUnit cacheTimeUnit; - private boolean refreshStaleValues; - private boolean asyncRefresh; - - public Builder(FeatureStoreCore core) { - this.core = core; - } + private FeatureStoreCaching caching = FeatureStoreCaching.DEFAULT; /** - * Specifies the cache TTL. If {@code cacheTime} is zero or negative, there will be no local caching. - * Caching is off by default. - * @param cacheTime the cache TTL, in whatever unit is specified by {@code cacheTimeUnit} - * @param cacheTimeUnit the time unit - * @return the same builder + * Creates a new builder. + * @param core the {@link FeatureStoreCore} instance */ - public Builder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { - this.cacheTime = cacheTime; - this.cacheTimeUnit = cacheTimeUnit; - return this; + public Builder(FeatureStoreCore core) { + this.core = core; } - + /** - * Specifies whether the cache (if any) should attempt to refresh stale values instead of evicting them. - * In this mode, if the refresh fails, the last good value will still be available from the cache. - * @param refreshStaleValues true if values should be lazily refreshed - * @return the same builder + * Sets the local caching properties. + * @param caching a {@link FeatureStoreCaching} object specifying cache parameters + * @return the builder */ - public Builder refreshStaleValues(boolean refreshStaleValues) { - this.refreshStaleValues = refreshStaleValues; + public Builder caching(FeatureStoreCaching caching) { + this.caching = caching; return this; } /** - * Specifies whether cache refreshing should be asynchronous (assuming {@code refreshStaleValues} is true). - * In this mode, if a cached value has expired, retrieving it will still get the old value but will - * trigger an attempt to refresh on another thread, rather than blocking until a new value is available. - * @param asyncRefresh true if values should be asynchronously refreshed - * @return the same builder + * Creates and configures the wrapper object. + * @return a {@link CachingStoreWrapper} instance */ - public Builder asyncRefresh(boolean asyncRefresh) { - this.asyncRefresh = asyncRefresh; - return this; - } - public CachingStoreWrapper build() { - return new CachingStoreWrapper(core, cacheTime, cacheTimeUnit, refreshStaleValues, asyncRefresh); + return new CachingStoreWrapper(core, caching); } } } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java new file mode 100644 index 000000000..0911bc4ae --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -0,0 +1,120 @@ +package com.launchdarkly.client; + +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.EVICT; +import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.REFRESH; +import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class FeatureStoreCachingTest { + @Test + public void disabledHasExpectedProperties() { + FeatureStoreCaching fsc = FeatureStoreCaching.disabled(); + assertThat(fsc.getCacheTime(), equalTo(0L)); + assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); + } + + @Test + public void enabledHasExpectedProperties() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled(); + assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCaching.DEFAULT_TIME_SECONDS)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); + } + + @Test + public void defaultIsEnabled() { + FeatureStoreCaching fsc = FeatureStoreCaching.DEFAULT; + assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCaching.DEFAULT_TIME_SECONDS)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); + } + + @Test + public void canSetTtl() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .staleValuesPolicy(REFRESH) + .ttl(3, TimeUnit.DAYS); + assertThat(fsc.getCacheTime(), equalTo(3L)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.DAYS)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); + } + + @Test + public void canSetTtlInMillis() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .staleValuesPolicy(REFRESH) + .ttlMillis(3); + assertThat(fsc.getCacheTime(), equalTo(3L)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); + } + + @Test + public void canSetTtlInSeconds() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .staleValuesPolicy(REFRESH) + .ttlSeconds(3); + assertThat(fsc.getCacheTime(), equalTo(3L)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); + } + + @Test + public void zeroTtlMeansDisabled() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .ttl(0, TimeUnit.SECONDS); + assertThat(fsc.isEnabled(), equalTo(false)); + } + + @Test + public void negativeTtlMeansDisabled() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .ttl(-1, TimeUnit.SECONDS); + assertThat(fsc.isEnabled(), equalTo(false)); + } + + @Test + public void canSetStaleValuesPolicy() { + FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + .ttlMillis(3) + .staleValuesPolicy(REFRESH_ASYNC); + assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); + assertThat(fsc.getCacheTime(), equalTo(3L)); + assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); + } + + @Test + public void equalityUsesTime() { + FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().ttlMillis(3); + FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().ttlMillis(3); + FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().ttlMillis(4); + assertThat(fsc1.equals(fsc2), equalTo(true)); + assertThat(fsc1.equals(fsc3), equalTo(false)); + } + + @Test + public void equalityUsesTimeUnit() { + FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().ttlMillis(3); + FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().ttlMillis(3); + FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().ttlSeconds(3); + assertThat(fsc1.equals(fsc2), equalTo(true)); + assertThat(fsc1.equals(fsc3), equalTo(false)); + } + + @Test + public void equalityUsesStaleValuesPolicy() { + FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().staleValuesPolicy(EVICT); + FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().staleValuesPolicy(EVICT); + FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().staleValuesPolicy(REFRESH); + assertThat(fsc1.equals(fsc2), equalTo(true)); + assertThat(fsc1.equals(fsc3), equalTo(false)); + } +} diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java index ed029109b..f6656f4d8 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java @@ -17,12 +17,9 @@ public class RedisFeatureStoreBuilderTest { public void testDefaultValues() { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTime); - assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); + assertEquals(FeatureStoreCaching.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(false, conf.refreshStaleValues); - assertEquals(false, conf.asyncRefresh); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @@ -32,12 +29,9 @@ public void testConstructorSpecifyingUri() { URI uri = URI.create("redis://host:1234"); RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); assertEquals(uri, conf.uri); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_CACHE_TIME_SECONDS, conf.cacheTime); - assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); + assertEquals(FeatureStoreCaching.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(false, conf.refreshStaleValues); - assertEquals(false, conf.asyncRefresh); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @@ -47,26 +41,34 @@ public void testConstructorSpecifyingUri() { public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); assertEquals(URI.create("badscheme://example:1234"), conf.uri); - assertEquals(100, conf.cacheTime); - assertEquals(TimeUnit.SECONDS, conf.cacheTimeUnit); + assertEquals(100, conf.caching.getCacheTime()); + assertEquals(TimeUnit.SECONDS, conf.caching.getCacheTimeUnit()); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(false, conf.refreshStaleValues); - assertEquals(false, conf.asyncRefresh); + assertEquals(FeatureStoreCaching.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } + @SuppressWarnings("deprecation") @Test public void testRefreshStaleValues() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true); - assertEquals(true, conf.refreshStaleValues); + assertEquals(FeatureStoreCaching.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); } + @SuppressWarnings("deprecation") @Test public void testAsyncRefresh() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true).asyncRefresh(true); + assertEquals(FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); + } + + @SuppressWarnings("deprecation") + @Test + public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().asyncRefresh(true); - assertEquals(true, conf.asyncRefresh); + assertEquals(FeatureStoreCaching.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); } @Test @@ -87,11 +89,12 @@ public void testSocketTimeoutConfigured() throws URISyntaxException { assertEquals(1000, conf.socketTimeout); } + @SuppressWarnings("deprecation") @Test public void testCacheTimeWithUnit() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); - assertEquals(2000, conf.cacheTime); - assertEquals(TimeUnit.MILLISECONDS, conf.cacheTimeUnit); + assertEquals(2000, conf.caching.getCacheTime()); + assertEquals(TimeUnit.MILLISECONDS, conf.caching.getCacheTimeUnit()); } @Test diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index b3aae4706..904de8de4 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client.utils; import com.google.common.collect.ImmutableMap; +import com.launchdarkly.client.FeatureStoreCaching; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; @@ -13,7 +14,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.TimeUnit; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -36,7 +36,8 @@ public static Iterable data() { public CachingStoreWrapperTest(boolean cached) { this.cached = cached; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cached ? 30 : 0, TimeUnit.SECONDS, false, false); + this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCaching.enabled().ttlSeconds(30) : + FeatureStoreCaching.disabled()); } @Test @@ -255,7 +256,7 @@ public void initializedCanCacheFalseResult() throws Exception { assumeThat(cached, is(true)); // We need to create a different object for this test so we can set a short cache TTL - try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, 500, TimeUnit.MILLISECONDS, false, false)) { + try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, FeatureStoreCaching.enabled().ttlMillis(500))) { assertThat(wrapper1.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); From 79a758e487629536088dbc9e2dca90bef33fca4b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 13:02:27 -0800 Subject: [PATCH 117/156] fix doc comment --- .../java/com/launchdarkly/client/FeatureStoreCaching.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java index ab975b8eb..392d186b1 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java @@ -10,13 +10,15 @@ * Parameters that can be used for {@link FeatureStore} implementations that support local caching. *

    * This is an immutable class that uses a fluent interface. Obtain an instance by calling the static - * methods {@link #disabled()} or {@link #enabled(long, TimeUnit)}; then, if it is enabled, you can - * use chained methods to set other properties: + * methods {@link #disabled()} or {@link #enabled()}; then, if desired, you can use chained methods + * to set other properties: * *

      *     new RedisFeatureStoreBuilder()
      *         .caching(
    - *             FeatureStoreCaching.enabled().staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.REFRESH)
    + *             FeatureStoreCaching.enabled()
    + *                 .ttlSeconds(30)
    + *                 .staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.REFRESH)
      *         )
      * 
    * From e08885845a652c20d5f6534e9dfcbf48ce3fc19b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 13:05:23 -0800 Subject: [PATCH 118/156] comment --- .../com/launchdarkly/client/RedisFeatureStoreBuilder.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index bd23ec05c..5b3f8ddd9 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -50,9 +50,9 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; FeatureStoreCaching caching = FeatureStoreCaching.DEFAULT; - JedisPoolConfig poolConfig = null; - boolean refreshStaleValues = false; + boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCaching, but are used by deprecated setters boolean asyncRefresh = false; + JedisPoolConfig poolConfig = null; // These constructors are called only from Implementations RedisFeatureStoreBuilder() { From f7f6bbedf30116a70b42101c206267e7260d1ce6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 13:10:16 -0800 Subject: [PATCH 119/156] javadoc fixes --- .../java/com/launchdarkly/client/FeatureStoreCaching.java | 6 +++--- .../com/launchdarkly/client/RedisFeatureStoreBuilder.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java index 392d186b1..f2ad5fdbf 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java @@ -51,7 +51,7 @@ public enum StaleValuesPolicy { * attempt to read that item causes a synchronous read from the underlying data store; if that * fails, no value is available. This is the default behavior. * - * @see {@link CacheBuilder#expireAfterWrite(long, TimeUnit)} + * @see CacheBuilder#expireAfterWrite(long, TimeUnit) */ EVICT, /** @@ -123,7 +123,7 @@ public boolean isEnabled() { /** * Returns the cache TTL. Caching is enabled if this is greater than zero. * @return the cache TTL in whatever units were specified - * @see {@link #getCacheTimeUnit()} + * @see #getCacheTimeUnit() */ public long getCacheTime() { return cacheTime; @@ -169,7 +169,7 @@ public FeatureStoreCaching ttl(long cacheTime, TimeUnit timeUnit) { /** * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. * - * @param seconds the cache TTL in milliseconds + * @param millis the cache TTL in milliseconds * @return an updated parameters object */ public FeatureStoreCaching ttlMillis(long millis) { diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 5b3f8ddd9..497f5ac55 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -168,7 +168,7 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * @param timeUnit the time unit for the time value * @return the builder * - * @deprecated use {@link #caching(FeatureStoreCaching)} and {@link FeatureStoreCaching#enabled(long, TimeUnit)}. + * @deprecated use {@link #caching(FeatureStoreCaching)} and {@link FeatureStoreCaching#ttl(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { this.caching = this.caching.ttl(cacheTime, timeUnit) From a478796ea54a60cfb5f9cd3c34c20d0c5d6b05a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 13:15:44 -0800 Subject: [PATCH 120/156] better use of static instances --- .../com/launchdarkly/client/FeatureStoreCaching.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java index f2ad5fdbf..be1b2bac8 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java @@ -36,7 +36,11 @@ public final class FeatureStoreCaching { * The caching parameters that feature store should use by default. Caching is enabled, with a * TTL of {@link #DEFAULT_TIME_SECONDS} and the {@link StaleValuesPolicy#EVICT} policy. */ - public static final FeatureStoreCaching DEFAULT = enabled(); + public static final FeatureStoreCaching DEFAULT = + new FeatureStoreCaching(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + + private static final FeatureStoreCaching DISABLED = + new FeatureStoreCaching(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); private final long cacheTime; private final TimeUnit cacheTimeUnit; @@ -93,7 +97,7 @@ public enum StaleValuesPolicy { * @return a {@link FeatureStoreCaching} instance */ public static FeatureStoreCaching disabled() { - return new FeatureStoreCaching(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); + return DISABLED; } /** @@ -103,7 +107,7 @@ public static FeatureStoreCaching disabled() { * @return a {@link FeatureStoreCaching} instance */ public static FeatureStoreCaching enabled() { - return new FeatureStoreCaching(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + return DEFAULT; } private FeatureStoreCaching(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { From 95b352dd6755488013c65d115107343ac7d2419b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 13:17:36 -0800 Subject: [PATCH 121/156] use newer methods in test --- .../java/com/launchdarkly/client/RedisFeatureStoreTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index 90c9a1dca..16d0cd38f 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -3,7 +3,6 @@ import com.launchdarkly.client.RedisFeatureStore.UpdateListener; import java.net.URI; -import java.util.concurrent.TimeUnit; import redis.clients.jedis.Jedis; @@ -18,13 +17,13 @@ public RedisFeatureStoreTest(boolean cached) { @Override protected RedisFeatureStore makeStore() { RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); - builder.cacheTime(cached ? 30 : 0, TimeUnit.SECONDS); + builder.caching(cached ? FeatureStoreCaching.enabled().ttlSeconds(30) : FeatureStoreCaching.disabled()); return builder.build(); } @Override protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).cacheTime(0, TimeUnit.SECONDS).prefix(prefix).build(); + return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCaching.disabled()).prefix(prefix).build(); } @Override From 993185850de92130c4917cb3d62373f846c2e51f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 19:39:05 -0800 Subject: [PATCH 122/156] add readme text about DNS cache --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 59e0068d5..c890f6157 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Distributions Three variants of the SDK jar are published to Maven: -* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4j, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. -* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4j are also bundled, without shading (and are exported in OSGi). +* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4J, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. +* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4J are also bundled, without shading (and are exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. Quick setup @@ -71,6 +71,13 @@ Using flag data from a file For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. +DNS caching issues +------------------ + +LaunchDarkly servers operate in a load-balancing framework which may cause their IP addresses to change. This could result in the SDK failing to connect to LaunchDarkly if an old IP address is still in your system's DNS cache. + +Depending on your runtime environment, IP addresses may expire from the cache after a reasonably short period of time; or, if there is a [security manager](https://docs.oracle.com/javase/tutorial/essential/environment/security.html), they may _never_ expire. In the latter case, we recommend that you set the security property `networkaddress.cache.ttl`, as described [here](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-jvm-ttl.html), to a number of seconds such as 30 or 60. A lower value will reduce the chance of intermittent failures, but will slightly reduce networking performance. + Learn more ---------- From 9caea4cf88aeb27a11d9ee1b84a4c36b9206f6df Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Dec 2018 19:43:16 -0800 Subject: [PATCH 123/156] copyedit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c890f6157..2d2343a37 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ For testing purposes, the SDK can be made to read feature flag state from a file DNS caching issues ------------------ -LaunchDarkly servers operate in a load-balancing framework which may cause their IP addresses to change. This could result in the SDK failing to connect to LaunchDarkly if an old IP address is still in your system's DNS cache. +LaunchDarkly servers operate in a load-balancing framework which may cause their IP addresses to change. This could result in the SDK failing to connect to LaunchDarkly if an old IP address is still in your system's DNS cache. -Depending on your runtime environment, IP addresses may expire from the cache after a reasonably short period of time; or, if there is a [security manager](https://docs.oracle.com/javase/tutorial/essential/environment/security.html), they may _never_ expire. In the latter case, we recommend that you set the security property `networkaddress.cache.ttl`, as described [here](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-jvm-ttl.html), to a number of seconds such as 30 or 60. A lower value will reduce the chance of intermittent failures, but will slightly reduce networking performance. +Unlike some other languages, in Java the DNS caching behavior is controlled by the Java virtual machine rather than the operating system. The default behavior varies depending on whether there is a [security manager](https://docs.oracle.com/javase/tutorial/essential/environment/security.html): if there is, IP addresses will _never_ expire. In that case, we recommend that you set the security property `networkaddress.cache.ttl`, as described [here](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-jvm-ttl.html), to a number of seconds such as 30 or 60 (a lower value will reduce the chance of intermittent failures, but will slightly reduce networking performance). Learn more ---------- From 30cad56cc894fa3fd11c2ac77fe32aab1e1a0b61 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 8 Dec 2018 09:56:29 -0800 Subject: [PATCH 124/156] add Electron link --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d2343a37..0cb394b32 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ About LaunchDarkly * [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK") * [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK") * [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK") + * [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK") * [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK") * [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK") * [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK") From d44bf0ec028735f3fb2fd87ad2a02f13ab39f41e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 10 Dec 2018 14:51:43 -0800 Subject: [PATCH 125/156] simplify method signatures + some test fixes --- .../client/RedisFeatureStore.java | 20 ++++---- .../client/utils/CachingStoreWrapper.java | 40 +++++++++------- .../client/utils/FeatureStoreCore.java | 29 +++++++----- .../client/FeatureStoreTestBase.java | 11 +++++ .../client/utils/CachingStoreWrapperTest.java | 47 +++++++++++-------- 5 files changed, 87 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index bac489feb..94698ccfc 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -117,26 +117,25 @@ static class Core implements FeatureStoreCore { this.prefix = prefix; } - @SuppressWarnings("unchecked") @Override - public T getInternal(VersionedDataKind kind, String key) { + public VersionedData getInternal(VersionedDataKind kind, String key) { try (Jedis jedis = pool.getResource()) { VersionedData item = getRedis(kind, key, jedis); if (item != null) { logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); } - return (T)item; + return item; } } @Override - public Map getAllInternal(VersionedDataKind kind) { + public Map getAllInternal(VersionedDataKind kind) { try (Jedis jedis = pool.getResource()) { Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); + Map result = new HashMap<>(); for (Map.Entry entry : allJson.entrySet()) { - T item = unmarshalJson(kind, entry.getValue()); + VersionedData item = unmarshalJson(kind, entry.getValue()); result.put(entry.getKey(), item); } return result; @@ -144,11 +143,11 @@ public Map getAllInternal(VersionedDataKind } @Override - public void initInternal(Map, Map> allData) { + public void initInternal(Map, Map> allData) { try (Jedis jedis = pool.getResource()) { Transaction t = jedis.multi(); - for (Map.Entry, Map> entry: allData.entrySet()) { + for (Map.Entry, Map> entry: allData.entrySet()) { String baseKey = itemsKey(entry.getKey()); t.del(baseKey); for (VersionedData item: entry.getValue().values()) { @@ -161,9 +160,8 @@ public void initInternal(Map, Map T upsertInternal(VersionedDataKind kind, T newItem) { + public VersionedData upsertInternal(VersionedDataKind kind, VersionedData newItem) { while (true) { Jedis jedis = null; try { @@ -182,7 +180,7 @@ public T upsertInternal(VersionedDataKind kind, T n " with a version that is the same or older: {} in \"{}\"", newItem.isDeleted() ? "delete" : "update", newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace()); - return (T)oldItem; + return oldItem; } Transaction tx = jedis.multi(); diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 53f2a66de..541195d7b 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -36,7 +36,7 @@ public class CachingStoreWrapper implements FeatureStore { private final FeatureStoreCore core; private final LoadingCache> itemCache; - private final LoadingCache, Map> allCache; + private final LoadingCache, Map> allCache; private final LoadingCache initCache; private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; @@ -56,9 +56,9 @@ public Optional load(CacheKey key) throws Exception { return Optional.fromNullable(core.getInternal(key.kind, key.key)); } }; - CacheLoader, Map> allLoader = new CacheLoader, Map>() { + CacheLoader, Map> allLoader = new CacheLoader, Map>() { @Override - public Map load(VersionedDataKind kind) throws Exception { + public Map load(VersionedDataKind kind) throws Exception { return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); } }; @@ -114,11 +114,10 @@ public T get(VersionedDataKind kind, String key) { if (itemCache != null) { Optional cachedItem = itemCache.getUnchecked(CacheKey.forItem(kind, key)); if (cachedItem != null) { - T item = (T)cachedItem.orNull(); - return itemOnlyIfNotDeleted(item); + return (T)itemOnlyIfNotDeleted(cachedItem.orNull()); } } - return itemOnlyIfNotDeleted(core.getInternal(kind, key)); + return (T)itemOnlyIfNotDeleted(core.getInternal(kind, key)); } @SuppressWarnings("unchecked") @@ -130,21 +129,29 @@ public Map all(VersionedDataKind kind) { return items; } } - return core.getAllInternal(kind); + return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); } + @SuppressWarnings("unchecked") @Override public void init(Map, Map> allData) { - core.initInternal(allData); + Map, Map> params = new HashMap, Map>(); + for (Map.Entry, Map> e0: allData.entrySet()) { + // unfortunately this is necessary because we can't just cast to a map with a different type signature in this case + params.put(e0.getKey(), (Map)e0.getValue()); + } + core.initInternal(params); + inited.set(true); + if (allCache != null && itemCache != null) { allCache.invalidateAll(); itemCache.invalidateAll(); - for (Map.Entry, Map> e0: allData.entrySet()) { + for (Map.Entry, Map> e0: params.entrySet()) { VersionedDataKind kind = e0.getKey(); - allCache.put(kind, e0.getValue()); - for (Map.Entry e1: e0.getValue().entrySet()) { - itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of((VersionedData)e1.getValue())); + allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); + for (Map.Entry e1: e0.getValue().entrySet()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); } } } @@ -204,16 +211,17 @@ public FeatureStoreCore getCore() { return core; } - private T itemOnlyIfNotDeleted(T item) { + private VersionedData itemOnlyIfNotDeleted(VersionedData item) { return (item != null && item.isDeleted()) ? null : item; } - private Map itemsOnlyIfNotDeleted(Map items) { - Map ret = new HashMap<>(); + @SuppressWarnings("unchecked") + private Map itemsOnlyIfNotDeleted(Map items) { + Map ret = new HashMap<>(); if (items != null) { for (Map.Entry item: items.entrySet()) { if (!item.getValue().isDeleted()) { - ret.put(item.getKey(), item.getValue()); + ret.put(item.getKey(), (T) item.getValue()); } } } diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java index 561fa468f..d7d57a9bd 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -12,8 +12,14 @@ * {@link FeatureStore}, to be used in conjunction with {@link CachingStoreWrapper}. This allows * developers of custom FeatureStore implementations to avoid repeating logic that would * commonly be needed in any such implementation, such as caching. Instead, they can implement - * only FeatureStoreCore and then create a CachingStoreWrapper. {@link FeatureStoreHelpers} may - * also be useful. + * only FeatureStoreCore and then create a CachingStoreWrapper. + *

    + * Note that these methods do not take any generic type parameters; all storeable entities are + * treated as implementations of the {@link VersionedData} interface, and a {@link VersionedDataKind} + * instance is used to specify what kind of entity is being referenced. If entities will be + * marshaled and unmarshaled, this must be done by reflection, using the type specified by + * {@link VersionedDataKind#getItemClass()}; the methods in {@link FeatureStoreHelpers} may be + * useful for this. * * @since 4.6.0 */ @@ -23,33 +29,31 @@ public interface FeatureStoreCore extends Closeable { * The method should not attempt to filter out any items based on their isDeleted() property, * nor to cache any items. * - * @param class of the object that will be returned * @param kind the kind of object to get * @param key the key whose associated object is to be returned * @return the object to which the specified key is mapped, or null */ - T getInternal(VersionedDataKind kind, String key); + VersionedData getInternal(VersionedDataKind kind, String key); /** * Returns a {@link java.util.Map} of all associated objects of a given kind. The method * should not attempt to filter out any items based on their isDeleted() property, nor to * cache any items. * - * @param class of the objects that will be returned in the map * @param kind the kind of objects to get - * @return a map of all associated object. + * @return a map of all associated objects. */ - Map getAllInternal(VersionedDataKind kind); + Map getAllInternal(VersionedDataKind kind); /** * Initializes (or re-initializes) the store with the specified set of objects. Any existing entries * will be removed. Implementations can assume that this set of objects is up to date-- there is no * need to perform individual version comparisons between the existing objects and the supplied - * features. + * data. * * @param allData all objects to be stored */ - void initInternal(Map, Map> allData); + void initInternal(Map, Map> allData); /** * Updates or inserts the object associated with the specified key. If an item with the same key @@ -59,18 +63,17 @@ public interface FeatureStoreCore extends Closeable { * then it returns the item that is currently in the data store (this ensures that * CachingStoreWrapper will update the cache correctly). * - * @param class of the object to be updated * @param kind the kind of object to update * @param item the object to update or insert * @return the state of the object after the update */ - T upsertInternal(VersionedDataKind kind, T item); + VersionedData upsertInternal(VersionedDataKind kind, VersionedData item); /** * Returns true if this store has been initialized. In a shared data store, it should be able to - * detect this even if initInternal was called in a different process,ni.e. the test should be + * detect this even if initInternal was called in a different process, i.e. the test should be * based on looking at what is in the data store. The method does not need to worry about caching - * this value; FeatureStoreWrapper will only call it when necessary. + * this value; CachingStoreWrapper will only call it when necessary. * * @return true if this store has been initialized */ diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index 222af5eb7..5b3bdf2f4 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -129,6 +129,17 @@ public void getAll() { assertEquals(feature2.getVersion(), item2.getVersion()); } + @Test + public void getAllWithDeletedItem() { + store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); + store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); + Map items = store.all(FEATURES); + assertEquals(1, items.size()); + FeatureFlag item2 = items.get(feature2.getKey()); + assertNotNull(item2); + assertEquals(feature2.getVersion(), item2.getVersion()); + } + @Test public void upsertWithNewerVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index 904de8de4..2f48313ca 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -88,7 +88,9 @@ public void cachedGetUsesValuesFromInit() { Map, Map> allData = makeData(item1, item2); wrapper.init(allData); - assertThat(core.data, equalTo(allData)); + core.forceRemove(THINGS, item1.key); + + assertThat(wrapper.get(THINGS, item1.key), equalTo(item1)); } @Test @@ -111,6 +113,18 @@ public void getAll() { assertThat(items, equalTo(expected1)); } } + + @Test + public void getAllRemovesDeletedItems() { + MockItem item1 = new MockItem("flag1", 1, false); + MockItem item2 = new MockItem("flag2", 1, true); + + core.forceSet(THINGS, item1); + core.forceSet(THINGS, item2); + Map items = wrapper.all(THINGS); + Map expected = ImmutableMap.of(item1.key, item1); + assertThat(items, equalTo(expected)); + } @Test public void cachedAllUsesValuesFromInit() { @@ -285,7 +299,7 @@ public void initializedCanCacheFalseResult() throws Exception { } static class MockCore implements FeatureStoreCore { - Map, Map> data = new HashMap<>(); + Map, Map> data = new HashMap<>(); boolean inited; int initedQueryCount; @@ -293,38 +307,32 @@ static class MockCore implements FeatureStoreCore { public void close() throws IOException { } - @SuppressWarnings("unchecked") @Override - public T getInternal(VersionedDataKind kind, String key) { + public VersionedData getInternal(VersionedDataKind kind, String key) { if (data.containsKey(kind)) { - return (T)data.get(kind).get(key); + return data.get(kind).get(key); } return null; } - @SuppressWarnings("unchecked") @Override - public Map getAllInternal(VersionedDataKind kind) { - return (Map)data.get(kind); + public Map getAllInternal(VersionedDataKind kind) { + return data.get(kind); } @Override - public void initInternal(Map, Map> allData) { - data = new HashMap<>(); - for (Map.Entry, Map> e: allData.entrySet()) { - data.put(e.getKey(), new HashMap<>(e.getValue())); - } + public void initInternal(Map, Map> allData) { + data = allData; inited = true; } - @SuppressWarnings("unchecked") @Override - public T upsertInternal(VersionedDataKind kind, T item) { + public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { if (!data.containsKey(kind)) { data.put(kind, new HashMap()); } - HashMap items = (HashMap)data.get(kind); - T oldItem = (T)items.get(item.getKey()); + Map items = data.get(kind); + VersionedData oldItem = items.get(item.getKey()); if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { return oldItem; } @@ -338,12 +346,11 @@ public boolean initializedInternal() { return inited; } - public void forceSet(VersionedDataKind kind, T item) { + public void forceSet(VersionedDataKind kind, VersionedData item) { if (!data.containsKey(kind)) { data.put(kind, new HashMap()); } - @SuppressWarnings("unchecked") - HashMap items = (HashMap)data.get(kind); + Map items = data.get(kind); items.put(item.getKey(), item); } From c453aac4cee8f08d06df18d357eaff67a587c4b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 12 Dec 2018 16:16:42 -0800 Subject: [PATCH 126/156] misc API cleanup --- ...hing.java => FeatureStoreCacheConfig.java} | 50 ++++++++++--------- .../client/RedisFeatureStore.java | 4 +- .../client/RedisFeatureStoreBuilder.java | 40 +++++++-------- .../client/utils/CachingStoreWrapper.java | 28 ++++++----- .../client/FeatureStoreCachingTest.java | 46 ++++++++--------- .../client/RedisFeatureStoreBuilderTest.java | 12 ++--- .../client/RedisFeatureStoreTest.java | 4 +- .../client/utils/CachingStoreWrapperTest.java | 8 +-- 8 files changed, 99 insertions(+), 93 deletions(-) rename src/main/java/com/launchdarkly/client/{FeatureStoreCaching.java => FeatureStoreCacheConfig.java} (78%) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java similarity index 78% rename from src/main/java/com/launchdarkly/client/FeatureStoreCaching.java rename to src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index be1b2bac8..2c92bee20 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCaching.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -8,6 +8,8 @@ /** * Parameters that can be used for {@link FeatureStore} implementations that support local caching. + * If a store implementation uses this class, then it is using the standard caching mechanism that + * is built into the SDK, and is guaranteed to support all the properties defined in this class. *

    * This is an immutable class that uses a fluent interface. Obtain an instance by calling the static * methods {@link #disabled()} or {@link #enabled()}; then, if desired, you can use chained methods @@ -16,17 +18,17 @@ *

      *     new RedisFeatureStoreBuilder()
      *         .caching(
    - *             FeatureStoreCaching.enabled()
    + *             FeatureStoreCacheConfig.enabled()
      *                 .ttlSeconds(30)
    - *                 .staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.REFRESH)
    + *                 .staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH)
      *         )
      * 
    * - * @see RedisFeatureStoreBuilder#caching(FeatureStoreCaching) - * @see CachingStoreWrapper.Builder#caching(FeatureStoreCaching) + * @see RedisFeatureStoreBuilder#caching(FeatureStoreCacheConfig) + * @see CachingStoreWrapper.Builder#caching(FeatureStoreCacheConfig) * @since 4.6.0 */ -public final class FeatureStoreCaching { +public final class FeatureStoreCacheConfig { /** * The default TTL, in seconds, used by {@link #DEFAULT}. */ @@ -36,18 +38,18 @@ public final class FeatureStoreCaching { * The caching parameters that feature store should use by default. Caching is enabled, with a * TTL of {@link #DEFAULT_TIME_SECONDS} and the {@link StaleValuesPolicy#EVICT} policy. */ - public static final FeatureStoreCaching DEFAULT = - new FeatureStoreCaching(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + public static final FeatureStoreCacheConfig DEFAULT = + new FeatureStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); - private static final FeatureStoreCaching DISABLED = - new FeatureStoreCaching(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); + private static final FeatureStoreCacheConfig DISABLED = + new FeatureStoreCacheConfig(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); private final long cacheTime; private final TimeUnit cacheTimeUnit; private final StaleValuesPolicy staleValuesPolicy; /** - * Possible values for {@link FeatureStoreCaching#staleValuesPolicy(StaleValuesPolicy)}. + * Possible values for {@link FeatureStoreCacheConfig#staleValuesPolicy(StaleValuesPolicy)}. */ public enum StaleValuesPolicy { /** @@ -94,9 +96,9 @@ public enum StaleValuesPolicy { /** * Returns a parameter object indicating that caching should be disabled. Specifying any additional * properties on this object will have no effect. - * @return a {@link FeatureStoreCaching} instance + * @return a {@link FeatureStoreCacheConfig} instance */ - public static FeatureStoreCaching disabled() { + public static FeatureStoreCacheConfig disabled() { return DISABLED; } @@ -104,13 +106,13 @@ public static FeatureStoreCaching disabled() { * Returns a parameter object indicating that caching should be enabled, using the default TTL of * {@link #DEFAULT_TIME_SECONDS}. You can further modify the cache properties using the other * methods of this class. - * @return a {@link FeatureStoreCaching} instance + * @return a {@link FeatureStoreCacheConfig} instance */ - public static FeatureStoreCaching enabled() { + public static FeatureStoreCacheConfig enabled() { return DEFAULT; } - private FeatureStoreCaching(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { + private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { this.cacheTime = cacheTime; this.cacheTimeUnit = cacheTimeUnit; this.staleValuesPolicy = staleValuesPolicy; @@ -118,7 +120,7 @@ private FeatureStoreCaching(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesP /** * Returns true if caching will be enabled. - * @return true if the cache TTL is great then 0 + * @return true if the cache TTL is greater than 0 */ public boolean isEnabled() { return getCacheTime() > 0; @@ -166,8 +168,8 @@ public StaleValuesPolicy getStaleValuesPolicy() { * @param timeUnit the time unit * @return an updated parameters object */ - public FeatureStoreCaching ttl(long cacheTime, TimeUnit timeUnit) { - return new FeatureStoreCaching(cacheTime, timeUnit, staleValuesPolicy); + public FeatureStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { + return new FeatureStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); } /** @@ -176,7 +178,7 @@ public FeatureStoreCaching ttl(long cacheTime, TimeUnit timeUnit) { * @param millis the cache TTL in milliseconds * @return an updated parameters object */ - public FeatureStoreCaching ttlMillis(long millis) { + public FeatureStoreCacheConfig ttlMillis(long millis) { return ttl(millis, TimeUnit.MILLISECONDS); } @@ -186,7 +188,7 @@ public FeatureStoreCaching ttlMillis(long millis) { * @param seconds the cache TTL in seconds * @return an updated parameters object */ - public FeatureStoreCaching ttlSeconds(long seconds) { + public FeatureStoreCacheConfig ttlSeconds(long seconds) { return ttl(seconds, TimeUnit.SECONDS); } @@ -197,14 +199,14 @@ public FeatureStoreCaching ttlSeconds(long seconds) { * @param policy a {@link StaleValuesPolicy} constant * @return an updated parameters object */ - public FeatureStoreCaching staleValuesPolicy(StaleValuesPolicy policy) { - return new FeatureStoreCaching(cacheTime, cacheTimeUnit, policy); + public FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { + return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); } @Override public boolean equals(Object other) { - if (other instanceof FeatureStoreCaching) { - FeatureStoreCaching o = (FeatureStoreCaching) other; + if (other instanceof FeatureStoreCacheConfig) { + FeatureStoreCacheConfig o = (FeatureStoreCacheConfig) other; return o.cacheTime == this.cacheTime && o.cacheTimeUnit == this.cacheTimeUnit && o.staleValuesPolicy == this.staleValuesPolicy; } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 94698ccfc..296dd0aa1 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -93,7 +93,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { builder.prefix; this.core = new Core(pool, prefix); - this.wrapper = new CachingStoreWrapper.Builder(this.core).caching(builder.caching) + this.wrapper = CachingStoreWrapper.builder(this.core).caching(builder.caching) .build(); } @@ -104,7 +104,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { public RedisFeatureStore() { JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost"); this.core = new Core(pool, RedisFeatureStoreBuilder.DEFAULT_PREFIX); - this.wrapper = new CachingStoreWrapper.Builder(this.core).build(); + this.wrapper = CachingStoreWrapper.builder(this.core).build(); } static class Core implements FeatureStoreCore { diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 497f5ac55..e277267ac 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -40,17 +40,17 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). - * @deprecated Use {@link FeatureStoreCaching#DEFAULT}. + * @deprecated Use {@link FeatureStoreCacheConfig#DEFAULT}. * @since 4.0.0 */ - public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCaching.DEFAULT_TIME_SECONDS; + public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; final URI uri; String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; - FeatureStoreCaching caching = FeatureStoreCaching.DEFAULT; - boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCaching, but are used by deprecated setters + FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters boolean asyncRefresh = false; JedisPoolConfig poolConfig = null; @@ -92,28 +92,28 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache /** * Specifies whether local caching should be enabled and if so, sets the cache properties. Local - * caching is enabled by default; see {@link FeatureStoreCaching#DEFAULT}. To disable it, pass - * {@link FeatureStoreCaching#disabled()} to this method. + * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass + * {@link FeatureStoreCacheConfig#disabled()} to this method. * - * @param caching a {@link FeatureStoreCaching} object specifying caching parameters + * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters * @return the builder * * @since 4.6.0 */ - public RedisFeatureStoreBuilder caching(FeatureStoreCaching caching) { + public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { this.caching = caching; return this; } /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH} - * or {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH_ASYNC}. + * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH} + * or {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. * * @param enabled turns on lazy refresh of cached values * @return the builder * - * @deprecated Use {@link #caching(FeatureStoreCaching)} and - * {@link FeatureStoreCaching#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy)}. + * @deprecated Use {@link #caching(FeatureStoreCacheConfig)} and + * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. */ public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { this.refreshStaleValues = enabled; @@ -122,14 +122,14 @@ public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { } /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCaching.StaleValuesPolicy#REFRESH_ASYNC}. + * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. * * @param enabled turns on asynchronous refresh of cached values (only if {@link #refreshStaleValues(boolean)} * is also true) * @return the builder * - * @deprecated Use {@link #caching(FeatureStoreCaching)} and - * {@link FeatureStoreCaching#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy)}. + * @deprecated Use {@link #caching(FeatureStoreCacheConfig)} and + * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. */ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { this.asyncRefresh = enabled; @@ -142,10 +142,10 @@ private void updateCachingStaleValuesPolicy() { // asyncRefresh is supposed to have no effect unless refreshStaleValues is true if (this.refreshStaleValues) { this.caching = this.caching.staleValuesPolicy(this.asyncRefresh ? - FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC : - FeatureStoreCaching.StaleValuesPolicy.REFRESH); + FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : + FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); } else { - this.caching = this.caching.staleValuesPolicy(FeatureStoreCaching.StaleValuesPolicy.EVICT); + this.caching = this.caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); } } @@ -162,13 +162,13 @@ public RedisFeatureStoreBuilder prefix(String prefix) { /** * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled - * by default; see {@link FeatureStoreCaching#DEFAULT}. + * by default; see {@link FeatureStoreCacheConfig#DEFAULT}. * * @param cacheTime the time value to cache for, or 0 to disable local caching * @param timeUnit the time unit for the time value * @return the builder * - * @deprecated use {@link #caching(FeatureStoreCaching)} and {@link FeatureStoreCaching#ttl(long, TimeUnit)}. + * @deprecated use {@link #caching(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { this.caching = this.caching.ttl(cacheTime, timeUnit) diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 541195d7b..48e995902 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -9,7 +9,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCaching; +import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; @@ -27,7 +27,7 @@ * other logic that would otherwise be repeated in every feature store implementation. This makes it * easier to create new database integrations by implementing only the database-specific logic. *

    - * Construct instances of this class with {@link CachingStoreWrapper.Builder}. + * Construct instances of this class with {@link CachingStoreWrapper#builder(FeatureStoreCore)}. * * @since 4.6.0 */ @@ -41,7 +41,15 @@ public class CachingStoreWrapper implements FeatureStore { private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; - protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCaching caching) { + /** + * Creates a new builder. + * @param core the {@link FeatureStoreCore} instance + */ + public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { + return new Builder(core); + } + + protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { this.core = core; if (!caching.isEnabled()) { @@ -89,7 +97,7 @@ public Boolean load(String key) throws Exception { ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); executorService = MoreExecutors.listeningDecorator(parentExecutor); - if (caching.getStaleValuesPolicy() == FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC) { + if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); } itemCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(itemLoader); @@ -262,22 +270,18 @@ public int hashCode() { */ public static class Builder { private final FeatureStoreCore core; - private FeatureStoreCaching caching = FeatureStoreCaching.DEFAULT; + private FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - /** - * Creates a new builder. - * @param core the {@link FeatureStoreCore} instance - */ - public Builder(FeatureStoreCore core) { + Builder(FeatureStoreCore core) { this.core = core; } /** * Sets the local caching properties. - * @param caching a {@link FeatureStoreCaching} object specifying cache parameters + * @param caching a {@link FeatureStoreCacheConfig} object specifying cache parameters * @return the builder */ - public Builder caching(FeatureStoreCaching caching) { + public Builder caching(FeatureStoreCacheConfig caching) { this.caching = caching; return this; } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java index 0911bc4ae..f8d15f517 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -4,16 +4,16 @@ import java.util.concurrent.TimeUnit; -import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.EVICT; -import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.REFRESH; -import static com.launchdarkly.client.FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC; +import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.EVICT; +import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH; +import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; public class FeatureStoreCachingTest { @Test public void disabledHasExpectedProperties() { - FeatureStoreCaching fsc = FeatureStoreCaching.disabled(); + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); assertThat(fsc.getCacheTime(), equalTo(0L)); assertThat(fsc.isEnabled(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -21,8 +21,8 @@ public void disabledHasExpectedProperties() { @Test public void enabledHasExpectedProperties() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled(); - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCaching.DEFAULT_TIME_SECONDS)); + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled(); + assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -30,8 +30,8 @@ public void enabledHasExpectedProperties() { @Test public void defaultIsEnabled() { - FeatureStoreCaching fsc = FeatureStoreCaching.DEFAULT; - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCaching.DEFAULT_TIME_SECONDS)); + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.DEFAULT; + assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -39,7 +39,7 @@ public void defaultIsEnabled() { @Test public void canSetTtl() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttl(3, TimeUnit.DAYS); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -49,7 +49,7 @@ public void canSetTtl() { @Test public void canSetTtlInMillis() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlMillis(3); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -59,7 +59,7 @@ public void canSetTtlInMillis() { @Test public void canSetTtlInSeconds() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlSeconds(3); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -69,21 +69,21 @@ public void canSetTtlInSeconds() { @Test public void zeroTtlMeansDisabled() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(0, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); } @Test public void negativeTtlMeansDisabled() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(-1, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); } @Test public void canSetStaleValuesPolicy() { - FeatureStoreCaching fsc = FeatureStoreCaching.enabled() + FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttlMillis(3) .staleValuesPolicy(REFRESH_ASYNC); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); @@ -93,27 +93,27 @@ public void canSetStaleValuesPolicy() { @Test public void equalityUsesTime() { - FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().ttlMillis(3); - FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().ttlMillis(3); - FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().ttlMillis(4); + FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); + FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); + FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlMillis(4); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } @Test public void equalityUsesTimeUnit() { - FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().ttlMillis(3); - FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().ttlMillis(3); - FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().ttlSeconds(3); + FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); + FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); + FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlSeconds(3); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } @Test public void equalityUsesStaleValuesPolicy() { - FeatureStoreCaching fsc1 = FeatureStoreCaching.enabled().staleValuesPolicy(EVICT); - FeatureStoreCaching fsc2 = FeatureStoreCaching.enabled().staleValuesPolicy(EVICT); - FeatureStoreCaching fsc3 = FeatureStoreCaching.enabled().staleValuesPolicy(REFRESH); + FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); + FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); + FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(REFRESH); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java index f6656f4d8..64fb15068 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java @@ -17,7 +17,7 @@ public class RedisFeatureStoreBuilderTest { public void testDefaultValues() { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCaching.DEFAULT, conf.caching); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); @@ -29,7 +29,7 @@ public void testConstructorSpecifyingUri() { URI uri = URI.create("redis://host:1234"); RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); assertEquals(uri, conf.uri); - assertEquals(FeatureStoreCaching.DEFAULT, conf.caching); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); @@ -45,7 +45,7 @@ public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { assertEquals(TimeUnit.SECONDS, conf.caching.getCacheTimeUnit()); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(FeatureStoreCaching.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @@ -54,21 +54,21 @@ public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { @Test public void testRefreshStaleValues() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true); - assertEquals(FeatureStoreCaching.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); } @SuppressWarnings("deprecation") @Test public void testAsyncRefresh() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true).asyncRefresh(true); - assertEquals(FeatureStoreCaching.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); } @SuppressWarnings("deprecation") @Test public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().asyncRefresh(true); - assertEquals(FeatureStoreCaching.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); } @Test diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index 16d0cd38f..9299bf269 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -17,13 +17,13 @@ public RedisFeatureStoreTest(boolean cached) { @Override protected RedisFeatureStore makeStore() { RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); - builder.caching(cached ? FeatureStoreCaching.enabled().ttlSeconds(30) : FeatureStoreCaching.disabled()); + builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); return builder.build(); } @Override protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCaching.disabled()).prefix(prefix).build(); + return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); } @Override diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index 2f48313ca..077dc09f8 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.utils; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.FeatureStoreCaching; +import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; @@ -36,8 +36,8 @@ public static Iterable data() { public CachingStoreWrapperTest(boolean cached) { this.cached = cached; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCaching.enabled().ttlSeconds(30) : - FeatureStoreCaching.disabled()); + this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : + FeatureStoreCacheConfig.disabled()); } @Test @@ -270,7 +270,7 @@ public void initializedCanCacheFalseResult() throws Exception { assumeThat(cached, is(true)); // We need to create a different object for this test so we can set a short cache TTL - try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, FeatureStoreCaching.enabled().ttlMillis(500))) { + try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { assertThat(wrapper1.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); From b17a239f4b8105bfefdcdab9db862071af0ea99b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 12 Dec 2018 17:03:21 -0800 Subject: [PATCH 127/156] use EventSource snapshot prior to release --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3175e9155..84b490ef3 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.7.1", + "com.launchdarkly:okhttp-eventsource:1.9.0-SNAPSHOT", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] From e02d97c5407912ebf6b76a926f550988a945ea65 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 12 Dec 2018 17:40:21 -0800 Subject: [PATCH 128/156] use EventSource 1.9.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 84b490ef3..fffb691c1 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.9.0-SNAPSHOT", + "com.launchdarkly:okhttp-eventsource:1.9.0", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] From f4413fbdf8b9f8d9242088c647e019116509035b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 12 Dec 2018 17:44:54 -0800 Subject: [PATCH 129/156] fix broken unit test --- src/test/java/com/launchdarkly/client/LDClientTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index b95b070e3..cd0d3ec47 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -71,7 +71,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() .stream(true) - .streamURI(URI.create("/fake")) + .streamURI(URI.create("http://fake")) .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -83,7 +83,7 @@ public void streamingClientHasStreamProcessor() throws Exception { public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() .stream(false) - .baseURI(URI.create("/fake")) + .baseURI(URI.create("http://fake")) .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { From d5d822f41d38994715570c8f4015848a52cd3a11 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 8 Jan 2019 14:38:13 -0800 Subject: [PATCH 130/156] implement dependency ordering for feature store data --- .../com/launchdarkly/client/FeatureStore.java | 10 +- .../client/FeatureStoreClientWrapper.java | 54 ++++++++++ .../client/FeatureStoreDataSetSorter.java | 81 +++++++++++++++ .../client/InMemoryFeatureStore.java | 6 +- .../com/launchdarkly/client/LDClient.java | 6 +- .../client/VersionedDataKind.java | 98 +++++++++++++++---- .../client/utils/CachingStoreWrapper.java | 11 +-- .../client/utils/FeatureStoreCore.java | 5 + .../com/launchdarkly/client/LDClientTest.java | 81 +++++++++++++++ .../com/launchdarkly/client/TestUtil.java | 21 ++++ .../client/utils/CachingStoreWrapperTest.java | 6 +- 11 files changed, 345 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java create mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java index 62469896c..ca5949526 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/FeatureStore.java @@ -43,7 +43,15 @@ public interface FeatureStore extends Closeable { * will be removed. Implementations can assume that this set of objects is up to date-- there is no * need to perform individual version comparisons between the existing objects and the supplied * features. - * + *

    + * If possible, the store should update the entire data set atomically. If that is not possible, it + * should iterate through the outer map and then the inner map in the order provided (the SDK + * will use a Map subclass that has a defined ordering), storing each item, and then delete any + * leftover items at the very end. + *

    + * The store should not attempt to modify any of the Maps, and if it needs to retain the data in + * memory it should copy the Maps. + * * @param allData all objects to be stored */ void init(Map, Map> allData); diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java new file mode 100644 index 000000000..5c2bba097 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java @@ -0,0 +1,54 @@ +package com.launchdarkly.client; + +import java.io.IOException; +import java.util.Map; + +/** + * Provides additional behavior that the client requires before or after feature store operations. + * Currently this just means sorting the data set for init(). In the future we may also use this + * to provide an update listener capability. + * + * @since 4.6.1 + */ +class FeatureStoreClientWrapper implements FeatureStore { + private final FeatureStore store; + + public FeatureStoreClientWrapper(FeatureStore store) { + this.store = store; + } + + @Override + public void init(Map, Map> allData) { + store.init(FeatureStoreDataSetSorter.sortAllCollections(allData)); + } + + @Override + public T get(VersionedDataKind kind, String key) { + return store.get(kind, key); + } + + @Override + public Map all(VersionedDataKind kind) { + return store.all(kind); + } + + @Override + public void delete(VersionedDataKind kind, String key, int version) { + store.delete(kind, key, version); + } + + @Override + public void upsert(VersionedDataKind kind, T item) { + store.upsert(kind, item); + } + + @Override + public boolean initialized() { + return store.initialized(); + } + + @Override + public void close() throws IOException { + store.close(); + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java new file mode 100644 index 000000000..c894f6d5a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java @@ -0,0 +1,81 @@ +package com.launchdarkly.client; + +import com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSortedMap; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; + +/** + * Implements a dependency graph ordering for data to be stored in a feature store. We must use this + * on every data set that will be passed to {@link com.launchdarkly.client.FeatureStore#init(Map)}. + * + * @since 4.6.1 + */ +abstract class FeatureStoreDataSetSorter { + /** + * Returns a copy of the input map that has the following guarantees: the iteration order of the outer + * map will be in ascending order by {@link VersionedDataKind#getPriority()}; and for each data kind + * that returns a non-null function for {@link VersionedDataKind#getDependencyKeysFunction()}, the + * inner map will have an iteration order where B is before A if A has a dependency on B. + * + * @param allData the unordered data set + * @return a map with a defined ordering + */ + public static Map, Map> sortAllCollections( + Map, Map> allData) { + ImmutableSortedMap.Builder, Map> builder = + ImmutableSortedMap.orderedBy(dataKindPriorityOrder); + for (Map.Entry, Map> entry: allData.entrySet()) { + VersionedDataKind kind = entry.getKey(); + builder.put(kind, sortCollection(kind, entry.getValue())); + } + return builder.build(); + } + + private static Map sortCollection(VersionedDataKind kind, Map input) { + Function> dependenciesGetter = kind.getDependencyKeysFunction(); + if (dependenciesGetter == null || input.isEmpty()) { + return input; + } + + Map remainingItems = new HashMap<>(input); + ImmutableMap.Builder builder = ImmutableMap.builder(); + // Note, ImmutableMap guarantees that the iteration order will be the same as the builder insertion order + + while (!remainingItems.isEmpty()) { + // pick a random item that hasn't been updated yet + for (Map.Entry entry: remainingItems.entrySet()) { + addWithDependenciesFirst(entry.getValue(), remainingItems, builder, dependenciesGetter); + break; + } + } + + return builder.build(); + } + + private static void addWithDependenciesFirst(VersionedData item, + Map remainingItems, + ImmutableMap.Builder builder, + Function> dependenciesGetter) { + remainingItems.remove(item.getKey()); // we won't need to visit this item again + for (String prereqKey: dependenciesGetter.apply(item)) { + VersionedData prereqItem = remainingItems.get(prereqKey); + if (prereqItem != null) { + addWithDependenciesFirst(prereqItem, remainingItems, builder, dependenciesGetter); + } + } + builder.put(item.getKey(), item); + } + + private static Comparator> dataKindPriorityOrder = new Comparator>() { + @Override + public int compare(VersionedDataKind o1, VersionedDataKind o2) { + return o1.getPriority() - o2.getPriority(); + } + }; +} diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index c6eee1963..b8db96e3a 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -69,14 +69,16 @@ public Map all(VersionedDataKind kind) { } } - @SuppressWarnings("unchecked") @Override public void init(Map, Map> allData) { try { lock.writeLock().lock(); this.allData.clear(); for (Map.Entry, Map> entry: allData.entrySet()) { - this.allData.put(entry.getKey(), (Map)entry.getValue()); + // Note, the FeatureStore contract specifies that we should clone all of the maps. This doesn't + // really make a difference in regular use of the SDK, but not doing it could cause unexpected + // behavior in tests. + this.allData.put(entry.getKey(), new HashMap(entry.getValue())); } initialized = true; } finally { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 67da07f3d..2429cde89 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -61,8 +61,9 @@ public LDClient(String sdkKey, LDConfig config) { this.config = config; this.sdkKey = sdkKey; + FeatureStore store; if (config.deprecatedFeatureStore != null) { - this.featureStore = config.deprecatedFeatureStore; + store = config.deprecatedFeatureStore; // The following line is for backward compatibility with the obsolete mechanism by which the // caller could pass in a FeatureStore implementation instance that we did not create. We // were not disposing of that instance when the client was closed, so we should continue not @@ -72,9 +73,10 @@ public LDClient(String sdkKey, LDConfig config) { } else { FeatureStoreFactory factory = config.featureStoreFactory == null ? Components.inMemoryFeatureStore() : config.featureStoreFactory; - this.featureStore = factory.createFeatureStore(); + store = factory.createFeatureStore(); this.shouldCloseFeatureStore = true; } + this.featureStore = new FeatureStoreClientWrapper(store); EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory; diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java index f4aba3f7e..f70c9ca47 100644 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/VersionedDataKind.java @@ -1,7 +1,10 @@ package com.launchdarkly.client; +import com.google.common.base.Function; import com.google.common.collect.ImmutableList; +import static com.google.common.collect.Iterables.transform; + /** * The descriptor for a specific kind of {@link VersionedData} objects that may exist in a {@link FeatureStore}. * You will not need to refer to this type unless you are directly manipulating a {@code FeatureStore} @@ -9,6 +12,7 @@ * maximum forward compatibility you should only refer to {@link VersionedData}, {@link VersionedDataKind}, * and {@link VersionedDataKind#ALL}, and avoid any dependencies on specific type descriptor instances * or any specific fields of the types they describe. + * @param the item type * @since 3.0.0 */ public abstract class VersionedDataKind { @@ -39,6 +43,37 @@ public abstract class VersionedDataKind { */ public abstract T makeDeletedItem(String key, int version); + /** + * Used internally to determine the order in which collections are updated. The default value is + * arbitrary; the built-in data kinds override it for specific data model reasons. + * + * @return a zero-based integer; collections with a lower priority are updated first + * @since 4.7.0 + */ + public int getPriority() { + return getNamespace().length() + 10; + } + + /** + * Returns a function for getting all keys of items that this one directly depends on, if this + * kind of item can have dependencies. Returns null otherwise. + *

    + * Note that this does not use the generic type T, because it is called from code that only knows + * about VersionedData, so it will need to do a type cast. However, it can rely on the item being + * of the correct class. + * + * @return a function for getting the dependencies of an item + * @since 4.7.0 + */ + public Function> getDependencyKeysFunction() { + return null; + } + + @Override + public String toString() { + return "{" + getNamespace() + "}"; + } + /** * Used internally to match data URLs in the streaming API. * @param path path from an API message @@ -48,44 +83,65 @@ String getKeyFromStreamApiPath(String path) { return path.startsWith(getStreamApiPath()) ? path.substring(getStreamApiPath().length()) : null; } - /** - * The {@link VersionedDataKind} instance that describes feature flag data. - */ - public static VersionedDataKind FEATURES = new VersionedDataKind() { + static abstract class Impl extends VersionedDataKind { + private final String namespace; + private final Class itemClass; + private final String streamApiPath; + private final int priority; + + Impl(String namespace, Class itemClass, String streamApiPath, int priority) { + this.namespace = namespace; + this.itemClass = itemClass; + this.streamApiPath = streamApiPath; + this.priority = priority; + } public String getNamespace() { - return "features"; + return namespace; } - public Class getItemClass() { - return FeatureFlag.class; + public Class getItemClass() { + return itemClass; } public String getStreamApiPath() { - return "/flags/"; + return streamApiPath; } + public int getPriority() { + return priority; + } + } + + /** + * The {@link VersionedDataKind} instance that describes feature flag data. + */ + public static VersionedDataKind FEATURES = new Impl("features", FeatureFlag.class, "/flags/", 1) { public FeatureFlag makeDeletedItem(String key, int version) { return new FeatureFlagBuilder(key).deleted(true).version(version).build(); } + + public Function> getDependencyKeysFunction() { + return new Function>() { + public Iterable apply(VersionedData item) { + FeatureFlag flag = (FeatureFlag)item; + if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { + return ImmutableList.of(); + } + return transform(flag.getPrerequisites(), new Function() { + public String apply(Prerequisite p) { + return p.getKey(); + } + }); + } + }; + } }; /** * The {@link VersionedDataKind} instance that describes user segment data. */ - public static VersionedDataKind SEGMENTS = new VersionedDataKind() { - - public String getNamespace() { - return "segments"; - } - - public Class getItemClass() { - return Segment.class; - } - - public String getStreamApiPath() { - return "/segments/"; - } + public static VersionedDataKind SEGMENTS = new Impl("segments", Segment.class, "/segments/", 0) { public Segment makeDeletedItem(String key, int version) { return new Segment.Builder(key).deleted(true).version(version).build(); diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 48e995902..b1680712e 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -44,6 +44,7 @@ public class CachingStoreWrapper implements FeatureStore { /** * Creates a new builder. * @param core the {@link FeatureStoreCore} instance + * @return the builder */ public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { return new Builder(core); @@ -143,19 +144,15 @@ public Map all(VersionedDataKind kind) { @SuppressWarnings("unchecked") @Override public void init(Map, Map> allData) { - Map, Map> params = new HashMap, Map>(); - for (Map.Entry, Map> e0: allData.entrySet()) { - // unfortunately this is necessary because we can't just cast to a map with a different type signature in this case - params.put(e0.getKey(), (Map)e0.getValue()); - } - core.initInternal(params); + Map, Map> castMap = // silly generic wildcard problem + (Map, Map>)((Map)allData); inited.set(true); if (allCache != null && itemCache != null) { allCache.invalidateAll(); itemCache.invalidateAll(); - for (Map.Entry, Map> e0: params.entrySet()) { + for (Map.Entry, Map> e0: castMap.entrySet()) { VersionedDataKind kind = e0.getKey(); allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); for (Map.Entry e1: e0.getValue().entrySet()) { diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java index d7d57a9bd..a04c09a00 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -50,6 +50,11 @@ public interface FeatureStoreCore extends Closeable { * will be removed. Implementations can assume that this set of objects is up to date-- there is no * need to perform individual version comparisons between the existing objects and the supplied * data. + *

    + * If possible, the store should update the entire data set atomically. If that is not possible, it + * should iterate through the outer map and then the inner map in the order provided (the SDK + * will use a Map subclass that has a defined ordering), storing each item, and then delete any + * leftover items at the very end. * * @param allData all objects to be stored */ diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index cd0d3ec47..158977fad 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -1,11 +1,21 @@ package com.launchdarkly.client; +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; + +import org.easymock.Capture; +import org.easymock.EasyMock; import org.easymock.EasyMockSupport; import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.net.URI; +import java.util.List; +import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -14,13 +24,17 @@ import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.updateProcessorWithData; import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import junit.framework.AssertionFailedError; @@ -240,6 +254,54 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep verifyAll(); } + @Test + public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { + // This verifies that the client is using FeatureStoreClientWrapper and that it is applying the + // correct ordering for flag prerequisites, etc. This should work regardless of what kind of + // UpdateProcessor we're using. + + Capture, Map>> captureData = Capture.newInstance(); + FeatureStore store = createStrictMock(FeatureStore.class); + store.init(EasyMock.capture(captureData)); + replay(store); + + LDConfig.Builder config = new LDConfig.Builder() + .updateProcessorFactory(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) + .featureStoreFactory(specificFeatureStore(store)) + .sendEvents(false); + client = new LDClient("SDK_KEY", config.build()); + + Map, Map> dataMap = captureData.getValue(); + assertEquals(2, dataMap.size()); + + // Segments should always come first + assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); + assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(SEGMENTS).size(), Iterables.get(dataMap.values(), 0).size()); + + // Features should be ordered so that a flag always appears after its prerequisites, if any + assertEquals(FEATURES, Iterables.get(dataMap.keySet(), 1)); + Map map1 = Iterables.get(dataMap.values(), 1); + List list1 = ImmutableList.copyOf(map1.values()); + assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(FEATURES).size(), map1.size()); + for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { + FeatureFlag item = (FeatureFlag)list1.get(itemIndex); + for (Prerequisite prereq: item.getPrerequisites()) { + FeatureFlag depFlag = (FeatureFlag)map1.get(prereq.getKey()); + int depIndex = list1.indexOf(depFlag); + if (depIndex > itemIndex) { + Iterable allKeys = Iterables.transform(list1, new Function() { + public String apply(VersionedData d) { + return d.getKey(); + } + }); + fail(String.format("%s depends on %s, but %s was listed first; keys in order are [%s]", + item.getKey(), prereq.getKey(), item.getKey(), + Joiner.on(", ").join(allKeys))); + } + } + } + } + private void expectEventsSent(int count) { eventProcessor.sendEvent(anyObject(Event.class)); if (count > 0) { @@ -254,4 +316,23 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } + + private static Map, Map> DEPENDENCY_ORDERING_TEST_DATA = + ImmutableMap., Map>of( + FEATURES, + ImmutableMap.builder() + .put("a", new FeatureFlagBuilder("a") + .prerequisites(ImmutableList.of(new Prerequisite("b", 0), new Prerequisite("c", 0))).build()) + .put("b", new FeatureFlagBuilder("b") + .prerequisites(ImmutableList.of(new Prerequisite("c", 0), new Prerequisite("e", 0))).build()) + .put("c", new FeatureFlagBuilder("c").build()) + .put("d", new FeatureFlagBuilder("d").build()) + .put("e", new FeatureFlagBuilder("e").build()) + .put("f", new FeatureFlagBuilder("f").build()) + .build(), + SEGMENTS, + ImmutableMap.of( + "o", new Segment.Builder("o").build() + ) + ); } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index c37ff6549..422db127d 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -52,6 +53,26 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea }; } + public static UpdateProcessorFactory updateProcessorWithData(final Map, Map> data) { + return new UpdateProcessorFactory() { + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, final FeatureStore featureStore) { + return new UpdateProcessor() { + public Future start() { + featureStore.init(data); + return Futures.immediateFuture(null); + } + + public boolean initialized() { + return true; + } + + public void close() throws IOException { + } + }; + } + }; + } + public static FeatureStore featureStoreThatThrowsException(final RuntimeException e) { return new FeatureStore() { @Override diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index 077dc09f8..ff9a75d6a 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; @@ -322,7 +323,10 @@ public Map getAllInternal(VersionedDataKind kind) { @Override public void initInternal(Map, Map> allData) { - data = allData; + data.clear(); + for (Map.Entry, Map> entry: allData.entrySet()) { + data.put(entry.getKey(), new LinkedHashMap(entry.getValue())); + } inited = true; } From 746bf56b34497db1be2289d110843d0e8fbb2c13 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 8 Jan 2019 15:32:10 -0800 Subject: [PATCH 131/156] fix accidental deletion --- .../com/launchdarkly/client/utils/CachingStoreWrapper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index b1680712e..e2e5fa144 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -146,7 +146,8 @@ public Map all(VersionedDataKind kind) { public void init(Map, Map> allData) { Map, Map> castMap = // silly generic wildcard problem (Map, Map>)((Map)allData); - + core.initInternal(castMap); + inited.set(true); if (allCache != null && itemCache != null) { From b5677ef4a5dae088c5c18e6d9befa6e75e675ef3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 8 Jan 2019 16:22:29 -0800 Subject: [PATCH 132/156] simplify ordering logic --- .../client/FeatureStoreDataSetSorter.java | 22 ++++----- .../client/VersionedDataKind.java | 48 ++++++++++++------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java index c894f6d5a..d2ae25fc3 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java @@ -1,10 +1,7 @@ package com.launchdarkly.client; -import com.google.common.base.Function; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSortedMap; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; import java.util.Comparator; import java.util.HashMap; @@ -20,8 +17,8 @@ abstract class FeatureStoreDataSetSorter { /** * Returns a copy of the input map that has the following guarantees: the iteration order of the outer * map will be in ascending order by {@link VersionedDataKind#getPriority()}; and for each data kind - * that returns a non-null function for {@link VersionedDataKind#getDependencyKeysFunction()}, the - * inner map will have an iteration order where B is before A if A has a dependency on B. + * that returns true for {@link VersionedDataKind#isDependencyOrdered()}, the inner map will have an + * iteration order where B is before A if A has a dependency on B. * * @param allData the unordered data set * @return a map with a defined ordering @@ -38,8 +35,7 @@ abstract class FeatureStoreDataSetSorter { } private static Map sortCollection(VersionedDataKind kind, Map input) { - Function> dependenciesGetter = kind.getDependencyKeysFunction(); - if (dependenciesGetter == null || input.isEmpty()) { + if (!kind.isDependencyOrdered() || input.isEmpty()) { return input; } @@ -50,7 +46,7 @@ abstract class FeatureStoreDataSetSorter { while (!remainingItems.isEmpty()) { // pick a random item that hasn't been updated yet for (Map.Entry entry: remainingItems.entrySet()) { - addWithDependenciesFirst(entry.getValue(), remainingItems, builder, dependenciesGetter); + addWithDependenciesFirst(kind, entry.getValue(), remainingItems, builder); break; } } @@ -58,15 +54,15 @@ abstract class FeatureStoreDataSetSorter { return builder.build(); } - private static void addWithDependenciesFirst(VersionedData item, + private static void addWithDependenciesFirst(VersionedDataKind kind, + VersionedData item, Map remainingItems, - ImmutableMap.Builder builder, - Function> dependenciesGetter) { + ImmutableMap.Builder builder) { remainingItems.remove(item.getKey()); // we won't need to visit this item again - for (String prereqKey: dependenciesGetter.apply(item)) { + for (String prereqKey: kind.getDependencyKeys(item)) { VersionedData prereqItem = remainingItems.get(prereqKey); if (prereqItem != null) { - addWithDependenciesFirst(prereqItem, remainingItems, builder, dependenciesGetter); + addWithDependenciesFirst(kind, prereqItem, remainingItems, builder); } } builder.put(item.getKey(), item); diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java index f70c9ca47..16cf1badc 100644 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/VersionedDataKind.java @@ -55,18 +55,30 @@ public int getPriority() { } /** - * Returns a function for getting all keys of items that this one directly depends on, if this - * kind of item can have dependencies. Returns null otherwise. + * Returns true if the SDK needs to store items of this kind in an order that is based on + * {@link #getDependencyKeys(VersionedData)}. + * + * @return true if dependency ordering should be used + * @since 4.7.0 + */ + public boolean isDependencyOrdered() { + return false; + } + + /** + * Gets all keys of items that this one directly depends on, if this kind of item can have + * dependencies. *

    * Note that this does not use the generic type T, because it is called from code that only knows * about VersionedData, so it will need to do a type cast. However, it can rely on the item being * of the correct class. * - * @return a function for getting the dependencies of an item + * @param item the item + * @return keys of dependencies of the item * @since 4.7.0 */ - public Function> getDependencyKeysFunction() { - return null; + public Iterable getDependencyKeys(VersionedData item) { + return ImmutableList.of(); } @Override @@ -121,20 +133,20 @@ public FeatureFlag makeDeletedItem(String key, int version) { return new FeatureFlagBuilder(key).deleted(true).version(version).build(); } - public Function> getDependencyKeysFunction() { - return new Function>() { - public Iterable apply(VersionedData item) { - FeatureFlag flag = (FeatureFlag)item; - if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { - return ImmutableList.of(); - } - return transform(flag.getPrerequisites(), new Function() { - public String apply(Prerequisite p) { - return p.getKey(); - } - }); + public boolean isDependencyOrdered() { + return true; + } + + public Iterable getDependencyKeys(VersionedData item) { + FeatureFlag flag = (FeatureFlag)item; + if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { + return ImmutableList.of(); + } + return transform(flag.getPrerequisites(), new Function() { + public String apply(Prerequisite p) { + return p.getKey(); } - }; + }); } }; From 3151b34ce2908171ce373c59e7773f6225af9078 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Jan 2019 11:52:35 -0800 Subject: [PATCH 133/156] javadoc fix --- src/main/java/com/launchdarkly/client/FeatureStore.java | 2 +- .../java/com/launchdarkly/client/utils/FeatureStoreCore.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java index ca5949526..0ea551299 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/FeatureStore.java @@ -45,7 +45,7 @@ public interface FeatureStore extends Closeable { * features. *

    * If possible, the store should update the entire data set atomically. If that is not possible, it - * should iterate through the outer map and then the inner map in the order provided (the SDK + * should iterate through the outer map and then the inner map in the order provided (the SDK * will use a Map subclass that has a defined ordering), storing each item, and then delete any * leftover items at the very end. *

    diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java index a04c09a00..b4d2e3066 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -52,7 +52,7 @@ public interface FeatureStoreCore extends Closeable { * data. *

    * If possible, the store should update the entire data set atomically. If that is not possible, it - * should iterate through the outer map and then the inner map in the order provided (the SDK + * should iterate through the outer map and then the inner map in the order provided (the SDK * will use a Map subclass that has a defined ordering), storing each item, and then delete any * leftover items at the very end. * From d874798533276f6567623bcb694dde66baf63876 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Jan 2019 17:30:06 -0800 Subject: [PATCH 134/156] reset changelog - release error --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c37d16f..c9531c114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,6 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). -## [4.6.1] - 2019-01-14 -### Fixed -- Fixed a potential race condition that could happen when using a DynamoDB or Consul feature store. The Redis feature store was not affected. - ## [4.6.0] - 2018-12-12 ### Added: - The SDK jars now contain OSGi manifests which should make it possible to use them as bundles. The default jar requires Gson and SLF4J to be provided by other bundles, while the jar with the "all" classifier contains versions of Gson and SLF4J which it both exports and imports (i.e. it self-wires them, so it will use a higher version if you provide one). The "thin" jar is not recommended in an OSGi environment because it requires many dependencies which may not be available as bundles. From 0464fc8b7506594c5e0f843965a1bd92852e7ba5 Mon Sep 17 00:00:00 2001 From: Harpo roeder Date: Tue, 5 Feb 2019 11:17:39 -0800 Subject: [PATCH 135/156] add pipeline --- azure-pipelines.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..54a51ff98 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,26 @@ +jobs: + - job: build + pool: + vmImage: 'vs2017-win2016' + steps: + - task: PowerShell@2 + displayName: 'Setup Redis' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip + mkdir redis + Expand-Archive -Path redis.zip -DestinationPath redis + cd redis + ./redis-server --service-install + ./redis-server --service-start + - task: PowerShell@2 + displayName: 'Setup SDK and Test' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + cp gradle.properties.example gradle.properties + ./gradlew.bat dependencies + ./gradlew.bat test From 91c846be10e1da82af81228fb15ee4aebc26bdf2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 Feb 2019 11:50:30 -0800 Subject: [PATCH 136/156] fix tool download for packaging tests --- packaging-test/Makefile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index df0577b6f..b6d0c349e 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -26,9 +26,11 @@ TEST_APP_JAR=$(TEMP_DIR)/test-app.jar # SLF4j implementation - we need to download this separately because it's not in the SDK dependencies SLF4J_SIMPLE_JAR=$(TEMP_DIR)/test-slf4j-simple.jar +SLF4J_SIMPLE_JAR_URL=https://oss.sonatype.org/content/groups/public/org/slf4j/slf4j-simple/1.7.21/slf4j-simple-1.7.21.jar # Felix OSGi container -FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.1.tar.gz +FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.2.tar.gz +FELIX_ARCHIVE_URL=http://apache.mirrors.ionfish.org//felix/$(FELIX_ARCHIVE) FELIX_DIR=$(TEMP_DIR)/felix FELIX_JAR=$(FELIX_DIR)/bin/felix.jar TEMP_BUNDLE_DIR=$(TEMP_DIR)/bundles @@ -143,10 +145,10 @@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all rm $@/gson*.jar $@/slf4j*.jar $(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) - curl https://oss.sonatype.org/content/groups/public/org/slf4j/slf4j-simple/1.7.21/slf4j-simple-1.7.21.jar >$@ + curl $(SLF4J_SIMPLE_JAR_URL) >$@ $(FELIX_JAR): | $(TEMP_DIR) - curl http://ftp.naz.com/apache//felix/$(FELIX_ARCHIVE) >$(TEMP_DIR)/$(FELIX_ARCHIVE) + curl $(FELIX_ARCHIVE_URL) >$(TEMP_DIR)/$(FELIX_ARCHIVE) cd $(TEMP_DIR) && tar xfz $(FELIX_ARCHIVE) && rm $(FELIX_ARCHIVE) cd $(TEMP_DIR) && mv `ls -d felix*` felix From 9326cf53c4e9a37ed865c3a609dbe662410776f7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 Feb 2019 17:13:02 -0800 Subject: [PATCH 137/156] add experimentation event overrides for rules and fallthrough --- .../com/launchdarkly/client/EventFactory.java | 57 +++++++-- .../com/launchdarkly/client/FeatureFlag.java | 9 +- .../client/FeatureFlagBuilder.java | 9 +- .../java/com/launchdarkly/client/Rule.java | 16 ++- .../client/LDClientEventTest.java | 113 ++++++++++++++++++ .../com/launchdarkly/client/RuleBuilder.java | 44 +++++++ .../com/launchdarkly/client/TestUtil.java | 8 ++ 7 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/RuleBuilder.java diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 13559cf97..0038c8c9c 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -9,11 +9,29 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement value, + Integer variationIndex, EvaluationReason reason, JsonElement defaultValue, String prereqOf) { + boolean requireExperimentData = isExperiment(flag, reason); + return new Event.FeatureRequest( + getTimestamp(), + flag.getKey(), + user, + flag.getVersion(), + variationIndex, + value, + defaultValue, + prereqOf, + requireExperimentData || flag.isTrackEvents(), + flag.getDebugEventsUntilDate(), + (requireExperimentData || isIncludeReasons()) ? reason : null, + false + ); + } + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { - return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), - isIncludeReasons() ? result.getReason() : null, false); + return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), + defaultVal, null); } public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue, @@ -31,10 +49,9 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { - return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), - isIncludeReasons() ? result.getReason() : null, false); + return newFeatureRequestEvent(prereqFlag, user, result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), + null, prereqOf.getKey()); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { @@ -50,6 +67,30 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } + private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { + switch (reason.getKind()) { + case FALLTHROUGH: + return flag.isTrackEventsFallthrough(); + case RULE_MATCH: + if (!(reason instanceof EvaluationReason.RuleMatch)) { + // shouldn't be possible + return false; + } + EvaluationReason.RuleMatch rm = (EvaluationReason.RuleMatch)reason; + int ruleIndex = rm.getRuleIndex(); + // Note, it is OK to rely on the rule index rather than the unique ID in this context, because the + // FeatureFlag that is passed to us here *is* necessarily the same version of the flag that was just + // evaluated, so we cannot be out of sync with its rule list. + if (ruleIndex >= 0 && ruleIndex < flag.getRules().size()) { + Rule rule = flag.getRules().get(ruleIndex); + return rule.isTrackEvents(); + } + return false; + default: + return false; + } + } + public static class DefaultEventFactory extends EventFactory { private final boolean includeReasons; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 7cc7dde91..f05f9c519 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -32,6 +32,7 @@ class FeatureFlag implements VersionedData { private List variations; private boolean clientSide; private boolean trackEvents; + private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; @@ -48,7 +49,8 @@ static Map fromJsonMap(LDConfig config, String json) { FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, - boolean clientSide, boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { + boolean clientSide, boolean trackEvents, boolean trackEventsFallthrough, + Long debugEventsUntilDate, boolean deleted) { this.key = key; this.version = version; this.on = on; @@ -61,6 +63,7 @@ static Map fromJsonMap(LDConfig config, String json) { this.variations = variations; this.clientSide = clientSide; this.trackEvents = trackEvents; + this.trackEventsFallthrough = trackEventsFallthrough; this.debugEventsUntilDate = debugEventsUntilDate; this.deleted = deleted; } @@ -178,6 +181,10 @@ public boolean isTrackEvents() { return trackEvents; } + public boolean isTrackEventsFallthrough() { + return trackEventsFallthrough; + } + public Long getDebugEventsUntilDate() { return debugEventsUntilDate; } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index 2d7d86832..e4111ab24 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -19,6 +19,7 @@ class FeatureFlagBuilder { private List variations = new ArrayList<>(); private boolean clientSide; private boolean trackEvents; + private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; @@ -40,6 +41,7 @@ class FeatureFlagBuilder { this.variations = f.getVariations(); this.clientSide = f.isClientSide(); this.trackEvents = f.isTrackEvents(); + this.trackEventsFallthrough = f.isTrackEventsFallthrough(); this.debugEventsUntilDate = f.getDebugEventsUntilDate(); this.deleted = f.isDeleted(); } @@ -103,6 +105,11 @@ FeatureFlagBuilder trackEvents(boolean trackEvents) { this.trackEvents = trackEvents; return this; } + + FeatureFlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { + this.trackEventsFallthrough = trackEventsFallthrough; + return this; + } FeatureFlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { this.debugEventsUntilDate = debugEventsUntilDate; @@ -116,6 +123,6 @@ FeatureFlagBuilder deleted(boolean deleted) { FeatureFlag build() { return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - clientSide, trackEvents, debugEventsUntilDate, deleted); + clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index cee3d7ae0..799340791 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -10,22 +10,36 @@ class Rule extends VariationOrRollout { private String id; private List clauses; + private boolean trackEvents; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Rule() { super(); } - Rule(String id, List clauses, Integer variation, Rollout rollout) { + Rule(String id, List clauses, Integer variation, Rollout rollout, boolean trackEvents) { super(variation, rollout); this.id = id; this.clauses = clauses; + this.trackEvents = trackEvents; + } + + Rule(String id, List clauses, Integer variation, Rollout rollout) { + this(id, clauses, variation, rollout, false); } String getId() { return id; } + Iterable getClauses() { + return clauses; + } + + boolean isTrackEvents() { + return trackEvents; + } + boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { if (!clause.matchesUser(store, user)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index ded8dbb7b..0902a770f 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -15,11 +15,15 @@ import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; +import static com.launchdarkly.client.TestUtil.makeClauseToMatchUser; +import static com.launchdarkly.client.TestUtil.makeClauseToNotMatchUser; import static com.launchdarkly.client.TestUtil.specificEventProcessor; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); @@ -257,6 +261,111 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } + @Test + public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { + Clause clause = makeClauseToMatchUser(user); + Rule rule = new RuleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + FeatureFlag flag = new FeatureFlagBuilder("flag") + .on(true) + .rules(Arrays.asList(rule)) + .offVariation(0) + .variations(js("off"), js("on")) + .build(); + featureStore.upsert(FEATURES, flag); + + client.stringVariation("flag", user, "default"); + + // Note, we did not call stringVariationDetail and the flag is not tracked, but we should still get + // tracking and a reason, because the rule-level trackEvents flag is on for the matched rule. + + assertEquals(1, eventSink.events.size()); + Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); + assertTrue(event.trackEvents); + assertEquals(EvaluationReason.ruleMatch(0, "id"), event.reason); + } + + @Test + public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() throws Exception { + Clause clause0 = makeClauseToNotMatchUser(user); + Clause clause1 = makeClauseToMatchUser(user); + Rule rule0 = new RuleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + Rule rule1 = new RuleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + FeatureFlag flag = new FeatureFlagBuilder("flag") + .on(true) + .rules(Arrays.asList(rule0, rule1)) + .offVariation(0) + .variations(js("off"), js("on")) + .build(); + featureStore.upsert(FEATURES, flag); + + client.stringVariation("flag", user, "default"); + + // It matched rule1, which has trackEvents: false, so we don't get the override behavior + + assertEquals(1, eventSink.events.size()); + Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); + assertFalse(event.trackEvents); + assertNull(event.reason); + } + + @Test + public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { + FeatureFlag flag = new FeatureFlagBuilder("flag") + .on(true) + .fallthrough(new VariationOrRollout(0, null)) + .variations(js("fall"), js("off"), js("on")) + .trackEventsFallthrough(true) + .build(); + featureStore.upsert(FEATURES, flag); + + client.stringVariation("flag", user, "default"); + + // Note, we did not call stringVariationDetail and the flag is not tracked, but we should still get + // tracking and a reason, because trackEventsFallthrough is on and the evaluation fell through. + + assertEquals(1, eventSink.events.size()); + Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); + assertTrue(event.trackEvents); + assertEquals(EvaluationReason.fallthrough(), event.reason); + } + + @Test + public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { + FeatureFlag flag = new FeatureFlagBuilder("flag") + .on(true) + .fallthrough(new VariationOrRollout(0, null)) + .variations(js("fall"), js("off"), js("on")) + .trackEventsFallthrough(false) + .build(); + featureStore.upsert(FEATURES, flag); + + client.stringVariation("flag", user, "default"); + + assertEquals(1, eventSink.events.size()); + Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); + assertFalse(event.trackEvents); + assertNull(event.reason); + } + + @Test + public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { + FeatureFlag flag = new FeatureFlagBuilder("flag") + .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH + .offVariation(1) + .fallthrough(new VariationOrRollout(0, null)) + .variations(js("fall"), js("off"), js("on")) + .trackEventsFallthrough(true) + .build(); + featureStore.upsert(FEATURES, flag); + + client.stringVariation("flag", user, "default"); + + assertEquals(1, eventSink.events.size()); + Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); + assertFalse(event.trackEvents); + assertNull(event.reason); + } + @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") @@ -357,6 +466,8 @@ private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, Jso assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); assertEquals(reason, fe.reason); + assertEquals(flag.isTrackEvents(), fe.trackEvents); + assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); } private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf, @@ -370,5 +481,7 @@ private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVa assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); assertEquals(reason, fe.reason); + assertFalse(fe.trackEvents); + assertNull(fe.debugEventsUntilDate); } } diff --git a/src/test/java/com/launchdarkly/client/RuleBuilder.java b/src/test/java/com/launchdarkly/client/RuleBuilder.java new file mode 100644 index 000000000..c9dd19933 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/RuleBuilder.java @@ -0,0 +1,44 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.client.VariationOrRollout.Rollout; + +import java.util.ArrayList; +import java.util.List; + +public class RuleBuilder { + private String id; + private List clauses = new ArrayList<>(); + private Integer variation; + private Rollout rollout; + private boolean trackEvents; + + public Rule build() { + return new Rule(id, clauses, variation, rollout, trackEvents); + } + + public RuleBuilder id(String id) { + this.id = id; + return this; + } + + public RuleBuilder clauses(Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public RuleBuilder variation(Integer variation) { + this.variation = variation; + return this; + } + + public RuleBuilder rollout(Rollout rollout) { + this.rollout = rollout; + return this; + } + + public RuleBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } +} diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 422db127d..2d681a9ff 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -176,6 +176,14 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } + public static Clause makeClauseToMatchUser(LDUser user) { + return new Clause("key", Operator.in, Arrays.asList(user.getKey()), false); + } + + public static Clause makeClauseToNotMatchUser(LDUser user) { + return new Clause("key", Operator.in, Arrays.asList(js("not-" + user.getKeyAsString())), false); + } + public static class DataBuilder { private Map, Map> data = new HashMap<>(); From 4a54961ec7017167abbbeb0869a4a99f07e40845 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 Feb 2019 17:18:09 -0800 Subject: [PATCH 138/156] fix test case --- src/main/java/com/launchdarkly/client/EventFactory.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 0038c8c9c..05b5e5925 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -68,6 +68,10 @@ public Event.Identify newIdentifyEvent(LDUser user) { } private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { + if (reason == null) { + // doesn't happen in real life, but possible in testing + return false; + } switch (reason.getKind()) { case FALLTHROUGH: return flag.isTrackEventsFallthrough(); From 67f4498041fc6776cdaa51608d198d7687a7dfee Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Feb 2019 17:49:19 -0800 Subject: [PATCH 139/156] perform orderly shutdown of event processor if it dies; process queue in chunks --- .../client/DefaultEventProcessor.java | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index d242ed623..76979e762 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -53,7 +53,7 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - new EventDispatcher(sdkKey, config, inputChannel, threadFactory); + new EventDispatcher(sdkKey, config, inputChannel, threadFactory, closed); Runnable flusher = new Runnable() { public void run() { @@ -105,7 +105,9 @@ private void postMessageAsync(MessageType type, Event event) { private void postMessageAndWait(MessageType type, Event event) { EventProcessorMessage message = new EventProcessorMessage(type, event, true); postToChannel(message); - message.waitForCompletion(); + if (!closed.get()) { + message.waitForCompletion(); + } } private void postToChannel(EventProcessorMessage message) { @@ -120,6 +122,11 @@ private void postToChannel(EventProcessorMessage message) { if (inputCapacityExceeded.compareAndSet(false, true)) { logger.warn("Events are being produced faster than they can be processed"); } + if (closed.get()) { + // Whoops, the event processor has been shut down + message.completed(); + return; + } } } catch (InterruptedException ex) { } @@ -178,6 +185,7 @@ public String toString() { // for debugging only */ static final class EventDispatcher { private static final int MAX_FLUSH_THREADS = 5; + private static final int MESSAGE_BATCH_SIZE = 50; static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private final LDConfig config; @@ -189,7 +197,8 @@ static final class EventDispatcher { private EventDispatcher(String sdkKey, LDConfig config, final BlockingQueue inputChannel, - ThreadFactory threadFactory) { + ThreadFactory threadFactory, + final AtomicBoolean closed) { this.config = config; this.busyFlushWorkersCount = new AtomicInteger(0); @@ -207,6 +216,25 @@ public void run() { } }); mainThread.setDaemon(true); + + mainThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + // The thread's main loop catches all exceptions, so we'll only get here if an Error was thrown. + // In that case, the application is probably already in a bad state, but we can try to degrade + // relatively gracefully by performing an orderly shutdown of the event processor, so the + // application won't end up blocking on a queue that's no longer being consumed. + public void uncaughtException(Thread t, Throwable e) { + logger.error("Event processor thread was terminated by an unrecoverable error. No more analytics events will be sent.", e); + // Flip the switch to prevent DefaultEventProcessor from putting any more messages on the queue + closed.set(true); + // Now discard everything that was on the queue, but also make sure no one was blocking on a message + List messages = new ArrayList(); + inputChannel.drainTo(messages); + for (EventProcessorMessage m: messages) { + m.completed(); + } + } + }); + mainThread.start(); flushWorkers = new ArrayList<>(); @@ -230,29 +258,33 @@ public void handleResponse(Response response) { private void runMainLoop(BlockingQueue inputChannel, EventBuffer buffer, SimpleLRUCache userKeys, BlockingQueue payloadQueue) { + List batch = new ArrayList(MESSAGE_BATCH_SIZE); while (true) { try { - EventProcessorMessage message = inputChannel.take(); - switch(message.type) { - case EVENT: - processEvent(message.event, userKeys, buffer); - break; - case FLUSH: - triggerFlush(buffer, payloadQueue); - break; - case FLUSH_USERS: - userKeys.clear(); - break; - case SYNC: - waitUntilAllFlushWorkersInactive(); - message.completed(); - break; - case SHUTDOWN: - doShutdown(); + batch.clear(); + batch.add(inputChannel.take()); // take() blocks until a message is available + inputChannel.drainTo(batch, MESSAGE_BATCH_SIZE - 1); // this nonblocking call allows us to pick up more messages if available + for (EventProcessorMessage message: batch) { + switch(message.type) { + case EVENT: + processEvent(message.event, userKeys, buffer); + break; + case FLUSH: + triggerFlush(buffer, payloadQueue); + break; + case FLUSH_USERS: + userKeys.clear(); + break; + case SYNC: // this is used only by unit tests + waitUntilAllFlushWorkersInactive(); + break; + case SHUTDOWN: + doShutdown(); + message.completed(); + return; // deliberately exit the thread loop + } message.completed(); - return; } - message.completed(); } catch (InterruptedException e) { } catch (Exception e) { logger.error("Unexpected error in event processor: {}", e.toString()); From 3d7ab6bfe5ff380342a01eb43a7a839b7ea666bb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Feb 2019 17:58:50 -0800 Subject: [PATCH 140/156] still need to be able to wait on a shutdown message when closing --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 76979e762..1c33a51a8 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -105,9 +105,7 @@ private void postMessageAsync(MessageType type, Event event) { private void postMessageAndWait(MessageType type, Event event) { EventProcessorMessage message = new EventProcessorMessage(type, event, true); postToChannel(message); - if (!closed.get()) { - message.waitForCompletion(); - } + message.waitForCompletion(); } private void postToChannel(EventProcessorMessage message) { From 52cd89552daa8f02ba8db0f9241f635970e0027e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Feb 2019 18:44:52 -0800 Subject: [PATCH 141/156] use long ints in summary event counters --- src/main/java/com/launchdarkly/client/EventOutput.java | 4 ++-- src/main/java/com/launchdarkly/client/EventSummarizer.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java index 7d471086e..9cb4341e1 100644 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ b/src/main/java/com/launchdarkly/client/EventOutput.java @@ -128,10 +128,10 @@ static final class SummaryEventCounter { final Integer variation; final JsonElement value; final Integer version; - final int count; + final long count; final Boolean unknown; - SummaryEventCounter(Integer variation, JsonElement value, Integer version, int count, Boolean unknown) { + SummaryEventCounter(Integer variation, JsonElement value, Integer version, long count, Boolean unknown) { this.variation = variation; this.value = value; this.version = version; diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/client/EventSummarizer.java index 97fb10daa..3c454914b 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/client/EventSummarizer.java @@ -132,11 +132,11 @@ public String toString() { } static final class CounterValue { - int count; + long count; final JsonElement flagValue; final JsonElement defaultVal; - CounterValue(int count, JsonElement flagValue, JsonElement defaultVal) { + CounterValue(long count, JsonElement flagValue, JsonElement defaultVal) { this.count = count; this.flagValue = flagValue; this.defaultVal = defaultVal; From 1c49f9b30e82a7019605f25148c5610ac9da931a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 21 Feb 2019 10:42:51 -0800 Subject: [PATCH 142/156] Revert "Merge pull request #116 from launchdarkly/eb/ch32302/experimentation-events" This reverts commit 29716e647e8018bad86eb16bad560c7db42f6365, reversing changes made to 67beb27a67a157ac61321f1da6270e40ec47ef0d. --- .../com/launchdarkly/client/EventFactory.java | 61 ++-------- .../com/launchdarkly/client/FeatureFlag.java | 9 +- .../client/FeatureFlagBuilder.java | 9 +- .../java/com/launchdarkly/client/Rule.java | 16 +-- .../client/LDClientEventTest.java | 113 ------------------ .../com/launchdarkly/client/RuleBuilder.java | 44 ------- .../com/launchdarkly/client/TestUtil.java | 8 -- 7 files changed, 11 insertions(+), 249 deletions(-) delete mode 100644 src/test/java/com/launchdarkly/client/RuleBuilder.java diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 05b5e5925..13559cf97 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -9,29 +9,11 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement value, - Integer variationIndex, EvaluationReason reason, JsonElement defaultValue, String prereqOf) { - boolean requireExperimentData = isExperiment(flag, reason); - return new Event.FeatureRequest( - getTimestamp(), - flag.getKey(), - user, - flag.getVersion(), - variationIndex, - value, - defaultValue, - prereqOf, - requireExperimentData || flag.isTrackEvents(), - flag.getDebugEventsUntilDate(), - (requireExperimentData || isIncludeReasons()) ? reason : null, - false - ); - } - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { - return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), - defaultVal, null); + return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), + defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue, @@ -49,9 +31,10 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { - return newFeatureRequestEvent(prereqFlag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), - null, prereqOf.getKey()); + return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), + null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { @@ -67,34 +50,6 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { - if (reason == null) { - // doesn't happen in real life, but possible in testing - return false; - } - switch (reason.getKind()) { - case FALLTHROUGH: - return flag.isTrackEventsFallthrough(); - case RULE_MATCH: - if (!(reason instanceof EvaluationReason.RuleMatch)) { - // shouldn't be possible - return false; - } - EvaluationReason.RuleMatch rm = (EvaluationReason.RuleMatch)reason; - int ruleIndex = rm.getRuleIndex(); - // Note, it is OK to rely on the rule index rather than the unique ID in this context, because the - // FeatureFlag that is passed to us here *is* necessarily the same version of the flag that was just - // evaluated, so we cannot be out of sync with its rule list. - if (ruleIndex >= 0 && ruleIndex < flag.getRules().size()) { - Rule rule = flag.getRules().get(ruleIndex); - return rule.isTrackEvents(); - } - return false; - default: - return false; - } - } - public static class DefaultEventFactory extends EventFactory { private final boolean includeReasons; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index f05f9c519..7cc7dde91 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -32,7 +32,6 @@ class FeatureFlag implements VersionedData { private List variations; private boolean clientSide; private boolean trackEvents; - private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; @@ -49,8 +48,7 @@ static Map fromJsonMap(LDConfig config, String json) { FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, - boolean clientSide, boolean trackEvents, boolean trackEventsFallthrough, - Long debugEventsUntilDate, boolean deleted) { + boolean clientSide, boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { this.key = key; this.version = version; this.on = on; @@ -63,7 +61,6 @@ static Map fromJsonMap(LDConfig config, String json) { this.variations = variations; this.clientSide = clientSide; this.trackEvents = trackEvents; - this.trackEventsFallthrough = trackEventsFallthrough; this.debugEventsUntilDate = debugEventsUntilDate; this.deleted = deleted; } @@ -181,10 +178,6 @@ public boolean isTrackEvents() { return trackEvents; } - public boolean isTrackEventsFallthrough() { - return trackEventsFallthrough; - } - public Long getDebugEventsUntilDate() { return debugEventsUntilDate; } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index e4111ab24..2d7d86832 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -19,7 +19,6 @@ class FeatureFlagBuilder { private List variations = new ArrayList<>(); private boolean clientSide; private boolean trackEvents; - private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; @@ -41,7 +40,6 @@ class FeatureFlagBuilder { this.variations = f.getVariations(); this.clientSide = f.isClientSide(); this.trackEvents = f.isTrackEvents(); - this.trackEventsFallthrough = f.isTrackEventsFallthrough(); this.debugEventsUntilDate = f.getDebugEventsUntilDate(); this.deleted = f.isDeleted(); } @@ -105,11 +103,6 @@ FeatureFlagBuilder trackEvents(boolean trackEvents) { this.trackEvents = trackEvents; return this; } - - FeatureFlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { - this.trackEventsFallthrough = trackEventsFallthrough; - return this; - } FeatureFlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { this.debugEventsUntilDate = debugEventsUntilDate; @@ -123,6 +116,6 @@ FeatureFlagBuilder deleted(boolean deleted) { FeatureFlag build() { return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); + clientSide, trackEvents, debugEventsUntilDate, deleted); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index 799340791..cee3d7ae0 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -10,36 +10,22 @@ class Rule extends VariationOrRollout { private String id; private List clauses; - private boolean trackEvents; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Rule() { super(); } - Rule(String id, List clauses, Integer variation, Rollout rollout, boolean trackEvents) { + Rule(String id, List clauses, Integer variation, Rollout rollout) { super(variation, rollout); this.id = id; this.clauses = clauses; - this.trackEvents = trackEvents; - } - - Rule(String id, List clauses, Integer variation, Rollout rollout) { - this(id, clauses, variation, rollout, false); } String getId() { return id; } - Iterable getClauses() { - return clauses; - } - - boolean isTrackEvents() { - return trackEvents; - } - boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { if (!clause.matchesUser(store, user)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 0902a770f..ded8dbb7b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -15,15 +15,11 @@ import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; -import static com.launchdarkly.client.TestUtil.makeClauseToMatchUser; -import static com.launchdarkly.client.TestUtil.makeClauseToNotMatchUser; import static com.launchdarkly.client.TestUtil.specificEventProcessor; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); @@ -261,111 +257,6 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } - @Test - public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { - Clause clause = makeClauseToMatchUser(user); - Rule rule = new RuleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); - FeatureFlag flag = new FeatureFlagBuilder("flag") - .on(true) - .rules(Arrays.asList(rule)) - .offVariation(0) - .variations(js("off"), js("on")) - .build(); - featureStore.upsert(FEATURES, flag); - - client.stringVariation("flag", user, "default"); - - // Note, we did not call stringVariationDetail and the flag is not tracked, but we should still get - // tracking and a reason, because the rule-level trackEvents flag is on for the matched rule. - - assertEquals(1, eventSink.events.size()); - Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.ruleMatch(0, "id"), event.reason); - } - - @Test - public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() throws Exception { - Clause clause0 = makeClauseToNotMatchUser(user); - Clause clause1 = makeClauseToMatchUser(user); - Rule rule0 = new RuleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); - Rule rule1 = new RuleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); - FeatureFlag flag = new FeatureFlagBuilder("flag") - .on(true) - .rules(Arrays.asList(rule0, rule1)) - .offVariation(0) - .variations(js("off"), js("on")) - .build(); - featureStore.upsert(FEATURES, flag); - - client.stringVariation("flag", user, "default"); - - // It matched rule1, which has trackEvents: false, so we don't get the override behavior - - assertEquals(1, eventSink.events.size()); - Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); - } - - @Test - public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") - .on(true) - .fallthrough(new VariationOrRollout(0, null)) - .variations(js("fall"), js("off"), js("on")) - .trackEventsFallthrough(true) - .build(); - featureStore.upsert(FEATURES, flag); - - client.stringVariation("flag", user, "default"); - - // Note, we did not call stringVariationDetail and the flag is not tracked, but we should still get - // tracking and a reason, because trackEventsFallthrough is on and the evaluation fell through. - - assertEquals(1, eventSink.events.size()); - Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.fallthrough(), event.reason); - } - - @Test - public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") - .on(true) - .fallthrough(new VariationOrRollout(0, null)) - .variations(js("fall"), js("off"), js("on")) - .trackEventsFallthrough(false) - .build(); - featureStore.upsert(FEATURES, flag); - - client.stringVariation("flag", user, "default"); - - assertEquals(1, eventSink.events.size()); - Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); - } - - @Test - public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") - .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH - .offVariation(1) - .fallthrough(new VariationOrRollout(0, null)) - .variations(js("fall"), js("off"), js("on")) - .trackEventsFallthrough(true) - .build(); - featureStore.upsert(FEATURES, flag); - - client.stringVariation("flag", user, "default"); - - assertEquals(1, eventSink.events.size()); - Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); - } - @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") @@ -466,8 +357,6 @@ private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, Jso assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); assertEquals(reason, fe.reason); - assertEquals(flag.isTrackEvents(), fe.trackEvents); - assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); } private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf, @@ -481,7 +370,5 @@ private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVa assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); assertEquals(reason, fe.reason); - assertFalse(fe.trackEvents); - assertNull(fe.debugEventsUntilDate); } } diff --git a/src/test/java/com/launchdarkly/client/RuleBuilder.java b/src/test/java/com/launchdarkly/client/RuleBuilder.java deleted file mode 100644 index c9dd19933..000000000 --- a/src/test/java/com/launchdarkly/client/RuleBuilder.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.VariationOrRollout.Rollout; - -import java.util.ArrayList; -import java.util.List; - -public class RuleBuilder { - private String id; - private List clauses = new ArrayList<>(); - private Integer variation; - private Rollout rollout; - private boolean trackEvents; - - public Rule build() { - return new Rule(id, clauses, variation, rollout, trackEvents); - } - - public RuleBuilder id(String id) { - this.id = id; - return this; - } - - public RuleBuilder clauses(Clause... clauses) { - this.clauses = ImmutableList.copyOf(clauses); - return this; - } - - public RuleBuilder variation(Integer variation) { - this.variation = variation; - return this; - } - - public RuleBuilder rollout(Rollout rollout) { - this.rollout = rollout; - return this; - } - - public RuleBuilder trackEvents(boolean trackEvents) { - this.trackEvents = trackEvents; - return this; - } -} diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 2d681a9ff..422db127d 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -176,14 +176,6 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } - public static Clause makeClauseToMatchUser(LDUser user) { - return new Clause("key", Operator.in, Arrays.asList(user.getKey()), false); - } - - public static Clause makeClauseToNotMatchUser(LDUser user) { - return new Clause("key", Operator.in, Arrays.asList(js("not-" + user.getKeyAsString())), false); - } - public static class DataBuilder { private Map, Map> data = new HashMap<>(); From 5d208ae3ac9670c2bf79244678747e1140e88a9e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 21 Feb 2019 16:48:56 -0800 Subject: [PATCH 143/156] track or identify without a valid user shouldn't send an event --- .../com/launchdarkly/client/LDClient.java | 6 +++-- .../client/LDClientEventTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 2429cde89..ca97cca0d 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -116,8 +116,9 @@ public void track(String eventName, LDUser user, JsonElement data) { } if (user == null || user.getKey() == null) { logger.warn("Track called with null user or null user key!"); + } else { + eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data)); } - eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data)); } @Override @@ -132,8 +133,9 @@ public void track(String eventName, LDUser user) { public void identify(LDUser user) { if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); + } else { + eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); } - eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); } private void sendFlagRequestEvent(Event.FeatureRequest event) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index ded8dbb7b..fead60c86 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -23,6 +23,7 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); + private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private FeatureStore featureStore = TestUtil.initedFeatureStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); @@ -43,6 +44,18 @@ public void identifySendsEvent() throws Exception { Event.Identify ie = (Event.Identify)e; assertEquals(user.getKey(), ie.user.getKey()); } + + @Test + public void identifyWithNullUserDoesNotSendEvent() { + client.identify(null); + assertEquals(0, eventSink.events.size()); + } + + @Test + public void identifyWithUserWithNoKeyDoesNotSendEvent() { + client.identify(userWithNullKey); + assertEquals(0, eventSink.events.size()); + } @Test public void trackSendsEventWithoutData() throws Exception { @@ -72,6 +85,18 @@ public void trackSendsEventWithData() throws Exception { assertEquals(data, ce.data); } + @Test + public void trackWithNullUserDoesNotSendEvent() { + client.track("eventkey", null); + assertEquals(0, eventSink.events.size()); + } + + @Test + public void trackWithUserWithNoKeyDoesNotSendEvent() { + client.track("eventkey", userWithNullKey); + assertEquals(0, eventSink.events.size()); + } + @Test public void boolVariationSendsEvent() throws Exception { FeatureFlag flag = flagWithValue("key", jbool(true)); From f48fcaf749e257b5a49c676fe0075a0a9add1e71 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Mar 2019 17:40:46 -0700 Subject: [PATCH 144/156] bump eventsource version so we're no longer getting JSR305 annotations --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fffb691c1..4800cd044 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.9.0", + "com.launchdarkly:okhttp-eventsource:1.9.1", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] From 5f75259c57ffc341ad39fbdbf44dd8e933aad72f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Mar 2019 17:50:28 -0700 Subject: [PATCH 145/156] remove special shading rule for javax annotations --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4800cd044..ea0be8864 100644 --- a/build.gradle +++ b/build.gradle @@ -221,7 +221,7 @@ def shadeDependencies(jarTask) { configurations.runtime.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. - unique().findAll { it != "javax" } // also, don't shade javax + unique() topLevelPackages.forEach { top -> jarTask.relocate(top, "com.launchdarkly.shaded." + top) { excludePackages.forEach { exclude(it + ".*") } From ebc6099113085590517c1fbf2da4e6fbe10d2957 Mon Sep 17 00:00:00 2001 From: torchhound <5600929+torchhound@users.noreply.github.com> Date: Fri, 29 Mar 2019 16:04:32 -0700 Subject: [PATCH 146/156] Removed .fossa.yml, removed fossa job from circleci, removed fossa badge from README --- .circleci/config.yml | 15 --------------- .fossa.yaml | 11 ----------- README.md | 1 - 3 files changed, 27 deletions(-) delete mode 100644 .fossa.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index 3e37ca009..696949512 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,16 +41,6 @@ jobs: name: run packaging tests command: cd packaging-test && make all - fossa: - docker: - - image: circleci/java - steps: - - checkout - - run: cp gradle.properties.example gradle.properties - - run: ./gradlew dependencies - - run: curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash - - run: fossa analyze - workflows: version: 2 test: @@ -67,8 +57,3 @@ workflows: branches: ignore: - gh-pages - - fossa: - filters: - branches: - ignore: - - gh-pages \ No newline at end of file diff --git a/.fossa.yaml b/.fossa.yaml deleted file mode 100644 index c2ebbaf7a..000000000 --- a/.fossa.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: 1 - -cli: - server: https://app.fossa.io -analyze: - modules: - - name: java-client - path: . - type: gradle - options: - task: dependencies \ No newline at end of file diff --git a/README.md b/README.md index 9b6d01a49..6f84d6f21 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ LaunchDarkly SDK for Java [![Circle CI](https://circleci.com/gh/launchdarkly/java-client.svg?style=shield)](https://circleci.com/gh/launchdarkly/java-client) [![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-client.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-client) -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client?ref=badge_shield) Supported Java versions ----------------------- From 221c10df8f6fc8df86ba58ed73ea91b5e92829e1 Mon Sep 17 00:00:00 2001 From: Ben Woskow Date: Mon, 1 Apr 2019 09:32:40 -0700 Subject: [PATCH 147/156] extract gradle properties clarification to separate PR --- gradle.properties | 2 -- gradle.properties.example | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 9d8d1e06c..94d60b5aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1 @@ version=4.6.3 -ossrhUsername= -ossrhPassword= diff --git a/gradle.properties.example b/gradle.properties.example index 67d9415ff..058697d17 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -1,3 +1,4 @@ +# To release a version of this SDK, copy this file to ~/.gradle/gradle.properties and fill in the values. githubUser = YOUR_GITHUB_USERNAME githubPassword = YOUR_GITHUB_PASSWORD signing.keyId = 5669D902 From 157a70904fdfe4969bd0372095aabdb4f1406818 Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Wed, 1 May 2019 13:20:49 -0700 Subject: [PATCH 148/156] artifact/repository rename + doc update (#125) * artifact/repository rename + doc update * cleaning up markdown files * cleaning up markdown files * cleaning up markdown files * cleaning up markdown files * absolute contributing url * adding "server-side" to the title * adding "server-side" to the title * relative link * add a note to the changelog about the artifact name change --- CHANGELOG.md | 24 +++++---- CONTRIBUTING.md | 49 +++++++++++------ README.md | 78 +++++++--------------------- build.gradle | 12 ++--- packaging-test/Makefile | 6 +-- packaging-test/test-app/build.gradle | 2 +- scripts/release.sh | 6 +-- scripts/update-version.sh | 2 +- settings.gradle | 2 +- 9 files changed, 82 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9602fba66..e03479ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,15 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +# Note on future releases + +The LaunchDarkly SDK repositories are being renamed for consistency. This repository is now `java-server-sdk` rather than `java-client`. + +The artifact names will also change. In the 4.6.3 release, the generated artifact was named `com.launchdarkly.client:launchdarkly-client`; in all future releases, it will be `com.launchdarkly.client:launchdarkly-java-server-sdk`. + ## [4.6.3] - 2019-03-21 ### Fixed -- The SDK uberjars contained some JSR305 annotation classes such as `javax.annotation.Nullable`. These have been removed. They were not being used in the public API anyway. ([#156](https://github.com/launchdarkly/java-client/issues/156)) +- The SDK uberjars contained some JSR305 annotation classes such as `javax.annotation.Nullable`. These have been removed. They were not being used in the public API anyway. ([#156](https://github.com/launchdarkly/java-server-sdk/issues/156)) - If `track` or `identify` is called without a user, the SDK now logs a warning, and does not send an analytics event to LaunchDarkly (since it would not be processed without a user). ## [4.6.2] - 2019-02-21 @@ -45,7 +51,7 @@ It is now possible to inject feature flags into the client from local JSON or YA ## [4.4.1] - 2018-10-15 ### Fixed: -- The SDK's Maven releases had a `pom.xml` that mistakenly referenced dependencies that are actually bundled (with shading) inside of our jar, resulting in those dependencies being redundantly downloaded and included (without shading) in the runtime classpath, which could cause conflicts. This has been fixed. ([#122](https://github.com/launchdarkly/java-client/issues/122)) +- The SDK's Maven releases had a `pom.xml` that mistakenly referenced dependencies that are actually bundled (with shading) inside of our jar, resulting in those dependencies being redundantly downloaded and included (without shading) in the runtime classpath, which could cause conflicts. This has been fixed. ([#122](https://github.com/launchdarkly/java-server-sdk/issues/122)) ## [4.4.0] - 2018-10-01 ### Added: @@ -67,7 +73,7 @@ It is now possible to inject feature flags into the client from local JSON or YA ## [4.3.0] - 2018-08-27 ### Added: - The new `LDClient` method `allFlagsState()` should be used instead of `allFlags()` if you are passing flag data to the front end for use with the JavaScript SDK. It preserves some flag metadata that the front end requires in order to send analytics events correctly. Versions 2.5.0 and above of the JavaScript SDK are able to use this metadata, but the output of `allFlagsState()` will still work with older versions. -- The `allFlagsState()` method also allows you to select only client-side-enabled flags to pass to the front end, by using the option `FlagsStateOption.CLIENT_SIDE_ONLY`. ([#112](https://github.com/launchdarkly/java-client/issues/112)) +- The `allFlagsState()` method also allows you to select only client-side-enabled flags to pass to the front end, by using the option `FlagsStateOption.CLIENT_SIDE_ONLY`. ([#112](https://github.com/launchdarkly/java-server-sdk/issues/112)) - The new `LDClient` methods `boolVariationDetail`, `intVariationDetail`, `doubleVariationDetail`, `stringVariationDetail`, and `jsonVariationDetail` allow you to evaluate a feature flag (using the same parameters as you would for `boolVariation`, etc.) and receive more information about how the value was calculated. This information is returned in an `EvaluationDetail` object, which contains both the result value and an `EvaluationReason` which will tell you, for instance, if the user was individually targeted for the flag or was matched by one of the flag's rules, or if the flag returned the default value due to an error. ### Fixed: @@ -97,7 +103,7 @@ It is now possible to inject feature flags into the client from local JSON or YA ## [4.1.0] - 2018-05-15 ### Added: -- The new user builder methods `customValues` and `privateCustomValues` allow you to add a custom user attribute with multiple JSON values of mixed types. ([#126](https://github.com/launchdarkly/java-client/issues/126)) +- The new user builder methods `customValues` and `privateCustomValues` allow you to add a custom user attribute with multiple JSON values of mixed types. ([#126](https://github.com/launchdarkly/java-server-sdk/issues/126)) - The new constant `VersionedDataKind.ALL` is a list of all existing `VersionedDataKind` instances. This is mainly useful if you are writing a custom `FeatureStore` implementation. ## [4.0.0] - 2018-05-10 @@ -124,7 +130,7 @@ It is now possible to inject feature flags into the client from local JSON or YA ## [3.0.2] - 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)). +- Improved performance when evaluating flags with custom attributes, by avoiding an unnecessary caught exception (thanks, [rbalamohan](https://github.com/launchdarkly/java-server-sdk/issues/113)). ## [3.0.1] - 2018-02-22 @@ -143,7 +149,7 @@ _This release was broken and should not be used._ ## [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)). +- Improved performance when evaluating flags with custom attributes, by avoiding an unnecessary caught exception (thanks, [rbalamohan](https://github.com/launchdarkly/java-server-sdk/issues/113)). ## [2.6.0] - 2018-02-12 @@ -223,7 +229,7 @@ _This release was broken and should not be used._ ## [2.2.1] - 2017-04-25 ### Fixed -- [#92](https://github.com/launchdarkly/java-client/issues/92) Regex `matches` targeting rules now include the user if +- [#92](https://github.com/launchdarkly/java-server-sdk/issues/92) Regex `matches` targeting rules now include the user if a match is found anywhere in the attribute. Before fixing this bug, the entire attribute needed to match the pattern. ## [2.2.0] - 2017-04-11 @@ -273,7 +279,7 @@ feature flag's existence. Thanks @yuv422! ## [2.0.3] - 2016-10-10 ### Added -- StreamingProcessor now supports increasing retry delays with jitter. Addresses [https://github.com/launchdarkly/java-client/issues/74[(https://github.com/launchdarkly/java-client/issues/74) +- StreamingProcessor now supports increasing retry delays with jitter. Addresses [https://github.com/launchdarkly/java-server-sdk/issues/74[(https://github.com/launchdarkly/java-server-sdk/issues/74) ## [2.0.2] - 2016-09-13 ### Added @@ -281,7 +287,7 @@ feature flag's existence. Thanks @yuv422! ## [2.0.1] - 2016-08-12 ### Removed -- Removed slf4j from default artifact: [#71](https://github.com/launchdarkly/java-client/issues/71) +- Removed slf4j from default artifact: [#71](https://github.com/launchdarkly/java-server-sdk/issues/71) ## [2.0.0] - 2016-08-08 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f907311fb..656464d56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,42 @@ -Contributing to the LaunchDarkly SDK for Java +Contributing to the LaunchDarkly Server-side SDK for Java ================================================ + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/java-server-sdk/issues) in the SDK repository. Bug reports and feature requests specific to this SDK should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 7. + +### Building -We encourage pull-requests and other contributions from the community. We've also published an [SDK contributor's guide](http://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. - - -Testing Proxy Settings -================== -Installation is your own journey, but your squid.conf file should have auth/access sections that look something like this: +To build the SDK without running any tests: +``` +./gradlew jar +``` +If you wish to clean your working directory between builds, you can clean it by running: ``` -auth_param basic program /usr/local/Cellar/squid/3.5.6/libexec/basic_ncsa_auth /passwords -auth_param basic realm proxy -acl authenticated proxy_auth REQUIRED -http_access allow authenticated -# And finally deny all other access to this proxy -http_access deny all +./gradlew clean ``` -The contents of the passwords file is: +If you wish to use your generated SDK artifact by another Maven/Gradle project such as [hello-java](https://github.com/launchdarkly/hello-java), you will likely want to publish the artifact to your local Maven repository so that your other project can access it. ``` -user:$apr1$sBfNiLFJ$7h3S84EgJhlbWM3v.90v61 +./gradlew publishToMavenLocal ``` -The username/password is: user/password +### Testing + +To build the SDK and run all unit tests: +``` +./gradlew test +``` diff --git a/README.md b/README.md index beb5a0e22..adbf2eeb7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ -LaunchDarkly SDK for Java +LaunchDarkly Server-side SDK for Java ========================= [![Circle CI](https://circleci.com/gh/launchdarkly/java-server-sdk.svg?style=shield)](https://circleci.com/gh/launchdarkly/java-server-sdk) -[![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-client.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-client) +[![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-java-server-sdk.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk) + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/docs/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) Supported Java versions ----------------------- @@ -14,47 +20,14 @@ Distributions Three variants of the SDK jar are published to Maven: -* The default uberjar - the dependency that is shown below under "Quick setup". This contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4J, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. +* The default uberjar - this is accessible as `com.launchdarkly:launchdarkly-java-server-sdk:jar` and is the dependency used in the "[Getting started](https://docs.launchdarkly.com/docs/java-sdk-reference#section-getting-started)" section of the SDK reference guide as well as in the [`hello-java`](https://github.com/launchdarkly/hello-java) sample app. This variant contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4J, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. * The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4J are also bundled, without shading (and are exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. -Quick setup +Getting started ----------- -0. Add the Java SDK to your project - - - - com.launchdarkly - launchdarkly-client - 4.6.3 - - - // or in Gradle: - "com.launchdarkly:launchdarkly-client:4.6.3" - -1. Import the LaunchDarkly package: - - import com.launchdarkly.client.*; - -2. Create a new LDClient with your SDK key: - - LDClient ldClient = new LDClient("YOUR_SDK_KEY"); - -Your first feature flag ------------------------ - -1. Create a new feature flag on your [dashboard](https://app.launchdarkly.com) -2. In your application code, use the feature's key to check wthether the flag is on for each user: - - LDUser user = new LDUser(username); - boolean showFeature = ldClient.boolVariation("your.feature.key", user, false); - if (showFeature) { - // application code to show the feature - } - else { - // the code to run if the feature is off - } +Refer to the [SDK reference guide](https://docs.launchdarkly.com/docs/java-sdk-reference#section-getting-started) for instructions on getting started with using the SDK. Logging ------- @@ -68,7 +41,7 @@ Be aware of two considerations when enabling the DEBUG log level: Using flag data from a file --------------------------- -For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. +For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. DNS caching issues ------------------ @@ -80,18 +53,17 @@ Unlike some other languages, in Java the DNS caching behavior is controlled by t Learn more ---------- -Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](http://docs.launchdarkly.com/docs/java-sdk-reference) or our [Javadocs](http://launchdarkly.github.io/java-server-sdk/). +Check out our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/docs/java-sdk-reference) or our [code-generated API documentation](https://launchdarkly.github.io/java-server-sdk/). Testing ------- We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. - Contributing ------------ -We encourage pull-requests and other contributions from the community. We've also published an [SDK contributor's guide](http://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. About LaunchDarkly ----------- @@ -101,22 +73,10 @@ About LaunchDarkly * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. -* LaunchDarkly provides feature flag SDKs for - * [Java](http://docs.launchdarkly.com/docs/java-sdk-reference "Java SDK") - * [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK") - * [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK") - * [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK") - * [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK") - * [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK") - * [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK") - * [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK") - * [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK") - * [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK") - * [Android](http://docs.launchdarkly.com/docs/android-sdk-reference "LaunchDarkly Android SDK") +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. * Explore LaunchDarkly - * [launchdarkly.com](http://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information - * [docs.launchdarkly.com](http://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDKs - * [apidocs.launchdarkly.com](http://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation - * [blog.launchdarkly.com](http://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies - diff --git a/build.gradle b/build.gradle index ea0be8864..ad3f59aaf 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ allprojects { ext { sdkBasePackage = "com.launchdarkly.client" - sdkBaseName = "launchdarkly-client" + sdkBaseName = "launchdarkly-java-server-sdk" // List any packages here that should be included in OSGi imports for the SDK, if they cannot // be discovered by looking in our explicit dependencies. @@ -170,7 +170,7 @@ task javadocJar(type: Jar, dependsOn: javadoc) { } githubPages { - repoUri = 'https://github.com/launchdarkly/java-client.git' + repoUri = 'https://github.com/launchdarkly/java-server-sdk.git' pages { from javadoc } @@ -327,7 +327,7 @@ nexusStaging { def pomConfig = { name 'LaunchDarkly SDK for Java' packaging 'jar' - url 'https://github.com/launchdarkly/java-client' + url 'https://github.com/launchdarkly/java-server-sdk' licenses { license { @@ -345,9 +345,9 @@ def pomConfig = { } scm { - connection 'scm:git:git://github.com/launchdarkly/java-client.git' - developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-client.git' - url 'https://github.com/launchdarkly/java-client' + connection 'scm:git:git://github.com/launchdarkly/java-server-sdk.git' + developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-server-sdk.git' + url 'https://github.com/launchdarkly/java-server-sdk' } } diff --git a/packaging-test/Makefile b/packaging-test/Makefile index b6d0c349e..bde75cb07 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -14,9 +14,9 @@ PROJECT_DIR=$(shell cd .. && pwd) SDK_VERSION=$(shell grep "version=" $(PROJECT_DIR)/gradle.properties | cut -d '=' -f 2) SDK_JARS_DIR=$(PROJECT_DIR)/build/libs -SDK_DEFAULT_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION).jar -SDK_ALL_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION)-all.jar -SDK_THIN_JAR=$(SDK_JARS_DIR)/launchdarkly-client-$(SDK_VERSION)-thin.jar +SDK_DEFAULT_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION).jar +SDK_ALL_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-all.jar +SDK_THIN_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-thin.jar TEMP_DIR=$(BASE_DIR)/temp TEMP_OUTPUT=$(TEMP_DIR)/test.out diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 100bbaa0f..02ba7b08f 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -14,7 +14,7 @@ allprojects { dependencies { // Note, the SDK build must have already been run before this, since we're using its product as a dependency - compileClasspath fileTree(dir: "../../build/libs", include: "launchdarkly-client-*-thin.jar") + compileClasspath fileTree(dir: "../../build/libs", include: "launchdarkly-java-server-sdk-*-thin.jar") compileClasspath "com.google.code.gson:gson:2.7" compileClasspath "org.slf4j:slf4j-api:1.7.21" compileClasspath "org.osgi:osgi_R4_core:1.0" diff --git a/scripts/release.sh b/scripts/release.sh index a8a435ed4..b276b53e5 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# This script updates the version for the java-client library and releases the artifact + javadoc +# This script updates the version for the java-server-sdk library and releases the artifact + javadoc # It will only work if you have the proper credentials set up in ~/.gradle/gradle.properties # It takes exactly one argument: the new version. @@ -9,10 +9,10 @@ # When done you should commit and push the changes made. set -uxe -echo "Starting java-client release." +echo "Starting java-server-sdk release." $(dirname $0)/update-version.sh $1 ./gradlew clean publish closeAndReleaseRepository ./gradlew publishGhPages -echo "Finished java-client release." +echo "Finished java-server-sdk release." diff --git a/scripts/update-version.sh b/scripts/update-version.sh index 028bfd3be..3b9695934 100755 --- a/scripts/update-version.sh +++ b/scripts/update-version.sh @@ -8,5 +8,5 @@ rm -f gradle.properties.bak # Update version in README.md: sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md -sed -i.bak "s/\"com.launchdarkly:launchdarkly-client:.*\"/\"com.launchdarkly:launchdarkly-client:${VERSION}\"/" README.md +sed -i.bak "s/\"com.launchdarkly:launchdarkly-java-server-sdk:.*\"/\"com.launchdarkly:launchdarkly-java-server-sdk:${VERSION}\"/" README.md rm -f README.md.bak diff --git a/settings.gradle b/settings.gradle index 80a446a15..231504193 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'launchdarkly-client' +rootProject.name = 'launchdarkly-java-server-sdk' From 786e432b82363589e6231a11aa20d0ba39469c13 Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Thu, 2 May 2019 10:27:37 -0700 Subject: [PATCH 149/156] Adding properties back to fix the latest failure in the java restwrapper build (#126) * re-introduce ossrh properties which were removed a while back * adding a comment * bump felix versions --- gradle.properties | 4 ++++ packaging-test/Makefile | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 47f2b938e..bbf2ea9d2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,5 @@ version=4.6.4 +# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework +# and should not be needed for typical development purposes (including by third-party developers). +ossrhUsername= +ossrhPassword= diff --git a/packaging-test/Makefile b/packaging-test/Makefile index bde75cb07..6a1f2a76c 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -29,7 +29,7 @@ SLF4J_SIMPLE_JAR=$(TEMP_DIR)/test-slf4j-simple.jar SLF4J_SIMPLE_JAR_URL=https://oss.sonatype.org/content/groups/public/org/slf4j/slf4j-simple/1.7.21/slf4j-simple-1.7.21.jar # Felix OSGi container -FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.2.tar.gz +FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.3.tar.gz FELIX_ARCHIVE_URL=http://apache.mirrors.ionfish.org//felix/$(FELIX_ARCHIVE) FELIX_DIR=$(TEMP_DIR)/felix FELIX_JAR=$(FELIX_DIR)/bin/felix.jar From d5cca571690b7548189df25a86d84864f6ef338d Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Tue, 21 May 2019 11:12:19 -0700 Subject: [PATCH 150/156] change mirror used to get felix (#127) --- packaging-test/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 6a1f2a76c..156e4e17e 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -30,7 +30,7 @@ SLF4J_SIMPLE_JAR_URL=https://oss.sonatype.org/content/groups/public/org/slf4j/sl # Felix OSGi container FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.3.tar.gz -FELIX_ARCHIVE_URL=http://apache.mirrors.ionfish.org//felix/$(FELIX_ARCHIVE) +FELIX_ARCHIVE_URL=http://mirrors.ibiblio.org/apache//felix/$(FELIX_ARCHIVE) FELIX_DIR=$(TEMP_DIR)/felix FELIX_JAR=$(FELIX_DIR)/bin/felix.jar TEMP_BUNDLE_DIR=$(TEMP_DIR)/bundles From 8c7cb6345ca28e333b393ba31568e26d9528c388 Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Tue, 21 May 2019 12:15:47 -0700 Subject: [PATCH 151/156] Add circleci jobs for supported java versions (#128) --- .circleci/config.yml | 83 +++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 696949512..c0a066c46 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,34 +1,63 @@ version: 2 -jobs: +test-template: &test-template + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - attach_workspace: + at: build + - run: java -version + - run: ./gradlew test + - run: + name: Save test results + command: | + mkdir -p ~/junit/; + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + when: always + - store_test_results: + path: ~/junit + - store_artifacts: + path: ~/junit + +jobs: build: docker: - - image: circleci/java - - image: redis + - image: circleci/openjdk:8u131-jdk # To match the version pre-installed in Ubuntu 16 and used by Jenkins for releasing steps: - checkout - run: cp gradle.properties.example gradle.properties + - run: java -version - run: ./gradlew dependencies - - run: ./gradlew test - - run: - name: Save test results - command: | - mkdir -p ~/junit/; - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; - when: always - - store_test_results: - path: ~/junit - - store_artifacts: - path: ~/junit + - run: ./gradlew jar - persist_to_workspace: root: build paths: - classes - + test-java8: + <<: *test-template + docker: + - image: circleci/openjdk:8 + - image: redis + test-java9: + <<: *test-template + docker: + - image: circleci/openjdk:9 + - image: redis + test-java10: + <<: *test-template + docker: + - image: circleci/openjdk:10 + - image: redis + test-java11: + <<: *test-template + docker: + - image: circleci/openjdk:11 + - image: redis packaging: docker: - - image: circleci/java + - image: circleci/openjdk:8 steps: + - run: java -version - run: sudo apt-get install make -y -q - checkout - attach_workspace: @@ -45,15 +74,19 @@ workflows: version: 2 test: jobs: - - build: - filters: - branches: - ignore: - - gh-pages + - build + - test-java8: + requires: + - build + - test-java9: + requires: + - build + - test-java10: + requires: + - build + - test-java11: + requires: + - build - packaging: requires: - build - filters: - branches: - ignore: - - gh-pages From bb188bd36703504a8fbcfd40684c6d34d928b32b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Jul 2019 14:20:49 -0700 Subject: [PATCH 152/156] rename inputChannel and buffer to inbox and outbox --- .../client/DefaultEventProcessor.java | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 1c33a51a8..f9d23a9fb 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -39,21 +39,22 @@ final class DefaultEventProcessor implements EventProcessor { private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; - private final BlockingQueue inputChannel; + private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicBoolean inputCapacityExceeded = new AtomicBoolean(false); DefaultEventProcessor(String sdkKey, LDConfig config) { - inputChannel = new ArrayBlockingQueue<>(config.capacity); + inbox = new ArrayBlockingQueue<>(config.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-EventProcessor-%d") + .setPriority(Thread.MIN_PRIORITY) .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - new EventDispatcher(sdkKey, config, inputChannel, threadFactory, closed); + new EventDispatcher(sdkKey, config, inbox, threadFactory, closed); Runnable flusher = new Runnable() { public void run() { @@ -111,11 +112,11 @@ private void postMessageAndWait(MessageType type, Event event) { private void postToChannel(EventProcessorMessage message) { while (true) { try { - if (inputChannel.offer(message, CHANNEL_BLOCK_MILLIS, TimeUnit.MILLISECONDS)) { + if (inbox.offer(message, CHANNEL_BLOCK_MILLIS, TimeUnit.MILLISECONDS)) { inputCapacityExceeded.set(false); break; } else { - // This doesn't mean that the output event buffer is full, but rather that the main thread is + // This doesn't mean that the outbox is full, but rather that the main thread is // seriously backed up with not-yet-processed events. We shouldn't see this. if (inputCapacityExceeded.compareAndSet(false, true)) { logger.warn("Events are being produced faster than they can be processed"); @@ -183,7 +184,7 @@ public String toString() { // for debugging only */ static final class EventDispatcher { private static final int MAX_FLUSH_THREADS = 5; - private static final int MESSAGE_BATCH_SIZE = 50; + private static final int MESSAGE_BATCH_SIZE = 1; static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private final LDConfig config; @@ -194,7 +195,7 @@ static final class EventDispatcher { private final AtomicBoolean disabled = new AtomicBoolean(false); private EventDispatcher(String sdkKey, LDConfig config, - final BlockingQueue inputChannel, + final BlockingQueue inbox, ThreadFactory threadFactory, final AtomicBoolean closed) { this.config = config; @@ -205,12 +206,12 @@ private EventDispatcher(String sdkKey, LDConfig config, // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - final EventBuffer buffer = new EventBuffer(config.capacity); + final EventBuffer outbox = new EventBuffer(config.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(config.userKeysCapacity); Thread mainThread = threadFactory.newThread(new Runnable() { public void run() { - runMainLoop(inputChannel, buffer, userKeys, payloadQueue); + runMainLoop(inbox, outbox, userKeys, payloadQueue); } }); mainThread.setDaemon(true); @@ -226,7 +227,7 @@ public void uncaughtException(Thread t, Throwable e) { closed.set(true); // Now discard everything that was on the queue, but also make sure no one was blocking on a message List messages = new ArrayList(); - inputChannel.drainTo(messages); + inbox.drainTo(messages); for (EventProcessorMessage m: messages) { m.completed(); } @@ -253,22 +254,22 @@ public void handleResponse(Response response) { * thread so we don't have to synchronize on our internal structures; when it's time to flush, * triggerFlush will hand the events off to another task. */ - private void runMainLoop(BlockingQueue inputChannel, - EventBuffer buffer, SimpleLRUCache userKeys, + private void runMainLoop(BlockingQueue inbox, + EventBuffer outbox, SimpleLRUCache userKeys, BlockingQueue payloadQueue) { List batch = new ArrayList(MESSAGE_BATCH_SIZE); while (true) { try { batch.clear(); - batch.add(inputChannel.take()); // take() blocks until a message is available - inputChannel.drainTo(batch, MESSAGE_BATCH_SIZE - 1); // this nonblocking call allows us to pick up more messages if available + batch.add(inbox.take()); // take() blocks until a message is available + inbox.drainTo(batch, MESSAGE_BATCH_SIZE - 1); // this nonblocking call allows us to pick up more messages if available for (EventProcessorMessage message: batch) { switch(message.type) { case EVENT: - processEvent(message.event, userKeys, buffer); + processEvent(message.event, userKeys, outbox); break; case FLUSH: - triggerFlush(buffer, payloadQueue); + triggerFlush(outbox, payloadQueue); break; case FLUSH_USERS: userKeys.clear(); @@ -315,13 +316,13 @@ private void waitUntilAllFlushWorkersInactive() { } } - private void processEvent(Event e, SimpleLRUCache userKeys, EventBuffer buffer) { + private void processEvent(Event e, SimpleLRUCache userKeys, EventBuffer outbox) { if (disabled.get()) { return; } // Always record the event in the summarizer. - buffer.addToSummary(e); + outbox.addToSummary(e); // Decide whether to add the event to the payload. Feature events may be added twice, once for // the event (if tracked) and once for debugging. @@ -353,13 +354,13 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even if (addIndexEvent) { Event.Index ie = new Event.Index(e.creationDate, e.user); - buffer.add(ie); + outbox.add(ie); } if (addFullEvent) { - buffer.add(e); + outbox.add(e); } if (debugEvent != null) { - buffer.add(debugEvent); + outbox.add(debugEvent); } } @@ -391,15 +392,15 @@ private boolean shouldDebugEvent(Event.FeatureRequest fe) { return false; } - private void triggerFlush(EventBuffer buffer, BlockingQueue payloadQueue) { - if (disabled.get() || buffer.isEmpty()) { + private void triggerFlush(EventBuffer outbox, BlockingQueue payloadQueue) { + if (disabled.get() || outbox.isEmpty()) { return; } - FlushPayload payload = buffer.getPayload(); + FlushPayload payload = outbox.getPayload(); busyFlushWorkersCount.incrementAndGet(); if (payloadQueue.offer(payload)) { // These events now belong to the next available flush worker, so drop them from our state - buffer.clear(); + outbox.clear(); } else { logger.debug("Skipped flushing because all workers are busy"); // All the workers are busy so we can't flush now; keep the events in our state From 822dbaaf4fae00b998a875942765a9ec51bca058 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Jul 2019 14:47:55 -0700 Subject: [PATCH 153/156] drop events if inbox is full --- .../client/DefaultEventProcessor.java | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index f9d23a9fb..24c4b06f3 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -35,7 +35,6 @@ final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); - private static final int CHANNEL_BLOCK_MILLIS = 1000; private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; @@ -105,31 +104,23 @@ private void postMessageAsync(MessageType type, Event event) { private void postMessageAndWait(MessageType type, Event event) { EventProcessorMessage message = new EventProcessorMessage(type, event, true); - postToChannel(message); - message.waitForCompletion(); + if (postToChannel(message)) { + message.waitForCompletion(); + } } - private void postToChannel(EventProcessorMessage message) { - while (true) { - try { - if (inbox.offer(message, CHANNEL_BLOCK_MILLIS, TimeUnit.MILLISECONDS)) { - inputCapacityExceeded.set(false); - break; - } else { - // This doesn't mean that the outbox is full, but rather that the main thread is - // seriously backed up with not-yet-processed events. We shouldn't see this. - if (inputCapacityExceeded.compareAndSet(false, true)) { - logger.warn("Events are being produced faster than they can be processed"); - } - if (closed.get()) { - // Whoops, the event processor has been shut down - message.completed(); - return; - } - } - } catch (InterruptedException ex) { - } + private boolean postToChannel(EventProcessorMessage message) { + if (inbox.offer(message)) { + return true; + } + // If the inbox is full, it means the EventDispatcher thread is seriously backed up with not-yet-processed + // events. This is unlikely, but if it happens, it means the application is probably doing a ton of flag + // evaluations across many threads-- so if we wait for a space in the inbox, we risk a very serious slowdown + // of the app. To avoid that, we'll just drop the event. The log warning about this will only be shown once. + if (inputCapacityExceeded.compareAndSet(false, true)) { + logger.warn("Events are being produced faster than they can be processed; some events will be dropped"); } + return false; } private static enum MessageType { From 0af23e52f23e756172ffae113c9884210917bdcc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Jul 2019 14:54:35 -0700 Subject: [PATCH 154/156] revert unintentional change --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 24c4b06f3..82d0d1c5d 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -175,7 +175,7 @@ public String toString() { // for debugging only */ static final class EventDispatcher { private static final int MAX_FLUSH_THREADS = 5; - private static final int MESSAGE_BATCH_SIZE = 1; + private static final int MESSAGE_BATCH_SIZE = 50; static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private final LDConfig config; From 8c689d609383f3e63a9c770b469b35c83270fbe9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Jul 2019 16:24:43 -0700 Subject: [PATCH 155/156] use a volatile boolean instead of an AtomicBoolean --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 82d0d1c5d..deff3b39d 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -41,7 +41,7 @@ final class DefaultEventProcessor implements EventProcessor { private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); - private final AtomicBoolean inputCapacityExceeded = new AtomicBoolean(false); + private volatile boolean inputCapacityExceeded = false; DefaultEventProcessor(String sdkKey, LDConfig config) { inbox = new ArrayBlockingQueue<>(config.capacity); @@ -117,7 +117,9 @@ private boolean postToChannel(EventProcessorMessage message) { // events. This is unlikely, but if it happens, it means the application is probably doing a ton of flag // evaluations across many threads-- so if we wait for a space in the inbox, we risk a very serious slowdown // of the app. To avoid that, we'll just drop the event. The log warning about this will only be shown once. - if (inputCapacityExceeded.compareAndSet(false, true)) { + boolean alreadyLogged = inputCapacityExceeded; + inputCapacityExceeded = true; + if (!alreadyLogged) { logger.warn("Events are being produced faster than they can be processed; some events will be dropped"); } return false; From d2f52f9425eb98ff45964535a4af3ff2ed0e438c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Jul 2019 16:25:14 -0700 Subject: [PATCH 156/156] comment --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index deff3b39d..5afb3fdec 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -117,7 +117,7 @@ private boolean postToChannel(EventProcessorMessage message) { // events. This is unlikely, but if it happens, it means the application is probably doing a ton of flag // evaluations across many threads-- so if we wait for a space in the inbox, we risk a very serious slowdown // of the app. To avoid that, we'll just drop the event. The log warning about this will only be shown once. - boolean alreadyLogged = inputCapacityExceeded; + boolean alreadyLogged = inputCapacityExceeded; // possible race between this and the next line, but it's of no real consequence - we'd just get an extra log line inputCapacityExceeded = true; if (!alreadyLogged) { logger.warn("Events are being produced faster than they can be processed; some events will be dropped");